Skip to content

Latest commit

 

History

History
79 lines (62 loc) · 43.4 KB

File metadata and controls

79 lines (62 loc) · 43.4 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Multi-instance model (M2)

  • Editable regions are any elements with the rdfa-editor-content class token (host-page chrome, canonicalization-stripped). No id('content') singletons anywhere: rdfae:roots(), rdfae:root-of(), rdfae:active-root() (selection, then last focused host, then first region) in edit.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 (tocRoot remembered at render); lint and find work across all regions.
  • Editor state lives on one window container window.rdfaEditor — a single object created with Object() in the main template (mirrors LinkedDataHub's window.LinkedDataHub), holding all mutable state under bare names (range, activeBlock, editingSpan, …). Every read/write goes through the rdfae:editor-state() accessor (index.xsl): reads ixsl:get(rdfae:editor-state(), 'range'), writes object="rdfae:editor-state()". Browser globals (scrollX, Date, location.href, …) are read straight off ixsl:window() and are not editor state. Editor UI containers carry the rdfa-editor-ui class 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-mode match="/") 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-hint boxes (no content mutation), cleared on hide.

Overview

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).

Architecture

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.xsldist/index.xsl.sef.json, deployed to GitHub Pages — deploy-pages.yml excludes the LDH artifacts) and LDH-extended (src/ldh-editor.xsldist/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: main template (fully synchronous — the host page preloads the vocabularies into the SaxonJS documentPool keyed 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 via xslt3 for the test suite. Strictly W3C-conformant (RDFa 1.1 processing rules, section 7.5, including chaining via typed resources and rdfa:usesVocabulary). Covers @about/@typeof/@property/@content/@datatype/@resource/@href/@src/@prefix (and xmlns:*)/@vocab with bare terms, @lang+@xml:lang inheritance, base-URI resolution (about="" = the document, <base href> honored), and exclusion of head/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.xsl includes 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 HTML5 figure/figcaption. $cm:model (element → allowed children + #PCDATA flag), $cm:prohibitions, predicates cm: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:model is built with map: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>) and rdf:Description+rdf:type shapes (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-values map), 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 a https://w3id.org/atomgraph/rdfa-editor#custom sentinel + free-text custom-datatype input 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 via result-document literal 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 from rdfae:vocab-terms (prefix-ranked, capped, bold-highlighted match); keyboard nav mirrors the slash menu (Arrow/Enter/Escape, first option pre-selected); options commit on ixsl:onmousedown (before the input's focusout, children pointer-events:none so the <li> is the target). rdfae:typeahead-value reads 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), shared rdfae: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 (figure is always composite: image island + caption host); img never editable but given tabindex="-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, given tabindex="-1" + the rdfa-editor-island marker 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 ephemeral p.rdfa-editor-run host (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 through mode="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, hr or ephemera — whose parent is a region, a cm:flow container or a blockquote — 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 indents blockquote content so quote-child handles don't coincide with the quote's); rdfae:ensure-chrome in rdfae:after-mutation converges 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 an li into 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(.))] on rdfae: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 per ul → (li)+; outdent moves it after the container item, demoting followers into a nested list inside it; rdfae:collapse-container reverts 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 a blockquote where 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 runs mode="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 (when cm:allows-child permits), or flatten to text (caption, dt). Drag-and-drop ported from LinkedDataHub's client/block.xsl (handle-gated draggable, 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-level climbing 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 a td/th, so a cell's pixels away from any block it holds mean into it — the text host becomes a container per the rdfae:insert-block-at-caret flow doctrine, marked by an inset drop-into box; 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-husks for emptied structural containers — the B7 doctrine — then rdfae:collapse-container for a flow container that lost its last block); transient drag state (dragging/draggable/empty class leftovers — 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 receiverrdfae:deepest-block-at can'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 real tds — external rendering, not drop zones): pointing anywhere over an island means before/after the island itself. Arrow-key block crossing walks rdfae: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-island focuses 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 predicate rdfae:island($e) (div whose tokenize(@typeof) hits the param, OR a reference block per rdfae:reference-block($e): an effectively-empty div — ephemera and whitespace aside — whose @about is an absolute URI naming a different document than the page (string tests only, no resolve-uri, so a malformed @about can't error out of a hot predicate; relative/fragment @about never islands — document parts stay blocks, about-relative stays a lint case) — every island decision in init/nav/boundaries/delete-machine/undo routes through it), the render hook mode="rdfae:render-island" (context item = the island div; contract: inject exactly ONE div[@data-role='rendering'] as last child via rdfae:replace-rendering — async renderers only in the completion callback, loading state = ephemeral rdfa-editor-loading class, 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 stubs rdfae: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 share xmlns:rdfae="https://w3id.org/atomgraph/rdfa-editor#", so import precedence overrides rdfae:* 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-type mode="rdfae:render-island" renderers plus one matching rdfae: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+xml for the description; as a demo stand-in for live SPARQL, Accept: application/sparql-results+xml on 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 embeds http://dbpedia.org/resource/Ada_Lovelace directly, DBpedia 303s to the RDF/XML and allows CORS; the description card caps at 10 properties) — plus the #ldh-block-dialog insert dialog (opts into teardown via the edit-dialog class; kinds: Resource — one absolute URI straight into @about, gated by rdfae: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, bridging rdfae:render-island into its block rendering (v6 mode ldh:Block; the v5 ldh:RenderRow bridge 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 behind rdfae:has-spans (disabled on pasted colspan/rowspan grids — positional edits would corrupt a spanned table) and toolbar-synced via rdfae:sync-table-toolbar riding rdfae:update-breadcrumb, plus rdfae:table-tab/rdfae:table-enter cell traversal that appends a body row at the bottom edge. Cells are %Flow; containers: every caret landing resolves through rdfae: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 chrome span serialized as a direct child of a (possibly nested) <table> is foster-parented out by the HTML fragment parser on innerHTML restore, so rdfae:restore-snapshot strips every handle and re-converges via rdfae: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-pointcaretRangeFromPoint, caretPositionFromPoint fallback; chrome positions escape to just after the chrome); once the pointer leaves the anchor host, each body|html mousemove rebuilds the selection anchor→pointer via setBaseAndExtent — 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|html catches background ends) and ondragstart disarms 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-extends gates: cross-host selection, or focus at the host edge facing the arrow — probed from the focus, not the range end; rdfae:extend-selection-block-wise steps 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-aware rdfae: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, else rdfae: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 raw deleteContents across 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 via rdfae:merge-into-previous with the caret at the seam (never with pre — B6); emptied structural containers are pruned (rdfae:prune-husks), chrome is re-injected (idempotent), and an emptied region is reseeded with a fresh p host (rdfae:seed-region). The range is clamped to a single region (the start's, else the first swept; rdfae:clamped-range also 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, and rdfae:caret-at-point likewise 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-caret lands the character — same undo entry); Enter/Tab/paste are suppressed; plain arrows stay native. Canonical copy/cut (ixsl:oncopy/oncut on hosts and body): a cross-host selection is copied in its storage form — cloneContents into 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:onbeforeinput dispatcher intercepts printable-char triggers and next-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 for p|h1–h3|pre hosts, Quote where the wrap is legal, lists where the model places one, Figure…/Table… always — routed to the existing dialogs via insertHost, whose saves place per the content model through rdfae:insert-block-at-caret: an empty list item or cell grows the composite inside itself); state on slashHost, torn down by rdfae:hide-dialogs. Markdown shorthands in a paragraph host: #/##/###+space, -/*+space, 1.+space, ``` convert in place (rdfae:convert-block with 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 #content innerHTML: every mutating handler calls rdfae:push-undo first and rdfae:after-mutation last; plain typing coalesces into ~1s bursts via ixsl: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-snapshot re-fires rdfae:render-island only 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-with outline, click-to-jump, section drag-reorder via rdfae:section-of), breadcrumb bar (element path + rdfax:in-scope-subject at 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:import resolves 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 with lint:element-issues at 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 consults cm:*, so it is no longer standalone-compilable). Two passes in fixed order: mode="canonical" (drops *[@data-role] subtrees — nesting analysis must never see chrome; strips contenteditable/draggable/class/id/style/tabindex/aria-*/data-*; normalizes browser mess: bstrong, iem, font/u/meaningless span unwrap, empty non-RDFa inline pruning, trailing-br drop, br-in-pre→newline; content-aware div: inline-content attributeless div → p (C7a), block-holding attributeless div unwraps (C7b); run-wrapper p.rdfa-editor-run unwraps (C11) unless RDFa-annotated; non-RDFa HTML5 sectioning wrappers unwrap (C12)), then mode="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 via mode="cm-demote" (a list becomes nested spans, never a bare li in a span), 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/N5 ul/dl/tr strays wrapped in li/dd/td; N5b section strays → rows; N6 invalid table children hoisted before it; N7/N8 Appendix B fixes: pre exclusions unwrap/alt-text, a-in-aspan with attributes kept). cm:wrap-inline-runs pulls only text and known-inline elements into its inline-only wrapper — blocks, ephemera, unknowns AND known non-inline strays (an li outside 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 primitives cm:normalize, cm:wrap-inline-runs, cm:coerce-children are reused by edit.xsl's load-init and paste. RDFa attributes and pre whitespace are never touched.

The editor emits absolute IRIs in RDFa attributes (LinkedDataHub v6 convention — no CURIEs/@vocab/@prefix in output).

Commands

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 suites

The 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+xmlresults.xml, text/htmlindex.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.

Key constraints

  • 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 is property="…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 to blockquote > p once at load/save (LDH content audit advised).
  • All innerHTML writes of serialized XDM use serialize(…, map{ 'method': 'html' }) — XML's self-closing <p/> reads as an open tag to the HTML fragment parser and swallows following siblings.
  • The $base-uri global param is declared in RDFa2RDFXML-v3.xsl only — a second declaration in an including module is a static error (XTSE0630). The browser passes the page URI as a template param to rdfax:extract-rdfa instead.
  • The SEF is compiled with -relocate:on: relative doc()/document()/@document hrefs resolve against the SEF's load location (dist/), which is why generate-sef.sh copies vocabs/ to dist/vocabs/.
  • Form state (checked/value/disabled) is read and written via ixsl:get/ixsl:set-property — the attributes never reflect user input. ixsl:set-attribute is 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 in vocabs/ 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 the xmlstarlet c14n step in generate-sef.sh. The extractor is entity-free so the test suite runs against src/ directly.
  • Tests: expected files are authored as readable, grouped RDF/XML; tests/normalize.xsl canonicalizes 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), not generate-id(). Canonical-XHTML fixtures live in tests/fixtures/canonical/ and compare via tests/normalize-xhtml.xsl (whitespace-only text dropped only in element-only containers — pre stays exact).
  • Editable region convention: the host page provides <div id="content" about="" typeof="…"> with blocks as direct children and no hardcoded contenteditable — 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-run is 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 real p by structural gestures, never by plain typing. It renders margin-free via rdfa-editor.css.
  • Undo contract: every mutating handler pushes a snapshot first (rdfae:push-undo, optionally with a pre-captured $snapshot when the operation can fail) and calls rdfae:after-mutation last — never push from shared primitives. Undo/redo restore invalidates all node-valued window properties (cleared in rdfae: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 #content for UI purposes must be @class (or aria-*) only — both are canonicalization-stripped. @title is NOT stripped and is forbidden as a marker.
  • Object-block contract (= the v6 document format, see the XHTML-RDFa-as-LDH-v6-Document-Format wiki): the stored form is the block element ONLY — div[@about][@typeof] (@about a fragment URI: the block is a document part) with span[@property] definition children (objects via @resource, literals as span text content, hidden by rdfa-editor.css); no document→block containment edgeldh:content is v6's document→body XMLLiteral property, never a per-block edge, and block order is document order (no rdf:_N). Extraction yields the complete block definition. Everything else (tabindex, marker classes, the rendering div) is editing ephemera. Island detection keys on @typeof only, so rdfae: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.yml removes dist/ldh-editor.xsl.sef.json and never copies demo/) — 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 by ixsl:call marshal to XDM sequences (empty array = empty sequence). Large nested map literals fail the compiler with an internal assertion — build them with map:merge over map:entry (see $cm:model).
  • Browser tests live in tests/browser/ (npm run test:browser self-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 — via typeahead-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 — against tests/fixture-dragnest.html, which loads the core SEF) and blocks.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 — against tests/fixture-blocks.html, which hydrates three islands over conneg before baselining: a chart, a reference block whose absolute @about the fixture script stamps at load, and a nested View). All other suites run against tests/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.xsl is the storage boundary — it drops script/style/iframe/object/embed/applet/form controls/link/meta/base subtrees, comments/PIs, all on* attributes, and javascript:/vbscript:/data: URLs (data:image/* allowed in @src). HTML paste goes through this same mode. Lint mirrors these as unsafe-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.html keeps only demo styles.