sol-components 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +7 -0
  2. package/core/activate.js +27 -0
  3. package/core/adopt.js +71 -0
  4. package/core/auth-core.js +73 -0
  5. package/core/auth-fetch.js +154 -0
  6. package/core/component-mount.js +110 -0
  7. package/core/defaults.js +48 -0
  8. package/core/define.js +15 -0
  9. package/core/display-target.js +166 -0
  10. package/core/edit-placements.js +28 -0
  11. package/core/editor-self.js +127 -0
  12. package/core/editor.js +162 -0
  13. package/core/events.js +27 -0
  14. package/core/extension-points.js +189 -0
  15. package/core/form-utils.js +210 -0
  16. package/core/from-query.js +138 -0
  17. package/core/from-rdf.js +52 -0
  18. package/core/here.js +33 -0
  19. package/core/include-core.js +73 -0
  20. package/core/inrupt-global.js +18 -0
  21. package/core/menu-consumer.js +41 -0
  22. package/core/menu-rdf.js +154 -0
  23. package/core/pod-ops.js +392 -0
  24. package/core/pod-registry.js +82 -0
  25. package/core/popup-proxy.js +255 -0
  26. package/core/rdf-core.js +280 -0
  27. package/core/rdf-render.js +136 -0
  28. package/core/rdf-utils.js +411 -0
  29. package/core/rdf.js +154 -0
  30. package/core/services.js +106 -0
  31. package/core/shape-to-form.js +741 -0
  32. package/core/sparql-safety.js +20 -0
  33. package/core/utils.js +196 -0
  34. package/dist/importmap-cdn.json +49 -0
  35. package/dist/importmap-local.json +49 -0
  36. package/dist/sol-loader.manifest.json +140 -0
  37. package/dist/vendor/@comunica-query-sparql.js +137851 -0
  38. package/dist/vendor/@inrupt-solid-client-authn-browser.js +7503 -0
  39. package/dist/vendor/dompurify.js +1476 -0
  40. package/dist/vendor/ical.js.js +9739 -0
  41. package/dist/vendor/marked.js +85 -0
  42. package/dist/vendor/n3.js +14670 -0
  43. package/dist/vendor/rdf-validate-shacl.js +6970 -0
  44. package/dist/vendor/rdflib.js +35172 -0
  45. package/dist/vendor/solid-logic.js +6819 -0
  46. package/dist/vendor/solid-ui.js +21945 -0
  47. package/node/sol-form.js +133 -0
  48. package/node/sol-include.js +55 -0
  49. package/node/sol-login.js +632 -0
  50. package/node/sol-menu.js +639 -0
  51. package/node/sol-query.js +116 -0
  52. package/package.json +133 -0
  53. package/web/menu-from-rdf.js +23 -0
  54. package/web/scripts/prefs.js +25 -0
  55. package/web/sol-accordion.js +114 -0
  56. package/web/sol-basic.js +50 -0
  57. package/web/sol-breadcrumb.js +131 -0
  58. package/web/sol-button.js +244 -0
  59. package/web/sol-calendar.js +465 -0
  60. package/web/sol-default.js +118 -0
  61. package/web/sol-dropdown-button.js +222 -0
  62. package/web/sol-feed.js +1336 -0
  63. package/web/sol-form.js +949 -0
  64. package/web/sol-full.js +43 -0
  65. package/web/sol-gallery.js +303 -0
  66. package/web/sol-include.js +246 -0
  67. package/web/sol-live-edit.js +415 -0
  68. package/web/sol-login.js +856 -0
  69. package/web/sol-menu.js +593 -0
  70. package/web/sol-modal.js +377 -0
  71. package/web/sol-pod-extras.js +17 -0
  72. package/web/sol-pod-ops.js +680 -0
  73. package/web/sol-pod.js +1039 -0
  74. package/web/sol-query.js +546 -0
  75. package/web/sol-rolodex.js +95 -0
  76. package/web/sol-search.js +402 -0
  77. package/web/sol-settings.js +199 -0
  78. package/web/sol-solidos.js +93 -0
  79. package/web/sol-tabs.js +445 -0
  80. package/web/sol-time.js +194 -0
  81. package/web/sol-tree-edit.js +492 -0
  82. package/web/sol-wac.js +456 -0
  83. package/web/sol-weather.js +337 -0
  84. package/web/sol-window.js +142 -0
  85. package/web/styles/buttons-css.js +108 -0
  86. package/web/styles/help.css +242 -0
  87. package/web/styles/root.css +112 -0
  88. package/web/styles/sol-accordion-css.js +97 -0
  89. package/web/styles/sol-calendar-css.js +154 -0
  90. package/web/styles/sol-feed-css.js +475 -0
  91. package/web/styles/sol-form-css.js +471 -0
  92. package/web/styles/sol-gallery-css.js +181 -0
  93. package/web/styles/sol-include-css.js +95 -0
  94. package/web/styles/sol-live-edit-css.js +84 -0
  95. package/web/styles/sol-live-edit.css +101 -0
  96. package/web/styles/sol-login-css.js +116 -0
  97. package/web/styles/sol-menu-css.js +145 -0
  98. package/web/styles/sol-modal-css.js +134 -0
  99. package/web/styles/sol-pod-css.js +187 -0
  100. package/web/styles/sol-pod-modal-css.js +203 -0
  101. package/web/styles/sol-query-css.js +140 -0
  102. package/web/styles/sol-query-help.css +267 -0
  103. package/web/styles/sol-query-one-pager.css +67 -0
  104. package/web/styles/sol-search-css.js +157 -0
  105. package/web/styles/sol-solidos-css.js +7 -0
  106. package/web/styles/sol-tabs-css.js +114 -0
  107. package/web/styles/sol-time-css.js +30 -0
  108. package/web/styles/sol-wac-css.js +73 -0
  109. package/web/styles/sol-weather-css.js +59 -0
  110. package/web/styles/solid-logo.svg +9 -0
  111. package/web/styles/view-accordion-css.js +66 -0
  112. package/web/styles/view-anchorlist-css.js +22 -0
  113. package/web/styles/view-autocomplete-css.js +59 -0
  114. package/web/styles/view-rolodex-css.js +102 -0
  115. package/web/styles/view-select-css.js +21 -0
  116. package/web/utils/calendar-fetch.js +388 -0
  117. package/web/utils/code-mirror-editor.js +82 -0
  118. package/web/utils/commons-fetch.js +108 -0
  119. package/web/utils/feed-edit.js +159 -0
  120. package/web/utils/feed-edit.smoke.mjs +74 -0
  121. package/web/utils/feed-fetch.js +573 -0
  122. package/web/utils/live-edit-help/csv.js +64 -0
  123. package/web/utils/live-edit-help/graphviz.js +41 -0
  124. package/web/utils/live-edit-help/jsonld.js +55 -0
  125. package/web/utils/live-edit-help/markdown.js +52 -0
  126. package/web/utils/live-edit-help/mermaid.js +48 -0
  127. package/web/utils/live-edit-help/turtle.js +85 -0
  128. package/web/utils/rdf-config.js +125 -0
  129. package/web/utils/renderers/csv.js +124 -0
  130. package/web/utils/renderers/d3-force.js +82 -0
  131. package/web/utils/renderers/graphviz.js +13 -0
  132. package/web/utils/renderers/html.js +10 -0
  133. package/web/utils/renderers/jsonld.js +63 -0
  134. package/web/utils/renderers/markdown.js +19 -0
  135. package/web/utils/renderers/mermaid.js +54 -0
  136. package/web/utils/renderers/turtle.js +51 -0
  137. package/web/utils/sol-query-triple-patterns.js +151 -0
  138. package/web/utils/sol-query-ui.js +250 -0
  139. package/web/utils/sol-query-views.js +32 -0
  140. package/web/views/_helpers.js +34 -0
  141. package/web/views/accordion.js +133 -0
  142. package/web/views/anchorlist.js +59 -0
  143. package/web/views/auto-complete.js +183 -0
  144. package/web/views/dl.js +38 -0
  145. package/web/views/list.js +19 -0
  146. package/web/views/menu.js +56 -0
  147. package/web/views/rolodex.js +126 -0
  148. package/web/views/select.js +79 -0
  149. package/web/views/table.js +73 -0
  150. package/web/views/tabs.js +57 -0
@@ -0,0 +1,741 @@
1
+ // shape-to-form — turn a SHACL shape into an editable form.
2
+ //
3
+ // Pure functions; no DOM ownership beyond the rendering layer, which
4
+ // delegates to solid-ui's form widgets. The intended consumer pattern is:
5
+ //
6
+ // const { targets, properties } = parseShape(shapeText, shapeUri);
7
+ // const subjects = findSubjects(store, targets, dataDoc);
8
+ // const cleanup = renderRecordForm(container, store, subjects[0], properties, {
9
+ // doc, onChange: (subj) => { /* persist however the host wants */ },
10
+ // });
11
+ //
12
+ // Rendering goes through solid-ui's `window.UI.widgets.fieldFunction`
13
+ // for consistency with every other form on the page (sol-form's legacy
14
+ // form-driven path, the menu editor when it used menu-form.ttl, etc.).
15
+ // shape-to-form builds a synthetic ui:Form node in the data store per
16
+ // render, hands it to solid-ui, and collects a cleanup function that
17
+ // removes the synthesized triples on teardown.
18
+ //
19
+ // Mapping (SHACL → ui:* field type):
20
+ // sh:nodeKind sh:IRI → ui:NamedNodeURIField
21
+ // sh:datatype xsd:integer → ui:IntegerField
22
+ // sh:datatype xsd:decimal → ui:DecimalField
23
+ // sh:datatype xsd:boolean → ui:BooleanField
24
+ // sh:datatype xsd:date → ui:DateField
25
+ // sh:datatype xsd:dateTime → ui:DateTimeField
26
+ // sh:datatype xsd:anyURI → ui:NamedNodeURIField
27
+ // sh:datatype xsd:string / fallback → ui:SingleLineTextField
28
+ //
29
+ // sh:in (IRIs with rdfs:label) → ui:Choice + ui:from pointing at a
30
+ // synthesized rdfs:Class whose
31
+ // instances are the listed URIs;
32
+ // labels propagate via the rdfs:label
33
+ // already declared in the shape.
34
+ // sh:in (literals) → ui:SingleLineTextField fallback —
35
+ // solid-ui's Choice doesn't model
36
+ // literal-instance enums. Authors who
37
+ // need a dropdown should declare the
38
+ // options as URIs with rdfs:label.
39
+ //
40
+ // sh:maxCount > 1 / unbounded → wrapped in ui:Multiple, with the
41
+ // ui:* field above as ui:part.
42
+ // sh:name → ui:label
43
+ // sh:minCount 1 → ui:required true
44
+ //
45
+ // Read-only mode (`opts.readOnly`) is wired via `store.updater.editable`
46
+ // — solid-ui's fields respect that flag and render as non-editable.
47
+
48
+ import { rdf } from './rdf.js';
49
+
50
+ const SH = 'http://www.w3.org/ns/shacl#';
51
+ const RDF_NS = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
52
+ const RDFS_NS = 'http://www.w3.org/2000/01/rdf-schema#';
53
+ const UI = 'http://www.w3.org/ns/ui#';
54
+ const XSD = 'http://www.w3.org/2001/XMLSchema#';
55
+ const OWL = 'http://www.w3.org/2002/07/owl#';
56
+
57
+ /**
58
+ * Parse a SHACL document into a normalized descriptor list.
59
+ * Pure / sync. Throws if the SHACL fails to parse.
60
+ *
61
+ * @param {string} shapeText raw turtle of the SHACL document
62
+ * @param {string} baseUri base URI used to resolve relative refs in the doc
63
+ * @returns {{ targets: Targets, properties: ShapeProp[] }}
64
+ */
65
+ export async function parseShape(shapeText, baseUri, ctx = {}) {
66
+ const abs = baseUri
67
+ ? new URL(baseUri, typeof document !== 'undefined' ? document.baseURI : 'file:///').href
68
+ : '';
69
+ const shapeStore = rdf.graph();
70
+ rdf.parse(shapeText, shapeStore, abs, 'text/turtle');
71
+ await followOwlImports(shapeStore, abs);
72
+
73
+ // Shape selection (in priority order):
74
+ // 1. If ctx supplies subject + dataStore: prefer the shape whose
75
+ // sh:targetClass matches one of the subject's rdf:type values.
76
+ // This is the "outer shape applies to user data" case (e.g.,
77
+ // schema:ItemList → SearchEnginesShape).
78
+ // 2. The file/topic wrapper pattern: file-shape uses sh:node to
79
+ // point at a topic-shape carrying the actual property
80
+ // constraints (e.g., DataKitchenSettingsFile → settings topic
81
+ // shape via foaf:primaryTopic). Prefer the sh:node-referenced
82
+ // shape — the file-shape's only property is the walker.
83
+ // 3. Fallback: first NodeShape in the file.
84
+ const allShapes = shapeStore.each(null,
85
+ rdf.sym(RDF_NS + 'type'),
86
+ rdf.sym(SH + 'NodeShape'));
87
+ if (!allShapes.length) {
88
+ return { targets: { nodes: [], classes: [], subjectsOf: [] }, properties: [] };
89
+ }
90
+ let nodeShape = null;
91
+ if (ctx.subject && ctx.dataStore) {
92
+ const subjectTypes = ctx.dataStore.each(ctx.subject, rdf.sym(RDF_NS + 'type'));
93
+ if (subjectTypes.length) {
94
+ nodeShape = allShapes.find(s => {
95
+ const tcs = shapeStore.each(s, rdf.sym(SH + 'targetClass'));
96
+ return tcs.some(tc => subjectTypes.some(t => t.value === tc.value));
97
+ }) || null;
98
+ }
99
+ }
100
+ nodeShape ||=
101
+ allShapes.find(s => shapeStore.any(null, rdf.sym(SH + 'node'), s)) ||
102
+ allShapes[0];
103
+
104
+ const targets = {
105
+ nodes: shapeStore.each(nodeShape, rdf.sym(SH + 'targetNode')),
106
+ classes: shapeStore.each(nodeShape, rdf.sym(SH + 'targetClass')),
107
+ subjectsOf: shapeStore.each(nodeShape, rdf.sym(SH + 'targetSubjectsOf')),
108
+ };
109
+
110
+ const properties = [];
111
+ for (const prop of shapeStore.each(nodeShape, rdf.sym(SH + 'property'))) {
112
+ const desc = readShapeProperty(shapeStore, prop);
113
+ if (desc) properties.push(desc);
114
+ }
115
+ return { targets, properties };
116
+ }
117
+
118
+ // Follow owl:imports declarations in the shape store, fetching each
119
+ // referenced TTL and parsing it into BOTH the shape store (so
120
+ // shape-to-form's own lookups like sh:class → narrower options work)
121
+ // AND the shared singleton store (so solid-ui's Choice handler can
122
+ // enumerate instances of those classes at render time). Cycle-safe
123
+ // via a visited set; failed fetches are warned and skipped.
124
+ async function followOwlImports(store, baseUri) {
125
+ const seen = new Set(baseUri ? [baseUri] : []);
126
+ const objectsOfImports = () =>
127
+ store.statementsMatching(null, rdf.sym(OWL + 'imports'), null).map(st => st.object);
128
+ const queue = objectsOfImports()
129
+ .map(o => new URL(o.value, baseUri || document.baseURI).href)
130
+ .filter(u => !seen.has(u));
131
+ while (queue.length) {
132
+ const url = queue.shift();
133
+ if (seen.has(url)) continue;
134
+ seen.add(url);
135
+ try {
136
+ const resp = await fetch(url);
137
+ if (!resp.ok) { console.warn(`[shape-to-form] owl:imports HTTP ${resp.status}: ${url}`); continue; }
138
+ const text = await resp.text();
139
+ rdf.parse(text, store, url, 'text/turtle');
140
+ try { rdf.parse(text, rdf.store, url, 'text/turtle'); }
141
+ catch (_) { /* shared store may already have these triples; ignore */ }
142
+ const more = objectsOfImports()
143
+ .map(o => new URL(o.value, url).href)
144
+ .filter(u => !seen.has(u));
145
+ queue.push(...more);
146
+ } catch (err) {
147
+ console.warn(`[shape-to-form] owl:imports ${url}: ${err.message}`);
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Read a single sh:property descriptor from a shape store. Exported so
154
+ * components that parse multi-NodeShape files (e.g. sol-tree-edit, which
155
+ * routes one shape per ui:Component / ui:Link / ui:Menu via
156
+ * sh:targetClass) can reuse the same walker without re-implementing it.
157
+ *
158
+ * Recurses into sh:node when present, populating `nestedProperties` so
159
+ * renderers can synthesise a ui:Group / ui:Multiple for nested data
160
+ * shapes (e.g. a list of schema:PropertyValue pairs).
161
+ */
162
+ export function readShapeProperty(shapeStore, prop) {
163
+ const pathNode = shapeStore.any(prop, rdf.sym(SH + 'path'));
164
+ if (!pathNode) return null;
165
+
166
+ // SHACL property paths. We handle the two common shapes:
167
+ // sh:path <pred> → forward predicate (path = NamedNode)
168
+ // sh:path [ sh:inversePath <pred> ] → inverse predicate (path = blank node)
169
+ // Sequence / alternative / zeroOrMore paths aren't supported yet.
170
+ let path = pathNode;
171
+ let reverse = false;
172
+ if (pathNode.termType !== 'NamedNode') {
173
+ const inv = shapeStore.any(pathNode, rdf.sym(SH + 'inversePath'));
174
+ if (!inv) return null; // complex path we don't understand
175
+ path = inv;
176
+ reverse = true;
177
+ }
178
+
179
+ const minCount = parseInt(shapeStore.anyValue(prop, rdf.sym(SH + 'minCount')) ?? '0', 10);
180
+ const maxRaw = shapeStore.anyValue(prop, rdf.sym(SH + 'maxCount'));
181
+ const maxCount = maxRaw == null ? Infinity : parseInt(maxRaw, 10);
182
+ const label = shapeStore.anyValue(prop, rdf.sym(SH + 'name')) ?? null;
183
+ const description = shapeStore.anyValue(prop, rdf.sym(SH + 'description')) ?? null;
184
+
185
+ const dt = shapeStore.any(prop, rdf.sym(SH + 'datatype'));
186
+ const datatype = dt ? dt.value : null;
187
+
188
+ const inList = shapeStore.any(prop, rdf.sym(SH + 'in'));
189
+ let enumOpts = null, enumLabels = null;
190
+ if (inList) {
191
+ const items = collectRdfList(shapeStore, inList);
192
+ enumOpts = items.map(n => ({ value: n.value, termType: n.termType }));
193
+ enumLabels = items.map(n => {
194
+ if (n.termType !== 'NamedNode') return n.value;
195
+ const lbl = shapeStore.anyValue(n, rdf.sym(RDFS_NS + 'label'));
196
+ return lbl || n.value;
197
+ });
198
+ }
199
+
200
+ const nk = shapeStore.any(prop, rdf.sym(SH + 'nodeKind'));
201
+ const nodeKind = nk ? nk.value : null;
202
+
203
+ // sh:class — values must be instances of this class. shape-to-form
204
+ // emits ui:from on the synthesized ui:Choice, leaving the runtime
205
+ // enumeration to solid-ui (it walks `kb.each(null, rdf:type, X)`).
206
+ const classNode = shapeStore.any(prop, rdf.sym(SH + 'class')) || null;
207
+
208
+ // sh:node — a nested NodeShape validating the values of this path.
209
+ // Each matching value is a blank/named node carrying its own
210
+ // sh:property entries; the renderer turns this into a ui:Group of
211
+ // sub-fields (wrapped in a ui:Multiple when the outer property is
212
+ // multi-valued).
213
+ const nodeShape = shapeStore.any(prop, rdf.sym(SH + 'node'));
214
+ // ui:sortedBy on a container property — names the inner predicate
215
+ // whose integer value orders the rolodex cards. Renderer hides the
216
+ // named inner field and replaces it with ↑/↓ buttons that swap
217
+ // values with the previous / next subject.
218
+ const sortedBy = shapeStore.any(prop, rdf.sym(UI + 'sortedBy')) || null;
219
+ let nestedProperties = null;
220
+ if (nodeShape) {
221
+ nestedProperties = [];
222
+ for (const subProp of shapeStore.each(nodeShape, rdf.sym(SH + 'property'))) {
223
+ const subDesc = readShapeProperty(shapeStore, subProp);
224
+ if (subDesc) nestedProperties.push(subDesc);
225
+ }
226
+ }
227
+
228
+ const key = localPart(path.value);
229
+
230
+ return {
231
+ path, key, datatype, enumOpts, enumLabels, nodeKind, classNode,
232
+ minCount, maxCount, label, description, nestedProperties, reverse,
233
+ sortedBy,
234
+ };
235
+ }
236
+
237
+ function localPart(uri) {
238
+ const i = Math.max(uri.lastIndexOf('#'), uri.lastIndexOf('/'));
239
+ return i === -1 ? uri : uri.slice(i + 1);
240
+ }
241
+
242
+ function collectRdfList(store, head) {
243
+ if (!head) return [];
244
+ if (head.termType === 'Collection' && Array.isArray(head.elements)) {
245
+ return head.elements;
246
+ }
247
+ const FIRST = rdf.sym(RDF_NS + 'first');
248
+ const REST = rdf.sym(RDF_NS + 'rest');
249
+ const NIL = RDF_NS + 'nil';
250
+ const out = [];
251
+ let node = head;
252
+ while (node && node.value !== NIL) {
253
+ const first = store.any(node, FIRST);
254
+ if (first) out.push(first);
255
+ node = store.any(node, REST);
256
+ }
257
+ return out;
258
+ }
259
+
260
+ /**
261
+ * Resolve a parsed shape's targets against a data graph → list of
262
+ * subjects the shape covers.
263
+ */
264
+ export function findSubjects(store, targets, baseDoc = null) {
265
+ const seen = new Set();
266
+ const out = [];
267
+ const add = (n) => { if (n && !seen.has(n.value)) { seen.add(n.value); out.push(n); } };
268
+
269
+ for (const node of targets.nodes) add(node);
270
+ for (const cls of targets.classes) {
271
+ for (const s of store.each(null, rdf.sym(RDF_NS + 'type'), cls, baseDoc)) add(s);
272
+ }
273
+ for (const pred of targets.subjectsOf) {
274
+ for (const st of store.statementsMatching(null, pred, null, baseDoc)) add(st.subject);
275
+ }
276
+ return out;
277
+ }
278
+
279
+ /**
280
+ * Render an editable record form for one subject. Builds a synthetic
281
+ * ui:Form in the store, hands it to solid-ui's fieldFunction, and
282
+ * returns a cleanup function that removes the synthesized triples and
283
+ * detaches the rendered widget.
284
+ *
285
+ * @param {HTMLElement} container
286
+ * @param {Object} store rdflib graph (typically rdf.store)
287
+ * @param {Object} subject NamedNode being edited
288
+ * @param {ShapeProp[]} properties from parseShape().properties
289
+ * @param {Object} [opts]
290
+ * @param {Object?} [opts.doc] named-graph for the data (NamedNode)
291
+ * @param {Function} [opts.onChange] called with (subject) after every mutation
292
+ * @param {boolean} [opts.readOnly] render via solid-ui's read-only path
293
+ * @returns {Function} cleanup
294
+ */
295
+ export function renderRecordForm(container, store, subject, properties, opts = {}) {
296
+ const doc = opts.doc ?? null;
297
+ const onChange = typeof opts.onChange === 'function' ? opts.onChange : () => {};
298
+ const readOnly = !!opts.readOnly;
299
+
300
+ const inner = document.createElement('div');
301
+ inner.className = 'sol-form-shape-fields';
302
+ if (readOnly) inner.classList.add('sol-form-shape-readonly');
303
+ container.appendChild(inner);
304
+
305
+ const fieldFunction = window.UI?.widgets?.fieldFunction
306
+ ?? window.UI?.widgets?.forms?.fieldFunction;
307
+ if (typeof fieldFunction !== 'function') {
308
+ inner.innerHTML = '<div class="sol-form-error">solid-ui is not loaded — required for shape-driven forms.</div>';
309
+ return () => { if (inner.parentNode === container) container.removeChild(inner); };
310
+ }
311
+
312
+ // Solid-ui's editable flag governs whether fields render as inputs
313
+ // or as read-only text. Save and restore around the render so other
314
+ // forms aren't affected.
315
+ const origEditable = store.updater?.editable;
316
+ if (readOnly && store.updater) store.updater.editable = () => false;
317
+
318
+ // Each render synthesises ui:Form widget triples. We put them in a
319
+ // SEPARATE named graph (formGraph) rather than `doc`, so that a
320
+ // serialization of `doc` (e.g. sol-form's getTurtle) yields just the
321
+ // user's data — never the form metadata. The cleanup still removes
322
+ // them outright on form teardown.
323
+ const formGraph = rdf.sym('about:sol-form-synth#g');
324
+ const synthesized = [];
325
+ const add = (s, p, o, g = formGraph) => {
326
+ store.add(s, p, o, g);
327
+ synthesized.push({ s, p, o, g });
328
+ };
329
+
330
+ // For each descriptor, build a ui:* field node and hand it (or its
331
+ // ui:Multiple wrapper) to solid-ui. The widgets sit one-per-row in
332
+ // the same container; mixing solid-ui-rendered widgets in one list
333
+ // is supported because each fieldFunction call returns an
334
+ // independent DOM subtree.
335
+ for (const desc of properties) {
336
+ const row = document.createElement('div');
337
+ row.className = 'sol-form-shape-key';
338
+ row.dataset.key = desc.key;
339
+ inner.appendChild(row);
340
+
341
+ const cb = (ok /*, msg */) => {
342
+ if (ok) onChange(subject);
343
+ };
344
+
345
+ // Multi-valued primitive (no sh:node, no sh:in) → render rows
346
+ // ourselves. Workaround for a solid-ui basicField limitation: when
347
+ // wrapped in ui:Multiple, solid-ui passes the *value* as `subject`
348
+ // to the inner field, and basicField then does
349
+ // `kb.any(subject, property, …)` which looks for `<value> path ?`
350
+ // — a triple that doesn't exist for primitive multi-values like
351
+ // `<#All> dct:source <url>`. Result: inputs render empty.
352
+ // Persistence still goes through `store.updater.update` (rdflib's
353
+ // PATCH path) per [[feedback-no-reinvent-saves]].
354
+ const isMulti = desc.maxCount === Infinity || desc.maxCount > 1;
355
+ if (isMulti && !desc.nestedProperties && !desc.enumOpts) {
356
+ renderPrimitiveMulti(row, store, subject, desc, doc, cb, readOnly);
357
+ continue;
358
+ }
359
+
360
+ const fieldNode = buildFieldNode(store, desc, synthesized, formGraph);
361
+ if (!fieldNode) {
362
+ row.textContent = '(unrecognised shape for ' + (desc.label || desc.key) + ')';
363
+ continue;
364
+ }
365
+
366
+ try {
367
+ const renderFn = fieldFunction(document, fieldNode);
368
+ if (typeof renderFn !== 'function') {
369
+ row.textContent = '(no renderer for ' + (desc.label || desc.key) + ')';
370
+ continue;
371
+ }
372
+ const widget = renderFn(document, row, {}, subject, fieldNode, doc, cb);
373
+ if (widget && !row.contains(widget)) row.appendChild(widget);
374
+
375
+ // solid-ui's single-select Choice does NOT autosave on change —
376
+ // only its multiSelect path writes back. For sh:class-driven
377
+ // single-cardinality dropdowns we attach our own change handler
378
+ // that replaces the predicate's value with the picked URI and
379
+ // PUTs the result through updater.update.
380
+ if (!desc.nestedProperties) wireSingleSelectAutosave(row, store, subject, desc.path, doc, cb);
381
+ } catch (err) {
382
+ row.textContent = err.message;
383
+ console.error('[shape-to-form]', err);
384
+ }
385
+ }
386
+
387
+ return () => {
388
+ // Remove every triple we added during this render so the store
389
+ // doesn't accumulate dead ui:* metadata across renders.
390
+ for (const st of synthesized) {
391
+ if (!st) continue;
392
+ for (const match of store.statementsMatching(st.s, st.p, st.o, st.g).slice()) {
393
+ store.remove(match);
394
+ }
395
+ }
396
+ if (readOnly && store.updater && origEditable !== undefined) {
397
+ store.updater.editable = origEditable;
398
+ }
399
+ if (inner.parentNode === container) container.removeChild(inner);
400
+ };
401
+ }
402
+
403
+ // Render a multi-valued primitive (no sh:node, no sh:in) as a label
404
+ // + one input row per existing value, with ✕ to remove a value and
405
+ // + to add. Each commit PATCHes via `store.updater.update`.
406
+ //
407
+ // Why this exists: solid-ui's basicField, when wrapped in ui:Multiple,
408
+ // is passed each value as `subject` and then queries
409
+ // `kb.any(subject, property, …)` — wrong for primitive multi-values
410
+ // (the value isn't itself the subject of `property`). See
411
+ // [[feedback-dont-invent-what-exists]] — this is the bug-workaround
412
+ // carve-out (surgical, library data path).
413
+ function renderPrimitiveMulti(row, store, subject, desc, doc, cb, readOnly) {
414
+ const label = document.createElement('label');
415
+ label.className = 'sol-form-shape-multi-label';
416
+ label.textContent = desc.label || desc.key;
417
+ row.appendChild(label);
418
+
419
+ const valueBox = document.createElement('div');
420
+ valueBox.className = 'sol-form-shape-multi-value';
421
+ row.appendChild(valueBox);
422
+
423
+ const list = document.createElement('div');
424
+ list.className = 'sol-form-shape-multi-list';
425
+ valueBox.appendChild(list);
426
+
427
+ const isIRI = desc.nodeKind === SH + 'IRI'
428
+ || desc.nodeKind === SH + 'IRIOrLiteral'
429
+ || desc.nodeKind === SH + 'BlankNodeOrIRI'
430
+ || desc.datatype === XSD + 'anyURI';
431
+ const toTerm = (raw) => {
432
+ const s = String(raw).trim();
433
+ if (!s) return null;
434
+ if (isIRI) {
435
+ try { return rdf.sym(s); } catch (_) { return null; }
436
+ }
437
+ return desc.datatype
438
+ ? rdf.literal(s, undefined, rdf.sym(desc.datatype))
439
+ : rdf.literal(s);
440
+ };
441
+
442
+ const makeItem = (existingValue) => {
443
+ const item = document.createElement('div');
444
+ item.className = 'sol-form-shape-multi-item';
445
+ const input = document.createElement('input');
446
+ input.type = isIRI ? 'url' : 'text';
447
+ input.value = existingValue ? existingValue.value : '';
448
+ input.disabled = readOnly;
449
+ item.appendChild(input);
450
+
451
+ const del = document.createElement('button');
452
+ del.type = 'button';
453
+ del.className = 'sol-form-shape-multi-del';
454
+ del.setAttribute('aria-label', 'Remove value');
455
+ del.textContent = '✕';
456
+ del.disabled = readOnly;
457
+ item.appendChild(del);
458
+
459
+ let bound = existingValue || null;
460
+ del.addEventListener('click', () => {
461
+ if (!bound) { list.removeChild(item); return; }
462
+ const olds = [rdf.st(subject, desc.path, bound, doc)];
463
+ store.updater.update(olds, [], (_uri, ok) => {
464
+ if (!ok) return;
465
+ list.removeChild(item);
466
+ cb(true);
467
+ });
468
+ });
469
+
470
+ input.addEventListener('change', () => {
471
+ const term = toTerm(input.value);
472
+ if (!term) return;
473
+ if (bound && bound.equals(term)) return;
474
+ const olds = bound ? [rdf.st(subject, desc.path, bound, doc)] : [];
475
+ const news = [rdf.st(subject, desc.path, term, doc)];
476
+ store.updater.update(olds, news, (_uri, ok) => {
477
+ if (!ok) return;
478
+ bound = term;
479
+ cb(true);
480
+ });
481
+ });
482
+
483
+ return item;
484
+ };
485
+
486
+ for (const v of store.each(subject, desc.path, null, doc)) {
487
+ list.appendChild(makeItem(v));
488
+ }
489
+
490
+ if (!readOnly) {
491
+ const add = document.createElement('button');
492
+ add.type = 'button';
493
+ add.className = 'sol-form-shape-multi-add';
494
+ add.textContent = `+ Add ${(desc.label || desc.key).toLowerCase()}`;
495
+ add.addEventListener('click', () => {
496
+ const item = makeItem(null);
497
+ list.appendChild(item);
498
+ item.querySelector('input').focus();
499
+ });
500
+ valueBox.appendChild(add);
501
+ }
502
+ }
503
+
504
+ // Find the picker's <select> inside `row` and attach a change handler
505
+ // that swaps the (subject, predicate, *) triples for a single new one
506
+ // pointing at the picked URI. Skipped for multi-select selects (those
507
+ // go through solid-ui's own update path).
508
+ function wireSingleSelectAutosave(row, store, subject, predicate, doc, cb) {
509
+ // solid-ui's single-select Choice (a) doesn't autosave the chosen value
510
+ // and (b) actively detaches the <select> from its parent on every change
511
+ // (its onChange does `container.removeChild(container.lastChild)` and
512
+ // never re-adds for single-select). We attach our own change handler
513
+ // that re-appends the detached <select> AND PATCHes the new value via
514
+ // store.updater.update — the same path solid-ui's basic fields use.
515
+ row.addEventListener('change', (e) => {
516
+ const sel = e.target;
517
+ if (!sel || sel.tagName !== 'SELECT' || sel.multiple) return;
518
+ const newUri = sel.value;
519
+ if (!newUri || !/^https?:|^urn:|^did:/.test(newUri)) return;
520
+
521
+ // solid-ui already removed sel from its parent (.choiceBox-selectBox).
522
+ // Put it back so the user keeps seeing the dropdown.
523
+ if (!sel.parentNode) {
524
+ const rhs = row.querySelector('.choiceBox-selectBox');
525
+ if (rhs) rhs.appendChild(sel);
526
+ }
527
+
528
+ if (!store.updater) { cb(false); return; }
529
+ const olds = store.statementsMatching(subject, predicate, null, doc).slice();
530
+ const news = [rdf.st(subject, predicate, rdf.sym(newUri), doc)];
531
+ store.updater.update(olds, news, (_uri, ok) => { cb(!!ok); });
532
+ });
533
+ }
534
+
535
+ // Build (and add to the store) the ui:* triples for one descriptor.
536
+ // Returns the form-side node solid-ui should render — that's either the
537
+ // field itself for single-valued, or a wrapping ui:Multiple for
538
+ // multi-valued. Returns null if the descriptor is malformed.
539
+ function buildFieldNode(store, desc, synthesized, doc) {
540
+ if (desc.nestedProperties) {
541
+ return buildNestedFieldNode(store, desc, synthesized, doc);
542
+ }
543
+ const fieldNode = rdf.blankNode();
544
+ const fieldType = uiTypeForDescriptor(desc, store, synthesized, doc, fieldNode);
545
+ if (!fieldType) return null;
546
+
547
+ addTriple(store, synthesized, doc, fieldNode, rdf.sym(RDF_NS + 'type'), rdf.sym(fieldType));
548
+ addTriple(store, synthesized, doc, fieldNode, rdf.sym(UI + 'property'), desc.path);
549
+ if (desc.label) {
550
+ addTriple(store, synthesized, doc, fieldNode, rdf.sym(UI + 'label'),
551
+ rdf.literal(desc.label));
552
+ }
553
+ // ui:required true (solid-ui doesn't surface this visibly today but
554
+ // SHACL min/max are recorded for completeness).
555
+ if (desc.minCount >= 1 && desc.maxCount === 1) {
556
+ addTriple(store, synthesized, doc, fieldNode, rdf.sym(UI + 'required'),
557
+ rdf.literal('true', rdf.sym(XSD + 'boolean')));
558
+ }
559
+ // Description — solid-ui's basic fields don't render a tooltip from
560
+ // this, but rdfs:comment is the conventional slot and any future
561
+ // help-popover would use it.
562
+ if (desc.description) {
563
+ addTriple(store, synthesized, doc, fieldNode, rdf.sym(RDFS_NS + 'comment'),
564
+ rdf.literal(desc.description));
565
+ }
566
+
567
+ // Multi-valued handling:
568
+ //
569
+ // • IRI-enum (sh:in with NamedNode options) → keep ONE ui:Choice and
570
+ // mark it ui:multiselect true. Solid-ui's Choice handler reads that
571
+ // flag and renders a single multi-select widget showing every
572
+ // selected option simultaneously — instead of multiple parallel
573
+ // dropdowns that each show the same first-alphabetical option
574
+ // (the "Imperial / Imperial" bug we hit with Multiple-wrap).
575
+ //
576
+ // • Other multi-valued: wrap in ui:Multiple. Solid-ui renders one
577
+ // row per value with +/− chrome and reorder controls for ordered
578
+ // lists.
579
+ const isMulti = desc.maxCount > 1 || (desc.maxCount === Infinity && desc.minCount >= 0);
580
+ if (isMulti) {
581
+ const fieldType = store.anyValue(fieldNode, rdf.sym(RDF_NS + 'type'));
582
+ if (fieldType === UI + 'Choice') {
583
+ addTriple(store, synthesized, doc, fieldNode, rdf.sym(UI + 'multiselect'),
584
+ rdf.literal('true', rdf.sym(XSD + 'boolean')));
585
+ return fieldNode;
586
+ }
587
+ const multi = rdf.blankNode();
588
+ addTriple(store, synthesized, doc, multi, rdf.sym(RDF_NS + 'type'), rdf.sym(UI + 'Multiple'));
589
+ addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'property'), desc.path);
590
+ addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'part'), fieldNode);
591
+ if (desc.label) {
592
+ addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'label'), rdf.literal(desc.label));
593
+ }
594
+ if (desc.reverse) {
595
+ // SHACL sh:inversePath → solid-ui reads ui:reverse to flip its
596
+ // own kb.each() direction (and to emit inverse triples on add).
597
+ addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'reverse'),
598
+ rdf.literal('true', rdf.sym(XSD + 'boolean')));
599
+ }
600
+ return multi;
601
+ }
602
+ return fieldNode;
603
+ }
604
+
605
+ // sh:node nested shape → ui:Group of sub-fields (wrapped in ui:Multiple
606
+ // when the outer property is multi-valued, matching menu-form.ttl's
607
+ // ui:attribute → :attrForm pattern). Each sub-property is built via the
608
+ // regular buildFieldNode so nesting can chain arbitrarily deep.
609
+ function buildNestedFieldNode(store, desc, synthesized, doc) {
610
+ const groupNode = rdf.blankNode();
611
+ addTriple(store, synthesized, doc, groupNode,
612
+ rdf.sym(RDF_NS + 'type'), rdf.sym(UI + 'Group'));
613
+
614
+ const subNodes = [];
615
+ for (const sub of desc.nestedProperties) {
616
+ const subNode = buildFieldNode(store, sub, synthesized, doc);
617
+ if (subNode) subNodes.push(subNode);
618
+ }
619
+ const list = synthesizeRdfList(store, synthesized, doc, subNodes);
620
+ addTriple(store, synthesized, doc, groupNode,
621
+ rdf.sym(UI + 'parts'), list);
622
+
623
+ const isMulti = desc.maxCount > 1 || desc.maxCount === Infinity;
624
+ if (!isMulti) {
625
+ if (desc.label) {
626
+ addTriple(store, synthesized, doc, groupNode,
627
+ rdf.sym(UI + 'label'), rdf.literal(desc.label));
628
+ }
629
+ return groupNode;
630
+ }
631
+ const multi = rdf.blankNode();
632
+ addTriple(store, synthesized, doc, multi, rdf.sym(RDF_NS + 'type'), rdf.sym(UI + 'Multiple'));
633
+ addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'property'), desc.path);
634
+ addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'part'), groupNode);
635
+ if (desc.label) {
636
+ addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'label'), rdf.literal(desc.label));
637
+ }
638
+ if (desc.reverse) {
639
+ addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'reverse'),
640
+ rdf.literal('true', rdf.sym(XSD + 'boolean')));
641
+ }
642
+ return multi;
643
+ }
644
+
645
+ // Build an rdflib Collection holding `nodes`. Returned as a single
646
+ // Collection term so solid-ui's Group handler — which reads
647
+ // `parts.elements` directly — finds the populated array. Cons-cell
648
+ // triples never enter the store; on teardown the Collection just gets
649
+ // garbage-collected with the synthesized parent triple.
650
+ function synthesizeRdfList(store, synthesized, doc, nodes) {
651
+ if (nodes.length === 0) return rdf.sym(RDF_NS + 'nil');
652
+ return new rdf.Collection(nodes);
653
+ }
654
+
655
+ function uiTypeForDescriptor(desc, store, synthesized, doc, fieldNode) {
656
+ // sh:class — reuse the existing class as the ui:Choice source.
657
+ // Solid-ui enumerates instances at render time, so the option list
658
+ // stays live as new instances are added to the data store.
659
+ if (desc.classNode) {
660
+ addTriple(store, synthesized, doc, fieldNode, rdf.sym(UI + 'from'), desc.classNode);
661
+ return UI + 'Choice';
662
+ }
663
+ // sh:in with IRI options → ui:Choice + synthesized class.
664
+ if (desc.enumOpts && desc.enumOpts.length > 0 && desc.enumOpts[0].termType === 'NamedNode') {
665
+ const choiceClass = synthesizeEnumClass(store, synthesized, doc, desc);
666
+ addTriple(store, synthesized, doc, fieldNode, rdf.sym(UI + 'from'), choiceClass);
667
+ return UI + 'Choice';
668
+ }
669
+ // sh:in with literal options: no faithful ui:* mapping. Fall back to
670
+ // a text field — solid-ui renders it; users type the value. (Adding
671
+ // a class-with-instances mapping would change the stored RDF kind to
672
+ // a URI, which we don't want here.)
673
+ if (desc.enumOpts && desc.enumOpts.length > 0) {
674
+ return UI + 'SingleLineTextField';
675
+ }
676
+ // IRI-valued single field.
677
+ if (isIriKind(desc)) return UI + 'NamedNodeURIField';
678
+
679
+ switch (desc.datatype) {
680
+ case XSD + 'integer': return UI + 'IntegerField';
681
+ case XSD + 'decimal':
682
+ case XSD + 'double':
683
+ case XSD + 'float': return UI + 'DecimalField';
684
+ case XSD + 'boolean': return UI + 'BooleanField';
685
+ case XSD + 'date': return UI + 'DateField';
686
+ case XSD + 'dateTime':return UI + 'DateTimeField';
687
+ case XSD + 'anyURI': return UI + 'NamedNodeURIField';
688
+ case XSD + 'string':
689
+ default: return UI + 'SingleLineTextField';
690
+ }
691
+ }
692
+
693
+ function isIriKind(desc) {
694
+ return desc.nodeKind === SH + 'IRI' ||
695
+ desc.nodeKind === SH + 'IRIOrLiteral' ||
696
+ desc.nodeKind === SH + 'BlankNodeOrIRI';
697
+ }
698
+
699
+ // Build a unique rdfs:Class and declare each enum URI as an instance of
700
+ // it, propagating rdfs:label from the shape so solid-ui's Choice shows
701
+ // human-friendly text in the dropdown.
702
+ function synthesizeEnumClass(store, synthesized, doc, desc) {
703
+ const cls = rdf.blankNode();
704
+ addTriple(store, synthesized, doc, cls, rdf.sym(RDF_NS + 'type'), rdf.sym(RDFS_NS + 'Class'));
705
+ for (let i = 0; i < desc.enumOpts.length; i++) {
706
+ const opt = desc.enumOpts[i];
707
+ const node = rdf.sym(opt.value);
708
+ addTriple(store, synthesized, doc, node, rdf.sym(RDF_NS + 'type'), cls);
709
+ const label = desc.enumLabels?.[i];
710
+ if (label && label !== opt.value) {
711
+ addTriple(store, synthesized, doc, node, rdf.sym(RDFS_NS + 'label'), rdf.literal(label));
712
+ }
713
+ }
714
+ return cls;
715
+ }
716
+
717
+ function addTriple(store, synthesized, g, s, p, o) {
718
+ store.add(s, p, o, g);
719
+ synthesized.push({ s, p, o, g });
720
+ }
721
+
722
+ /**
723
+ * @typedef {Object} Targets
724
+ * @property {Array} nodes sh:targetNode values
725
+ * @property {Array} classes sh:targetClass values
726
+ * @property {Array} subjectsOf sh:targetSubjectsOf values
727
+ */
728
+
729
+ /**
730
+ * @typedef {Object} ShapeProp
731
+ * @property {Object} path NamedNode — sh:path (the real predicate)
732
+ * @property {string} key local part of the path URI (display key)
733
+ * @property {?string} datatype xsd: URI string, or null
734
+ * @property {?Array} enumOpts [{value, termType}, ...] from sh:in, or null
735
+ * @property {?string[]} enumLabels per-option rdfs:label (NamedNode opts), or null
736
+ * @property {?string} nodeKind sh:nodeKind URI string, or null
737
+ * @property {number} minCount sh:minCount (default 0)
738
+ * @property {number} maxCount sh:maxCount (default Infinity)
739
+ * @property {?string} label sh:name
740
+ * @property {?string} description sh:description
741
+ */