Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component...
npx skills add alistaircroll/pagelove --skill "multi-file-data"
Install specific skill from multi-skill repository
# Description
Use when an app needs multiple HTML files acting as relational tables β foreign keys, URL parameter navigation, cross-file reads, pickers, and shape constraints
# SKILL.md
name: multi-file-data
description: "Use when an app needs multiple HTML files acting as relational tables β foreign keys, URL parameter navigation, cross-file reads, pickers, and shape constraints"
Multi-File Relational Data Pattern
When a single HTML file is not enough β when you need the equivalent of multiple database tables with foreign keys, one-to-many or many-to-many relationships β Pagelove supports a multi-file relational data pattern where each HTML file acts as a table, and URLs act as the query language.
SSPI note: For some cross-file data needs,
<pagelove:include>and Resource Binding may simplify or replace the client-side fetch + DOMParser pattern described below. Includes keep fragments writable at their origin. Resource Binding enables server-side site-wide queries. See thepagelove:sspiskill. Consider SSPI first for new apps.
When to Use This Pattern
Use multi-file relational data when:
- Your data model has two or more entity types that reference each other (e.g., customers and orders, projects and tasks, deals and contacts)
- You need foreign keys β records in one file that point to records in another
- You want bidirectional navigation β clicking a linked record should open it in its own file
- A single file would become unwieldy with too many unrelated data types
Architecture: One File Per Table
Each HTML file is a "table." Records are childless hidden <div> elements inside a container, with all data stored in data-* attributes:
<!-- File 1: orders.html -->
<div id="orders" style="display:none">
<div class="order-record" id="o-abc123"
data-id="abc123"
data-name="Widget order"
data-customer-ids="cust1,cust2"
data-customer-names="Jane Smith,Bob Lee"
data-updated="1700000000000"></div>
</div>
<!-- File 2: customers.html -->
<div id="customers" style="display:none">
<div class="customer-record" id="c-cust1"
data-id="cust1"
data-name="Jane Smith"
data-order-ids="abc123"
data-order-names="Widget order"
data-updated="1700000000000"></div>
</div>
Why childless divs? PUT on elements with children causes the server to empty them. By storing all data in data-* attributes on childless elements, PUT is safe. The visible UI is rendered separately by JavaScript β the data layer and render layer are fully decoupled.
Why display:none? The data containers are hidden. JS reads the data-* attributes and renders a UI (cards, tables, detail panels) independently. This lets you redesign the UI without touching the data model.
Foreign Keys via Comma-Separated IDs
Store relationships as comma-separated ID lists in data-* attributes:
data-customer-ids="cust1,cust2" <!-- foreign keys (IDs) -->
data-customer-names="Jane,Bob" <!-- denormalized names for display -->
Always store both IDs and display names. The IDs are the canonical references; the names are denormalized for rendering without cross-file fetches. Names may become stale if changed on the other page β this is acceptable for most applications.
Cross-File Navigation via URL Parameters
Since you cannot write to two files simultaneously from one page, relationships propagate via URL parameters. The URL is the inter-file communication layer.
The URL Parameter Protocol
Define a set of URL parameters that each file recognizes and processes on load:
| Parameter | Action |
|---|---|
?id=X |
Navigate to and display record X |
?new=1&related_id=X&related_name=N |
Open new-record form, pre-linked to a record in another file |
?add_relation=X&relation_name=N&record_id=R |
Link record X to record R, then update the data |
After processing URL parameters, clean the URL with history.replaceState(null, '', window.location.pathname) to prevent re-execution on refresh.
Example Flow: Linking Records Across Files
Linking a customer to an order (initiated from orders.html):
- User edits an order, clicks "+ Link Customer"
- A modal does a read-only cross-fetch of
customers.html, parses the#customerscontainer, shows a searchable picker - User selects a customer -> the order's
data-customer-idsanddata-customer-namesare updated, PUT - The customer chip becomes a link:
customers.html?id=X&add_order=O&order_name=N - When the user clicks through,
customers.htmlprocesses the URL params, adds the order to the customer'sdata-order-ids, PUTs
Creating a new record from the other file:
- User views a customer, clicks "Create Order"
- Navigates to
orders.html?new=1&customer_id=X&customer_name=Jane orders.htmlopens the new-order form with the customer pre-filled- On save: POST the order, then redirect to
customers.html?add_order=NEW_ID&order_name=N&customer_id=X
Cross-File Read (The Picker Pattern)
To let users pick records from another file, fetch that file's HTML and parse it:
async function openPicker() {
const resp = await fetch('/app/other-table.html');
const html = await resp.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const records = Array.from(doc.querySelectorAll('.record-class'));
// Render a searchable list from records' data-* attributes
// On selection, update the local record's foreign key attributes and PUT
}
This is a read-only operation β the picker fetches the other file's HTML, extracts record data from data-* attributes, and displays them. No write to the other file happens here.
Cross-File Data Display (Beyond Pickers)
The cross-file fetch pattern is not limited to pickers. Any page can fetch any other page's HTML and extract data for display, enrichment, or aggregation β turning multiple HTML files into a queryable data layer.
Practical uses:
| Pattern | Example |
|---|---|
| Unified timeline | A deal detail view fetches contacts.html, extracts notes tagged with that deal's ID, and merges them chronologically with the deal's own notes |
| Cross-file enrichment | A contact list fetches deals.html to show each linked deal's current stage badge inline |
| Dashboard aggregation | A dashboard view fetches both files, parses their data containers, and computes cross-file metrics (total pipeline by contact org, etc.) |
| Relationship validation | When displaying a linked record, fetch the source file to check if the record still exists and show current data |
// Example: Fetch notes from another file that reference a specific record
async function fetchCrossFileNotes(dealId) {
const resp = await fetch('/app/contacts.html');
const html = await resp.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const notes = Array.from(doc.querySelectorAll('.contact-note'));
return notes.filter(n => n.dataset.dealId === dealId);
}
// Example: Enrich linked records with live data from another file
async function enrichLinkedDeals(contactEl) {
const dealIds = (contactEl.dataset.deals || '').split(',').filter(Boolean);
if (!dealIds.length) return [];
const resp = await fetch('/app/deals.html');
const doc = new DOMParser().parseFromString(await resp.text(), 'text/html');
return dealIds.map(id => {
const deal = doc.querySelector(`.deal-record[data-id="${id}"]`);
return deal ? { id, name: deal.dataset.name, stage: deal.dataset.stage, value: deal.dataset.value } : null;
}).filter(Boolean);
}
Key constraint: Cross-file fetches are always read-only. You cannot write to another file from JavaScript β writes happen only to the current page. To modify data in another file, use URL parameter navigation.
Performance best practices:
- Fetch on demand, not on every poll cycle. Trigger cross-file reads when a user opens a detail view or dashboard, not on the 2-4s polling interval.
- Cache within the session. Store parsed cross-file data in a variable and reuse it until the user takes an action that would invalidate it.
- Parse once, query many. Fetch the full HTML once, parse it with DOMParser, then run multiple querySelectorAll calls against the parsed document.
- Don't poll multiple files simultaneously. Each file should poll only its own data containers. Cross-file reads are event-driven (user clicks, view switches), not timer-driven.
Bidirectional Relationship Propagation
The key challenge: when you link A->B in file 1, you also need B->A in file 2. Since you can only write to the current file, propagation happens via navigation:
- File 1 updates its own record (adds the foreign key, PUTs)
- File 1 generates a URL that carries the reverse-link command
- User navigates (or is redirected) to file 2 with that URL
- File 2 processes the URL parameters, updates its own record, PUTs
- File 2 cleans the URL with
replaceState
This is inherently eventually consistent. If the user does not follow the link, the reverse relationship will not be written. For most applications, this is acceptable.
Denormalized Names and Staleness
Each record stores both the IDs and display names of linked records. If a name changes in one file, the denormalized copy in the other file becomes stale. Mitigation strategies:
- Accept staleness. Names rarely change, and they refresh whenever a user navigates between pages (the URL carries current names).
- Refresh on view. When displaying a detail view with linked records, optionally cross-fetch the other file and update names if they've changed.
- No real-time cross-file sync. Don't try to poll both files simultaneously.
Authorization Rules for Multi-File Apps
Each file needs its own set of authz rules. For a two-file app:
File 1 (e.g., /app/orders.html):
GET β public read
PUT β .order-record (update records)
POST β #orders, #activity-log (create records, log actions)
DELETE β .order-record, .log-entry (remove records)
File 2 (e.g., /app/customers.html):
GET β public read
PUT β .customer-record
POST β #customers, #activity-log
DELETE β .customer-record, .log-entry
Activity Log Pattern
Include an #activity-log container in each file for POST-only append logging:
<div id="activity-log" style="display:none"></div>
Log entries are childless <div class="log-entry"> elements with data-time, data-text, data-ts attributes. POST new entries to record who did what. Display them in the UI by reading and rendering the container contents.
Shared Navigation Bar
Multi-file apps should share a consistent navigation bar:
<nav class="app-nav">
<a href="/app/orders.html" class="logo">App<span>Name</span></a>
<a href="/app/orders.html" class="tab active">Orders</a>
<a href="/app/customers.html" class="tab">Customers</a>
</nav>
Highlight the active tab based on the current page.
Key Constraints and Trade-offs
- No atomic cross-file writes. You can only write to the file you are currently viewing. Relationship propagation requires user navigation.
- Eventual consistency between files. Denormalized names may lag. Design around it.
- Polling is per-file. Each file polls its own
#recordscontainer independently. No cross-file polling needed. - Cross-file fetch is read-only. The picker pattern reads the other file's HTML but never writes to it directly.
- URL params are the RPC layer. Treat URL parameters like API calls β define a clear protocol, validate inputs, clean URLs after processing.
- ID generation must be globally unique. Use
Date.now().toString(36) + Math.random().toString(36).slice(2,7)or similar. Prefix IDs with a type marker (e.g.,d-for deals,c-for contacts) to disambiguate across files.
Scaling Beyond Two Files
This pattern extends to three or more files. Each file defines its URL parameter protocol, and any file can cross-fetch any other file for read-only picker operations. The constraint remains: writes only happen to the current file, with propagation via URL-parameter navigation.
For complex multi-file apps, consider a hub-and-spoke model: one central file (e.g., a dashboard) that links to entity-specific files, each of which can cross-reference the others.
Shape Constraints
Shape Constraints enable structural validation of document modifications using CSS selectors. They answer: "When something is modified, what structure must exist for that modification to be accepted?"
ShapeConstraint Schema
<div hidden="" itemscope="" itemtype="https://pagelove.org/ShapeConstraint">
<span itemprop="resource">/app/*</span>
<span itemprop="selector">#items li</span>
<span itemprop="constraint">:has([itemprop=name])</span>
<span itemprop="constraint">:has(button.delete)</span>
</div>
| Property | Type | Cardinality | Meaning |
|---|---|---|---|
resource |
Text | 0..n | Resource path patterns to scope the constraint; omission = global (all files) |
selector |
Text | 0..n | CSS selector for elements to constrain; defaults to :root if omitted |
constraint |
Text | 1..n | CSS selectors that must match within the modified element's subtree |
Validation Process
- Identify matching ShapeConstraint declarations by resource path (glob patterns supported)
- Check if the request target matches the constraint's
selector - Evaluate all
constraintselectors against the proposed DOM state - Reject if any constraint selector fails to match
Error Responses
422 Unprocessable Contentβ returned when a POST or PUT violates a constraint. No partial modifications are applied.409 Conflictβ returned when a DELETE operation would cause the closest matching ancestor to fail its constraints.
Practical Example
Require that user list items always contain both username and email:
<div hidden="" itemscope="" itemtype="https://pagelove.org/ShapeConstraint">
<span itemprop="selector">li[itemtype*=User]</span>
<span itemprop="constraint">:has([itemprop="username"])</span>
<span itemprop="constraint">:has([itemprop="email"])</span>
</div>
ShapeConstraints are the only server-side validation Pagelove offers. Use them to prevent malformed elements from being persisted. Define constraints for every POST target to ensure children have required attributes.
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.