This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
- Editable regions are any elements with the
rdfa-editor-contentclass token (host-page chrome, canonicalization-stripped). Noid('content')singletons anywhere:rdfae:roots(),rdfae:root-of(),rdfae:active-root()(selection, then last focused host, then first region) inedit.xsl. - Undo is region-keyed: one global stack, each stash entry carries
data-root(region index) and restores only its region; caret data is region-relative. Stash ids:rdfa-editor-undo-storage/-stack/-redo-stack. - Blocks never move between regions (drop-target resolution is scoped to the dragged block's region). ToC and view-source follow the active region (
tocRootremembered at render); lint and find work across all regions. - Editor state lives on one window container
window.rdfaEditor— a single object created withObject()in themaintemplate (mirrors LinkedDataHub'swindow.LinkedDataHub), holding all mutable state under bare names (range,activeBlock,editingSpan, …). Every read/write goes through therdfae:editor-state()accessor (index.xsl): readsixsl:get(rdfae:editor-state(), 'range'), writesobject="rdfae:editor-state()". Browser globals (scrollX,Date,location.href, …) are read straight offixsl:window()and are not editor state. Editor UI containers carry therdfa-editor-uiclass and all generic CSS selectors (.btn-*,.modal-*,.crumb, …) are scoped under it — LDH/Bootstrap pages stay unaffected. - The extractor entry is a named template only (
rdfax:extract-rdfa, invoked headless as-it:"Q{https://w3id.org/atomgraph/rdfa-editor/rdfa#}extract-rdfa"; no unnamed-modematch="/") so it composes with host stylesheets' root templates. Integration with LinkedDataHub's client.xsl is compile-proven (see docs/ldh/MIGRATION.md §10). - Links in content: plain click places the caret (links render with a text I-beam, not a pointer, so the affordance matches — they are editable text); Ctrl/Cmd+Click opens the href. The annotation overlay paints the stored selection as
.rdfa-editor-selection-hintboxes (no content mutation), cleared on hide.
RDFa-Editor is a prototype XHTML+RDFa authoring tool using client-side XSLT. Content lives in a #content container of structured blocks (p, h1–h3, ul/ol, blockquote, pre, figure, table), always editable Notion-style: typing within blocks, Enter splits, Backspace-at-start merges, toolbar for block types / inline formatting / lists / figures / tables / links, drag-handle reordering (nested blocks included — every %block drags, drops land wherever the content model admits it), a / slash menu (in an empty block) and markdown shortcuts (# , - , 1. , > , ```) that insert/convert blocks per the content model. Object blocks (blocks.xsl) embed RDF-defined blocks per the LDH v6 document format (XHTML-RDFa-as-LDH-v6-Document-Format wiki) in two kinds: a defined block — a div[@about][@typeof] whose @typeof matches the $object-block-types param, fragment @about (a document part), its defining triples inline as span[@property] children — and a reference block — an empty div whose absolute @about names a resource outside the document (no ldh:Object wrapper, no rdf:value indirection — naming the resource IS the reference, and the empty div extracts to zero triples). Both are treated as atomic islands (never editable inside, focusable/navigable/deletable like block images, hard merge/delete boundaries) and rendered client-side into an ephemeral div[@data-role='rendering'] child via the overridable mode="rdfae:render-island" hook; the LinkedDataHub extension (ldh-blocks.xsl, layered by the ldh-editor.xsl entry via xsl:import) supplies ldh:View/ldh:ResultSetChart renderers plus the reference dereferencer (conneg on the @about URI — a DBpedia resource works directly, CORS + 303 to RDF/XML), an insert dialog and slash/toolbar contributions. Nesting follows the XHTML Strict content model (see content-model.xsl below): blockquote is a block container (blockquote > p+ — bare text inside it is invalid and gets normalized; the toolbar quote toggle wraps/unwraps), li/td/th/dd/figcaption are %Flow; (mixed text AND nested blocks — Tab/Shift+Tab indent/outdent nested lists), p/h1–h6/caption/dt are inline-only. Tables are composite blocks (structure locked; td/th/caption editable) inserted via a rows×cols dialog with an optional header row and caption; row/column toolbar controls, Tab/Shift+Tab cell traversal and Enter (step down a column) all grow the grid at its bottom edge. Right-click a text selection to annotate it with RDFa; right-click an existing annotation to edit or remove it. "Extract RDF" shows the page's triples; "Source" shows the canonical XHTML+RDFa serialization (all editing ephemera stripped) in Exclusive XML Canonicalization form (<br></br>, sorted attributes/namespace declarations, no indentation — the rdf:XMLLiteral value space).
XSLT 3.0 running on SaxonJS 3 with its interactive extensions (ixsl: namespace). index.html loads lib/SaxonJS3.js plus lib/xml-c14n-sync.js (the exclusive-c14n serializer LinkedDataHub also ships — the "Source" view calls it through rdfae:canonicalize-xml in edit.xsl, a port of LDH's ldh:canonicalize-xml: serialize() → parse-xml() bridges XDM to browser DOM, which the lib walks) and executes dist/index.xsl.sef.json (initial template main); async work uses the SaxonJS 3 promise API (ixsl:promise + ixsl:http-request => ixsl:then, on-failure; DOM-mutating callbacks are ixsl:updating="yes" functions) — never the legacy ixsl:schedule-action. The editor UI is fully generated by the stylesheet — the overlay is rendered once at startup (hidden) and only populated/shown/hidden afterwards, so the stylesheet is self-contained and embeddable on any host page (host pages must include both libs).
Two build flavors: core (src/index.xsl → dist/index.xsl.sef.json, deployed to GitHub Pages — deploy-pages.yml excludes the LDH artifacts) and LDH-extended (src/ldh-editor.xsl → dist/ldh-editor.xsl.sef.json, used by demo/index.html and the browser fixtures — local only, its object blocks need the content-negotiating dev server).
Modules under src/:
- index.xsl — the core entry point:
maintemplate (fully synchronous — the host page preloads the vocabularies into the SaxonJSdocumentPoolkeyed by page-relative URI, see index.html), editor-state container init,rdfae:editor-state(); the module include list. - RDFa2RDFXML-v3.xsl — RDFa 1.1 → RDF/XML extraction. Pure XSLT, no
ixsl:— it runs headless viaxslt3for the test suite. Strictly W3C-conformant (RDFa 1.1 processing rules, section 7.5, including chaining via typed resources andrdfa:usesVocabulary). Covers@about/@typeof/@property/@content/@datatype/@resource/@href/@src/@prefix(andxmlns:*)/@vocabwith bare terms,@lang+@xml:langinheritance, base-URI resolution (about=""= the document,<base href>honored), and exclusion ofhead/script/style/[data-role='rendering']subtrees. Out of scope (documented future work):@rel/@rev,@inlist, safe CURIEs,@datetime/<time>,xml:base. - content-model.xsl — the XHTML content model as data. Pure XSLT, include-free (no dependencies;
index.xslincludes it once, before its consumers): a 1:1 transcription of the XHTML 1.0 Strict DTD (Second Edition, Appendix A.1 entities + element declarations) and the Appendix B prohibitions, extended with HTML5figure/figcaption.$cm:model(element → allowed children +#PCDATAflag),$cm:prohibitions, predicatescm:known/cm:block/cm:inline/cm:allows-child/cm:allows-text/cm:inline-only/cm:flow/cm:structural/cm:valid-nesting. The single source of truth for nesting: editability init and keyboard machines (edit.xsl), boundary normalization (canonical-xhtml.xsl) and nesting lint (lint-xhtml.xsl) all consult it, so verdicts can't drift.$cm:modelis built withmap:merge+map:entry— the SaxonJS compiler fails an internal assertion on large nested map literals. The editor contract "region children are blocks" is deliberately NOT in this module. - vocab.xsl — generic ontology RDF/XML → term maps (
rdfae:vocab-terms→{uri, label}, consumed by the typeahead). Handles typed elements (<owl:Class rdf:about>) andrdf:Description+rdf:typeshapes (including striped one-triple-per-description files like DCMI terms); labels as attributes or child elements; terms outside the ontology namespace filtered out. - overlay.xsl — the annotation form (single copy), form reading (
rdfae:form-valuesmap), pre-filling (rdfae:populate-form), show/hide with viewport clamping (rdfae:show-at, shared with the edit dialogs). The property/type fields are typeaheads (typeahead.xsl); the datatype field stays a<select>(short XSD list) with ahttps://w3id.org/atomgraph/rdfa-editor#customsentinel + free-textcustom-datatypeinput and the datatype↔lang mutual-exclusion handler. - typeahead.xsl — autocomplete lookups for the property and type fields (replacing the long dropdowns; LDH's typeahead interaction ported with local synchronous filtering — no SPARQL/debounce, since vocabularies are already pooled). A stable wrapper
span[@data-field]holds one of two states, swapped viaresult-documentliteral result elements: typing (input.typeahead-input+ul.typeahead-menu) or committed (button.typeahead-value+ hidden input carrying the IRI, LDH-style). Filtering re-renders per keystroke fromrdfae:vocab-terms(prefix-ranked, capped, bold-highlighted match); keyboard nav mirrors the slash menu (Arrow/Enter/Escape, first option pre-selected); options commit onixsl:onmousedown(before the input'sfocusout, childrenpointer-events:noneso the<li>is the target).rdfae:typeahead-valuereads the committed hidden IRI, else a free-typed absolute IRI (rdfae:is-absolute-iri), else the untouched button→edit value parked on@data-editing-iri(cleared on the first keystroke — the stale-selection guard). Escape closes the menu else the overlay (the innermost template fully owns the input's keys). - annotate.xsl — the unified right-click dispatcher (edit vs create),
rdfae:apply-annotation(single write path for RDFa attributes), sharedrdfae:wrap-range/rdfae:unwrap-element(used by annotations AND inline formatting), invalid-selection flash, the shared output modal (rdfae:show-output; callers passing a filename get a Download button — the shown text becomes a client-side Blob object-URL download, so it works on static hosting like gh-pages; the lint modal passes none and hides it). - edit.xsl — the XHTML editor. Recursive editability init (
rdfae:init-block): an element is a text host (contenteditable) while its content model allows text and it holds no block children; mixed flow containers (li/td/dd/… with blocks) and structural containers (blockquote, lists, figure, table) lock their own markup and recurse into their parts (figureis always composite: image island + caption host);imgnever editable but giventabindex="-1"so a block image is a focusable navigation island; an object block (rdfae:island, FIRST branch of the choose — a flow div with span children would otherwise become a text host / get its spans run-wrapped) is locked whole, giventabindex="-1"+ therdfa-editor-islandmarker class and rendered via the hook when it has no rendering div. Run wrappers: the stray inline runs of a mixed flow container (<li>text<ul>…</ul></li>) get an ephemeralp.rdfa-editor-runhost (rdfae:wrap-stray-runs) so they stay editable — unwrapped again at canonicalization (C11), so mixed content round-trips byte-identical; structural gestures promote a wrapper to a real paragraph. Load-init normalization: each region is probed for invalid nesting (cm:valid-nesting, stray text in structural containers, stray region-level inline) and rebuilt throughmode="cm-normalize"only when invalid (the valid case is zero-churn; host-page node references into a rewritten region go stale, same as undo restore). First-child chrome injection covers every draggable block (rdfae:draggable-block: a real%block— never a run wrapper,hror ephemera — whose parent is a region, acm:flowcontainer or ablockquote— an object-block island qualifies like any div, nested islands carry handles too, while nothing inside one ever does): nested blocks carry their own handles, stacked per nesting level, each hover-revealed at its block's own gutter indent (the editing view indentsblockquotecontent so quote-child handles don't coincide with the quote's);rdfae:ensure-chromeinrdfae:after-mutationconverges handles after every gesture, so mutation sites never thread their own injection. The Enter/Backspace state machine (split/merge primitives with explicit caret placement): splitting a run wrapper promotes it (marker class off); E4a — Enter on the empty last item of a nested list outdents one level (progressive), E4b — top-level exits to a paragraph; B2 merges text blocks, B2b descends into a preceding non-composite container's (blockquote/list) last host — bare text in the container itself would be invalid — while tables/figures/islands stay hard boundaries (B3; an empty host removed before an island selects the island — no host to land in); B4 merges anliinto the visually preceding line (rdfae:merge-host-in— the last host of the previous item, unless it sits inside a nested table/figure/island OR an island follows it at the container's tail (islands hold no hosts, the lookup would leapfrog them): composites are hard boundaries at any depth, the gesture stays inert), B4b outdents the first item of a nested list, B4c merges the first item of a container-nested list (cell, quote, dd) into the preceding line inside the container — dissolving the emptied list and collapsing the container back to a text host — B7 exits the first block of a blockquote upward (emptied quote removed); E4b anchors the list-exit paragraph before removal, so it lands where the list was (inside the cell/quote for nested lists); E6 anchors the caption-exit paragraph after the figure/table itself (which may be nested). Ancestor walks clamp at the region root ([exists(rdfae:block-of(.))]onrdfae:item-of, the Tab cell lookup and the quote-toggle's blockquote lookup) — a host-page li/td/blockquote wrapping an embedded region is never touched. A focused image island deletes its figure when it has one, else just the image — never the whole containing list/table. Tab/Shift+Tab indent/outdent list items (rdfae:list-indent/rdfae:list-outdent: indent moves the item into a trailing-or-fresh nested list inside the previous item perul → (li)+; outdent moves it after the container item, demoting followers into a nested list inside it;rdfae:collapse-containerreverts an emptied container to a text host; extremes flash) — the innermost context wins over table-cell traversal (li-in-td indents, a table nested in an item traverses). The quote toggle (format-quote) wraps the current host block in ablockquotewhere the parent admits one / unwraps all children out (refused with a flash when the quote carries RDFa — triples would drop); the block-type select is host-based (rdfae:current-host): a paragraph inside a quote converts alone, non-convertible hosts disable it. The insert buttons (+¶, lists) place the new block per the content model (rdfae:insert-block-at-caret): after the caret's host when its parent admits the kind, inside a leaf flow host otherwise (a list inserted in a cell nests in the cell; in a list item it becomes a sublist), after the top-level block as a last resort. Paste is cm-driven: clipboard HTML runsmode="canonical"+mode="cm-normalize", then blocks land inside a mixed flow host (rdfae:paste-into-flow-host— head/tail runs wrapped, host re-inited as container), or as siblings of an inline-only host via split-and-thread (whencm:allows-childpermits), or flatten to text (caption, dt). Drag-and-drop ported from LinkedDataHub'sclient/block.xsl(handle-gateddraggable, midpoint before/after marks) and extended to nested blocks with a content-model-validated drop resolver: the target is the innermost draggable block under the pointer whose container accepts the dragged kind (rdfae:deepest-block-at+rdfae:legal-drop-levelclimbing to the nearest legal level — the region accepts any block, so the climb terminates and an illegal drop is clamped, never created); gutter/gap drops fall back to the nearest top-level block, so dropping in the left gutter lifts a nested block all the way out; cells take "into" drops (nothing can legally drop before/after atd/th, so a cell's pixels away from any block it holds mean into it — the text host becomes a container per therdfae:insert-block-at-caretflow doctrine, marked by an insetdrop-intobox; a list item deliberately stays a clamp to around-the-list, since its pixels compete with reordering); the vacated origin is repaired at commit (rdfae:prune-husksfor emptied structural containers — the B7 doctrine — thenrdfae:collapse-containerfor a flow container that lost its last block); transient drag state (dragging/draggable/emptyclassleftovers —rdfae:tidy-class) is stripped before the undo snapshot; drops stay region-scoped (blocks never move between regions); an object-block island moves as an atomic unit and is never a drop receiver —rdfae:deepest-block-atcan't resolve inside one (its interior is inline spans plus the ephemeral rendering subtree), and the cell "into" resolver skips cells inside ephemera/island interiors (a rendered chart or reference card paints realtds — external rendering, not drop zones): pointing anywhere over an island means before/after the island itself. Arrow-key block crossing walksrdfae:nav-targets(editable hosts plus block images plus object-block islands, in document order; nothing inside an island or ephemeral subtree is a stop): a host gets a caret, an image/island is selected (rdfae:select-islandfocuses it, clearing the caret) — so islands are never skipped; on a selected island, arrows step to the adjacent target, Backspace/Delete removes the unit whole (an image goes with its figure, an object block deletes itself; no confirm, undo-covered) and undo/redo chords work too (the island keydown template intercepts them — host/body templates don't fire with focus on an island). Table cell keys (Tab/Enter) and the table dialog live in tables.xsl. - blocks.xsl — the generic object-block (island) machinery, zero LDH knowledge:
$object-block-types(absolute class IRIs, default EMPTY — re-declared at higher import precedence by an extension entry; a same-precedence duplicate is XTSE0630), THE island predicaterdfae:island($e)(divwhosetokenize(@typeof)hits the param, OR a reference block perrdfae:reference-block($e): an effectively-empty div — ephemera and whitespace aside — whose@aboutis an absolute URI naming a different document than the page (string tests only, noresolve-uri, so a malformed@aboutcan't error out of a hot predicate; relative/fragment@aboutnever islands — document parts stay blocks,about-relativestays a lint case) — every island decision in init/nav/boundaries/delete-machine/undo routes through it), the render hookmode="rdfae:render-island"(context item = the island div; contract: inject exactly ONEdiv[@data-role='rendering']as last child viardfae:replace-rendering— async renderers only in the completion callback, loading state = ephemeralrdfa-editor-loadingclass, so "no rendering div" always means "render needed" and the undo-restore re-render pass keys on that; never touch the spans; never push undo; idempotent), the neutral default card, and the empty extension hook stubsrdfae:render-extra-dialogs/-insert-buttons/-slash-items,rdfae:run-extra-slash-command(called from edit.xsl's init/toolbar and input.xsl's slash menu). All modules sharexmlns:rdfae="https://w3id.org/atomgraph/rdfa-editor#", so import precedence overridesrdfae:*declarations across stylesheets. - ldh-blocks.xsl + ldh-editor.xsl — the LinkedDataHub extension and its entry (
<xsl:import href="index.xsl"/>+<xsl:include href="ldh-blocks.xsl"/>— core at lower precedence; the same layering shape LDH's client.xsl uses, MIGRATION.md §10/§12). The extension re-declares$object-block-types(ldh:View/ldh:ResultSetChart), provides per-typemode="rdfae:render-island"renderers plus one matchingrdfae:reference-block(dereferences the island's own@about) — promise chains fetching the referenced document by content negotiation on the clean trailing-slash URI (Accept: application/rdf+xmlfor the description; as a demo stand-in for live SPARQL,Accept: application/sparql-results+xmlon a query document returns canned results; serve.mjs / tests/browser/run.mjs negotiate; no file extensions in any URL; a reference block dereferences any absolute URI the same way — the demo embedshttp://dbpedia.org/resource/Ada_Lovelacedirectly, DBpedia 303s to the RDF/XML and allows CORS; the description card caps at 10 properties) — plus the#ldh-block-dialoginsert dialog (opts into teardown via theedit-dialogclass; kinds: Resource — one absolute URI straight into@about, gated byrdfae:is-absolute-iri— View and Result set chart with fragment ids), the ▦ toolbar button and the "Block…" slash item. In production LDH, client.xsl plays this module's role, bridgingrdfae:render-islandinto its block rendering (v6 modeldh:Block; the v5ldh:RenderRowbridge is worked through in MIGRATION.md §12). - tables.xsl — table blocks as a composite kind: the insert dialog (rows×cols + header-row + caption, modeled on the figure dialog), positional row/column operations (
rdfae:op-insert-row/-column,-delete-row/-column) gated behindrdfae:has-spans(disabled on pastedcolspan/rowspangrids — positional edits would corrupt a spanned table) and toolbar-synced viardfae:sync-table-toolbarridingrdfae:update-breadcrumb, plusrdfae:table-tab/rdfae:table-entercell traversal that appends a body row at the bottom edge. Cells are%Flow;containers: every caret landing resolves throughrdfae:first-host-in/rdfae:last-host-in, so a cell holding blocks lands the caret in its first/last editable host; the Tab dispatcher (edit.xsl) passes the cell as the traversal host even when the caret sits in a nested block. Deleting the last body row/column is a no-op — the confirm-guarded delete-block button removes the whole table. Undo hazard: a chromespanserialized as a direct child of a (possibly nested)<table>is foster-parented out by the HTML fragment parser oninnerHTMLrestore, sordfae:restore-snapshotstrips every handle and re-converges viardfae:ensure-chrome(deterministic first-child prepend) before caret resolution. - select.xsl — the Docs-style selection gesture layer, region-scoped select-all and cross-host selection delete (keyboard dispatch lives in edit.xsl's keydown/body/paste templates, mirroring the tables.xsl split; the mouse gesture templates live here). The browser confines a native drag-selection to the host it starts in (even background-origin drags clamp on the first host entered), so cross-block selection is synthesized: mousedown in a region arms a sweep anchor (
rdfae:caret-at-point—caretRangeFromPoint,caretPositionFromPointfallback; chrome positions escape to just after the chrome); once the pointer leaves the anchor host, eachbody|htmlmousemove rebuilds the selection anchor→pointer viasetBaseAndExtent— the only Selection API that can express a backward selection (upward drags keep the anchor fixed) — with the focus clamped into the anchor's region (rdfae:clamp-focus-to-region,comparePoint-based) and a viewport nudge near its edges (native autoscroll dies with the takeover); in-host moves stay native. Mouseup disarms (innermost-match dispatch: the host/drag-handle mouseup templates disarm on their paths,body|htmlcatches background ends) andondragstartdisarms defensively; a press on the drag handle never reaches the arm template. Shift+Click extends from the standing selection anchor to the clicked point (preventDefault keeps the anchor; re-arms so Shift+drag keeps extending; repeated Shift+Clicks share the anchor). Shift+Up/Down extend block-granularly past host edges (rdfae:shift-arrow-extendsgates: cross-host selection, or focus at the host edge facing the arrow — probed from the focus, not the range end;rdfae:extend-selection-block-wisesteps the focus between region-level child positions, whole blocks and composites as units, flipping direction across the anchor); within the host they stay native, Shift+Left/Right stay native throughout. Sweep state (sweepAnchorNode/-Offset/-Host,sweepRegion) lives on the editor-state container and is cleared on undo restore. Two-stage Ctrl/Cmd+A (Docs-style): stage 1 stays native — the browser scopes select-all to the focused host; stage 2 (host already fully selected per the chrome-awarerdfae:at-*probes, or empty, or the selection already spans hosts) selects all blocks of the region as a document-level range (rdfae:select-region) — it paints natively across host boundaries and never reaches the host page. Ctrl+A away from a caret also selects editor content, never the page: from the body it targets the swept region, elserdfae:active-root()(selection anchor → last focused host → first region), and a focused image island is its own fully-selected unit (stage 2 directly, with Backspace/Delete then running the delete machine instead of the island's figure-delete); native page select-all happens only when no region exists. One delete machine (rdfae:delete-cross-host-selection) serves stage-2 selections and every synthetic gesture alike (rdfae:selection-crosses-hosts()gates it), fired by Backspace/Delete from host or body focus — a sweep from the page background leaves focus on body. Deletion is block-granular, never one rawdeleteContentsacross the range: fully covered blocks are removed whole; partial edge hosts get sub-range deletes scoped inside the host; composites holding a range boundary never lose structure — their covered cells are cleared and flow cells collapse back to text hosts (rdfae:clear-host; B3/B4 doctrine at partial coverage) while a fully covered composite is removed whole; non-composite edge remnants merge Docs-style viardfae:merge-into-previouswith the caret at the seam (never withpre— B6); emptied structural containers are pruned (rdfae:prune-husks), chrome is re-injected (idempotent), and an emptied region is reseeded with a freshphost (rdfae:seed-region). The range is clamped to a single region (the start's, else the first swept;rdfae:clamped-rangealso moves boundaries out of chrome, and out of object-block islands —setStartBefore/setEndAfter, so islands join a selection whole and the machine never sees a boundary inside one; fully covered islands are removed whole by the existing rules, andrdfae:caret-at-pointlikewise escapes island interiors so sweep anchors never land in them) — one gesture, one region-keyed undo entry; other regions stay byte-identical. A printable character replaces the selection (the delete machine places the caret,rdfae:insert-text-at-caretlands the character — same undo entry); Enter/Tab/paste are suppressed; plain arrows stay native. Canonical copy/cut (ixsl:oncopy/oncuton hosts and body): a cross-host selection is copied in its storage form —cloneContentsinto a carrier div,mode="canonical"+cm:normalize(the paste pipeline in reverse), so the clipboard's HTML flavor has no chrome/contenteditable/marker classes but keeps RDFa attributes; cut adds the delete machine (one undo entry); within-host copy stays native. - input.xsl — Notion-style input affordances (ported from PR #10 onto the content-model era; dispatch additions in edit.xsl): one priority-raised
ixsl:onbeforeinputdispatcher intercepts printable-char triggers andnext-matches into undo's typing coalescer, so plain typing still snapshots. Triggers act on the host the caret sits in and are content-model-gated (rdfae:trigger-kind— a shorthand whose result the model rejects stays literal). The slash menu (/in an empty host): filterable, keyboard-navigable, showing only the commands the context admits (conversions forp|h1–h3|prehosts, Quote where the wrap is legal, lists where the model places one, Figure…/Table… always — routed to the existing dialogs viainsertHost, whose saves place per the content model throughrdfae:insert-block-at-caret: an empty list item or cell grows the composite inside itself); state onslashHost, torn down byrdfae:hide-dialogs. Markdown shorthands in a paragraph host:#/##/###+space,-/*+space,1.+space,``` convert in place (rdfae:convert-blockwith the pre-strip snapshot — one undo entry restores the literal marker); `> ` wraps via `rdfae:wrap-in-blockquote` (`blockquote > p`; converting the `p` itself would be invalid). Caret popups position via `rdfae:show-at-caret`/`-element` over overlay.xsl's `rdfae:show-at-point` split. - undo.xsl — unified snapshot undo/redo over
#contentinnerHTML: every mutating handler callsrdfae:push-undofirst andrdfae:after-mutationlast; plain typing coalesces into ~1s bursts viaixsl:onbeforeinput. Ctrl/Cmd+Z / Shift+Z / Ctrl+Y are intercepted (native undo is replaced). Stacks live in a hidden DOM stash (#undo-storage,data-role="storage") — JS arrays don't survive the IXSL boundary and sequence-valued window properties keep only their first item. Caret restoration after undo is approximate (first host) by design.rdfae:restore-snapshotre-firesrdfae:render-islandonly for islands without a rendering div — snapshots carry rendering markup, so restores are render-stable; only mid-render captures re-hydrate. - navigate.xsl — FontoXML-inspired navigation: ToC drawer (recursive
for-each-group group-starting-withoutline, click-to-jump, section drag-reorder viardfae:section-of), breadcrumb bar (element path +rdfax:in-scope-subjectat the caret), lint surfacing (markers + badge + issues modal), find & replace (single-text-node matches,nodeValue-rewrite replace-all — annotation-safe by construction). - lint-rdfa.xsl — RDFa lint logic. Pure XSLT, tested headless via
tests/lint-driver.xsl(xsl:importresolves the output-method conflict). Reuses the extractor's resolution functions so lint verdicts can't drift from extraction semantics. Checks: term-unresolvable, empty-href, content-resource-conflict, empty-literal, about-relative. - lint-xhtml.xsl — nesting lint (
lint:nesting-issues, concatenated withlint:element-issuesat every surfacing site). Pure XSLT, consults content-model.xsl only, so verdicts can't drift from normalization. Checks: invalid-nesting (known child not allowed by known parent), stray-text (non-ws text in an element-only container), prohibited-nesting (Appendix B), unknown-element (outside the model — preserved, unvalidated). - canonical-xhtml.xsl — canonical serialization form. Pure XSLT, tested headless via
tests/canonical-driver.xsl(it consultscm:*, so it is no longer standalone-compilable). Two passes in fixed order:mode="canonical"(drops*[@data-role]subtrees — nesting analysis must never see chrome; stripscontenteditable/draggable/class/id/style/tabindex/aria-*/data-*; normalizes browser mess:b→strong,i→em,font/u/meaninglessspanunwrap, empty non-RDFa inline pruning, trailing-brdrop,br-in-pre→newline; content-awarediv: inline-content attributeless div →p(C7a), block-holding attributeless div unwraps (C7b); run-wrapperp.rdfa-editor-rununwraps (C11) unless RDFa-annotated; non-RDFa HTML5 sectioning wrappers unwrap (C12)), thenmode="cm-normalize"(N-rules: N1 matches every inline-only element and decides on the PROCESSED children, so inner splits reach the fixed point in one bottom-up pass — an RDFa-bearing parent keeps its blocks as recursively-demoted inline viamode="cm-demote"(a list becomes nested spans, never a bareliin aspan), a plain parent splits around them with shells keeping ALL attributes (an<a href>split by a block keeps its target on both halves — safe, the branch is non-RDFa by construction); N2 blockquote stray runs →p; N3/N4/N5ul/dl/trstrays wrapped inli/dd/td; N5b section strays → rows; N6 invalidtablechildren hoisted before it; N7/N8 Appendix B fixes:preexclusions unwrap/alt-text,a-in-a→spanwith attributes kept).cm:wrap-inline-runspulls only text and known-inline elements into its inline-only wrapper — blocks, ephemera, unknowns AND known non-inline strays (anlioutside any list) pass through bare for lint to report, never wrapped into fresh invalid nesting. The entry template finally coerces region children to blocks (the editor contract, not the DTD's). Shared primitivescm:normalize,cm:wrap-inline-runs,cm:coerce-childrenare reused by edit.xsl's load-init and paste. RDFa attributes andprewhitespace are never touched.
The editor emits absolute IRIs in RDFa attributes (LinkedDataHub v6 convention — no CURIEs/@vocab/@prefix in output).
make up # build the SEFs, then serve on http://localhost:8000 (override: make up PORT=9000)
make sef # compile src → dist/index.xsl.sef.json (core) + dist/ldh-editor.xsl.sef.json (LDH)
make test # headless suites (extractor, canonical, lint); make test-browser for the Playwright suitesThe targets wrap the underlying scripts (bash generate-sef.sh, bash tests/run-tests.sh, node serve.mjs). serve.mjs content-negotiates trailing-slash document URIs (Accept: application/rdf+xml → the directory's index.rdf, application/sparql-results+xml → results.xml, text/html → index.html) — required for the object-block demo at /demo/; demo/queries/*/ and demo/resources/*/ hold the negotiated representations. dist/ is a generated build artifact (compiled SEFs + copied vocabs) and is gitignored — the SEF also bakes in a buildDateTime, so it is never byte-stable; make sef reproduces it.
Run make sef after any XSLT change; run the tests after any extractor change.
- No deviation from W3C specs. The extractor follows the RDFa 1.1 processing rules exactly. Markup that relies on non-conformant readings (e.g.
about="#part" property="schema:hasPart"expected to yield an edge) is a markup bug — the conformant containment idiom isproperty="…hasPart" resource="#part" typeof="…". - Content model: XHTML 1.0 Strict + HTML5 figure, transcribed in
content-model.xsl— nesting the DTD allows must round-trip intact; nesting it disallows is normalized at boundaries (canonical serialization, paste, load-init) and reported by lint. This changed the storage contract for blockquote: it is block-only, so stored bare-text quotes are rewritten toblockquote > ponce at load/save (LDH content audit advised). - All
innerHTMLwrites of serialized XDM useserialize(…, map{ 'method': 'html' })— XML's self-closing<p/>reads as an open tag to the HTML fragment parser and swallows following siblings. - The
$base-uriglobal param is declared inRDFa2RDFXML-v3.xslonly — a second declaration in an including module is a static error (XTSE0630). The browser passes the page URI as a template param tordfax:extract-rdfainstead. - The SEF is compiled with
-relocate:on: relativedoc()/document()/@documenthrefs resolve against the SEF's load location (dist/), which is whygenerate-sef.shcopiesvocabs/todist/vocabs/. - Form state (
checked/value/disabled) is read and written viaixsl:get/ixsl:set-property— the attributes never reflect user input.ixsl:set-attributeis used only for RDFa attributes that must serialize into content. - Vocabularies are plain ontology RDF/XML files in
vocabs/— no custom manifest format. Adding a vocabulary = drop the file invocabs/and add its href to$vocab-hrefs. src/index.xsl(and other editor modules) may use DOCTYPE entities; the SaxonJS compiler does not expand them, hence thexmlstarlet c14nstep ingenerate-sef.sh. The extractor is entity-free so the test suite runs againstsrc/directly.- Tests: expected files are authored as readable, grouped RDF/XML;
tests/normalize.xslcanonicalizes both sides into sorted triple lists, so prefixes/grouping/order don't matter. Blank-node labels are deterministic sibling-position paths (e.g.b1.1.2), notgenerate-id(). Canonical-XHTML fixtures live intests/fixtures/canonical/and compare viatests/normalize-xhtml.xsl(whitespace-only text dropped only in element-only containers —prestays exact). - Editable region convention: the host page provides
<div id="content" about="" typeof="…">with blocks as direct children and no hardcodedcontenteditable— init sets editability recursively per the content model and injects chrome (on every draggable block, nested ones included —rdfae:draggable-block). Everything carrying@data-role(chrome, rendering) is ephemeral: skipped by the extractor, stripped by canonicalization (LDH v6 contract). - Run-wrapper contract:
p.rdfa-editor-runis an editing-DOM-only marker for the stray inline runs of mixed flow containers — created by init/paste, unwrapped by canonicalization (C11), promoted to a realpby structural gestures, never by plain typing. It renders margin-free viardfa-editor.css. - Undo contract: every mutating handler pushes a snapshot first (
rdfae:push-undo, optionally with a pre-captured$snapshotwhen the operation can fail) and callsrdfae:after-mutationlast — never push from shared primitives. Undo/redo restore invalidates all node-valued window properties (cleared inrdfae:restore-snapshot) and closes overlay/dialogs. The caret rides along on stash entries as (block, text-node, offset) data attributes and is restored on undo/redo. - Content markers: anything written into
#contentfor UI purposes must be@class(oraria-*) only — both are canonicalization-stripped.@titleis NOT stripped and is forbidden as a marker. - Object-block contract (= the v6 document format, see the
XHTML-RDFa-as-LDH-v6-Document-Formatwiki): the stored form is the block element ONLY —div[@about][@typeof](@abouta fragment URI: the block is a document part) withspan[@property]definition children (objects via@resource, literals as span text content, hidden byrdfa-editor.css); no document→block containment edge —ldh:contentis v6's document→body XMLLiteral property, never a per-block edge, and block order is document order (nordf:_N). Extraction yields the complete block definition. Everything else (tabindex, marker classes, the rendering div) is editing ephemera. Island detection keys on@typeofonly, sordfae:block-text()of a chart island is non-empty (its var-name spans) — the toolbar delete-block button confirms, the keyboard island delete stays confirm-free. URLs are clean Linked Data URIs: document URIs end with a trailing slash, representations come from content negotiation — file extensions never appear in storage, fetch code or fixtures. Async rendering runs outside push-undo (a rendering-only undo step is possible and harmless — canonical content is identical). - GitHub Pages deploys the CORE editor only (
deploy-pages.ymlremovesdist/ldh-editor.xsl.sef.jsonand never copiesdemo/) — Pages is a static host with no conneg, so the LDH flavor is local-only by construction. - SaxonJS gotchas: computed numeric predicates (
[xs:integer(...)]) are evaluated as booleans in some contexts — bind to a variable and use the bare[$index]form. JS arrays returned byixsl:callmarshal to XDM sequences (empty array = empty sequence). Large nested map literals fail the compiler with an internal assertion — build them withmap:mergeovermap:entry(see$cm:model). - Browser tests live in
tests/browser/(npm run test:browserself-serves the repo):editor.mjs,features.mjs,fixes.mjs,hardening.mjs,multiinstance.mjs,tables.mjs,datatype.mjs,inspector.mjs,nesting.mjs(content-model foundation),authoring.mjs(nesting gestures),select.mjs(two-stage Ctrl+A, cross-host sweep delete, type-to-replace, canonical copy/cut, composite/clamp semantics, plus the real gestures: drag takeover with real mouse events, Shift+Click, Shift+Up/Down, gutter/backward/cross-region drags, drag-handle non-hijack),notion.mjs(slash menu, markdown input rules, context filtering),typeahead.mjs(property/type autocomplete: filter, keyboard/mouse select, free-IRI entry, non-IRI rejection, edit-prefill button, stale-selection invalidation — viatypeahead-helper.mjs),invariants.mjs(the cross-product net: a uniform gesture battery in every caret context asserting properties that must always hold — no orphan text outside an editable host, no nested hosts, chrome only on draggable blocks, run wrappers editable, zero lint issues, undo restores the exact baseline),dragnest.mjs(nested drag-and-drop with real mouse gestures: nested handles at init, lift-out of a list item — incl. the RDFa object-div case — drop-into a container and INTO a text-only cell, before/after precision on a cell's blocks, legality clamp, region clamp, origin collapse, split convergence, canonical cleanliness — againsttests/fixture-dragnest.html, which loads the core SEF) andblocks.mjs(object blocks: init/locking, storage-form round-trip — incl. the reference block's empty-div form — island navigation/deletion/boundaries, dialog insertion of a defined View top-level and a nested reference block, stage-2/sweep atomicity, canonical copy, undo — againsttests/fixture-blocks.html, which hydrates three islands over conneg before baselining: a chart, a reference block whose absolute@aboutthe fixture script stamps at load, and a nested View). All other suites run againsttests/fixture-nesting.html, which loads the ldh-editor SEF — proving the extension layers without disturbing core behavior (notion.mjs counts the extension's 11th slash item). Scenario tests assert semantics (where things land); the invariant suite catches validity/editability/undo regressions in combinations nobody enumerated. CI runs both headless loops and the browser suites (.github/workflows/ci.yml). - Sanitization:
canonical-xhtml.xslis the storage boundary — it drops script/style/iframe/object/embed/applet/form controls/link/meta/base subtrees, comments/PIs, allon*attributes, andjavascript:/vbscript:/data:URLs (data:image/*allowed in@src). HTML paste goes through this same mode. Lint mirrors these asunsafe-attribute/unsafe-url. - Editor-contract CSS lives in
rdfa-editor.css(host pages include it; the container needs ~2.5em left padding for the gutter handles);index.htmlkeeps only demo styles.