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,573 @@
1
+ /**
2
+ * feed-fetch.js — RSS / Atom fetching and parsing for <sol-feed>.
3
+ *
4
+ * Zero-dependency in the common path: `DOMParser` handles both the feed
5
+ * XML and the image / plain-text extraction from item descriptions. Feed
6
+ * content is never returned as live HTML — callers get plain strings only,
7
+ * so there is no sanitization burden on the component.
8
+ *
9
+ * RDF source lists (see `parseSourceList`) are the one exception: they
10
+ * lazily `import()` the project's rdflib wrapper, which the standalone
11
+ * UMD build keeps external.
12
+ */
13
+
14
+ const domParser = new DOMParser();
15
+
16
+ /**
17
+ * Prepend a CORS proxy to a URL. The pattern may contain a literal
18
+ * `{url}` placeholder (replaced with the encoded URL); otherwise the
19
+ * encoded URL is appended, matching the legacy `proxy + encodeURI(...)`
20
+ * behaviour.
21
+ *
22
+ * @param {string} url the target URL
23
+ * @param {string} proxy the proxy pattern, or falsy for a direct fetch
24
+ * @returns {string}
25
+ */
26
+ export function applyProxy(url, proxy) {
27
+ if (!proxy) return url;
28
+ if (proxy.includes('{url}')) return proxy.replace('{url}', encodeURIComponent(url));
29
+ return proxy + encodeURI(url);
30
+ }
31
+
32
+ /** Resolve a (possibly relative) URL against the current document. */
33
+ function resolveUrl(url) {
34
+ try { return new URL(url, document.baseURI).href; } catch { return url; }
35
+ }
36
+
37
+ /** True when `absUrl` points at a different origin than the page. */
38
+ function isCrossOrigin(absUrl) {
39
+ try { return new URL(absUrl).origin !== location.origin; }
40
+ catch { return false; }
41
+ }
42
+
43
+ /**
44
+ * Fetch a feed / source-list URL, routing it through the CORS proxy only
45
+ * when it is cross-origin. A same-origin resource (e.g. your own bookmark
46
+ * file) is fetched directly so the proxy is never asked to relay it.
47
+ */
48
+ function feedFetch(url, proxy) {
49
+ const abs = resolveUrl(url);
50
+ return fetch(isCrossOrigin(abs) ? applyProxy(abs, proxy) : abs);
51
+ }
52
+
53
+ /** Strip a wrapping `<![CDATA[ ... ]]>` and trim. */
54
+ function stripCdata(s) {
55
+ return (s || '')
56
+ .replace(/^\s*<!\[CDATA\[/, '')
57
+ .replace(/\]\]>\s*$/, '')
58
+ .trim();
59
+ }
60
+
61
+ /** First descendant element with one of the given (qualified) tag names. */
62
+ function firstTag(el, ...tags) {
63
+ for (const tag of tags) {
64
+ const found = el.getElementsByTagName(tag)[0];
65
+ if (found) return found;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /** Text content of the first matching child tag. */
71
+ function tagText(el, ...tags) {
72
+ const found = firstTag(el, ...tags);
73
+ return found ? found.textContent || '' : '';
74
+ }
75
+
76
+ /** Collapse an HTML (or CDATA-wrapped HTML) fragment to plain text. */
77
+ function htmlToText(html) {
78
+ if (!html) return '';
79
+ const doc = domParser.parseFromString(stripCdata(html), 'text/html');
80
+ return (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
81
+ }
82
+
83
+ /** First `<img src>` found inside an HTML fragment, or null. */
84
+ function firstImageIn(html) {
85
+ if (!html) return null;
86
+ const doc = domParser.parseFromString(stripCdata(html), 'text/html');
87
+ const img = doc.querySelector('img[src]');
88
+ return img ? img.getAttribute('src') : null;
89
+ }
90
+
91
+ const IMG_EXT = /\.(jpe?g|png|gif|webp|avif)(\?|#|$)/i;
92
+
93
+ /** Pick a representative image URL for an item, trying the richest
94
+ * feed extensions first and falling back to the description HTML. */
95
+ function pickImage(el, descHtml) {
96
+ for (const tag of ['media:thumbnail', 'media:content']) {
97
+ const m = el.getElementsByTagName(tag)[0];
98
+ const u = m && m.getAttribute('url');
99
+ if (u && (!m.getAttribute('type') || /^image\//.test(m.getAttribute('type')))) return u;
100
+ }
101
+ for (const enc of el.getElementsByTagName('enclosure')) {
102
+ const type = enc.getAttribute('type') || '';
103
+ const u = enc.getAttribute('url');
104
+ if (u && (/^image\//.test(type) || IMG_EXT.test(u))) return u;
105
+ }
106
+ const itunes = el.getElementsByTagName('itunes:image')[0];
107
+ if (itunes && itunes.getAttribute('href')) return itunes.getAttribute('href');
108
+ return firstImageIn(descHtml);
109
+ }
110
+
111
+ /** Resolve an item link, keeping the legacy workarounds for feeds that
112
+ * bury the link inside CDATA / content markup. */
113
+ function pickLink(el) {
114
+ let link = '';
115
+ const linkEl = el.getElementsByTagName('link')[0];
116
+ if (linkEl) {
117
+ link = (linkEl.textContent || '').trim();
118
+ if (!link || /\s/.test(link)) link = linkEl.getAttribute('href') || link;
119
+ }
120
+ if (!link || /\s/.test(link)) {
121
+ const content = tagText(el, 'content:encoded', 'content', 'description');
122
+ const m = content && content.match(/href=["']([^"']+)["']/i);
123
+ if (m) link = m[1];
124
+ }
125
+ link = stripCdata(link);
126
+ return link ? link.replace(/^http:/, 'https:') : '';
127
+ }
128
+
129
+ /** Parse one `<item>` / `<entry>` element into a feed-item record. */
130
+ function parseItem(el, source) {
131
+ const rawDesc = tagText(el, 'content:encoded', 'description', 'summary', 'content');
132
+ return {
133
+ title: htmlToText(tagText(el, 'title')) || '(untitled)',
134
+ link: pickLink(el),
135
+ summary: htmlToText(rawDesc),
136
+ image: pickImage(el, rawDesc),
137
+ pubDate: (tagText(el, 'pubDate', 'published', 'updated', 'dc:date')).trim(),
138
+ source,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Fetch and parse a single RSS or Atom feed.
144
+ *
145
+ * @param {string} feedUri the feed URL
146
+ * @param {{proxy?: string}} [options] CORS proxy pattern
147
+ * @returns {Promise<Array<object>>} feed-item records
148
+ */
149
+ export async function getFeedItems(feedUri, options = {}) {
150
+ const resp = await feedFetch(feedUri, options.proxy);
151
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching feed`);
152
+ const dom = domParser.parseFromString(await resp.text(), 'text/xml');
153
+ if (dom.getElementsByTagName('parsererror').length) {
154
+ throw new Error('Feed is not well-formed XML');
155
+ }
156
+
157
+ const source = htmlToText(
158
+ tagText(dom.documentElement, 'title')
159
+ ) || feedUri;
160
+
161
+ let items = Array.from(dom.getElementsByTagName('item'));
162
+ if (!items.length) items = Array.from(dom.getElementsByTagName('entry'));
163
+
164
+ return items
165
+ .map(el => parseItem(el, source))
166
+ .filter(it => it.link || it.title !== '(untitled)');
167
+ }
168
+
169
+ /* ── source lists ─────────────────────────────────────────────────────── */
170
+
171
+ /* Vocabularies the source list may use — the parser accepts both the W3C
172
+ * bookmark ontology (bk:/ui:) and SKOS (skos:/dct:) and treats them as
173
+ * interchangeable. */
174
+ const NS = {
175
+ rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
176
+ ui: 'http://www.w3.org/ns/ui#',
177
+ bk: 'http://www.w3.org/2002/01/bookmark#',
178
+ skos: 'http://www.w3.org/2004/02/skos/core#',
179
+ dct: 'http://purl.org/dc/terms/',
180
+ dcat: 'http://www.w3.org/ns/dcat#',
181
+ };
182
+
183
+ /** Last path / fragment segment of a URI — a readable fallback label. */
184
+ const lastSegment = uri => uri.replace(/[#/]+$/, '').replace(/^.*[#/]/, '') || uri;
185
+
186
+ /** Drop feeds that repeat a URL, keeping first-seen order. */
187
+ function dedupeFeeds(feeds) {
188
+ const seen = new Set();
189
+ return feeds.filter(f => f.url && !seen.has(f.url) && seen.add(f.url));
190
+ }
191
+
192
+ /**
193
+ * Parse a Turtle document and return the feed list scoped to the focus
194
+ * topic's subtree. Accepts BOTH the W3C bookmark ontology and SKOS, in
195
+ * either direction (broader/narrower, topConceptOf/hasTopConcept).
196
+ *
197
+ * # Bookmark form
198
+ * <#Feeds> a bk:Topic ; ui:label "Feeds" .
199
+ * <#News> a bk:Topic ; ui:label "News" ; bk:subTopicOf <#Feeds> .
200
+ * :00012 a ui:Link ; ui:label "NY Times" ;
201
+ * bk:recalls <https://…/World.xml> ; bk:hasTopic <#News> .
202
+ *
203
+ * # SKOS / DCAT form (feed IRI is the URL)
204
+ * <#Feeds> a skos:ConceptScheme ; skos:prefLabel "Feeds" .
205
+ * <#News> a skos:Concept ; skos:prefLabel "News" ; skos:topConceptOf <#Feeds> .
206
+ * <https://…/World.xml> dct:title "NY Times" ; dct:subject <#News> .
207
+ *
208
+ * # SKOS / DCAT form (local node — the feed URL is an editable property)
209
+ * <#nyt> a dcat:Dataset, rss:channel ; dct:title "NY Times" ;
210
+ * dcat:accessURL <https://…/World.xml> ; dcat:theme <#News> .
211
+ *
212
+ * Feeds whose topic falls outside the focus subtree (or isn't a defined
213
+ * topic) are dropped. Requires rdflib on the page.
214
+ */
215
+ async function feedsFromRdf(fileUri, focusUri, text) {
216
+ let rdf;
217
+ try { ({ rdf } = await import('../../core/rdf.js')); }
218
+ catch { throw new Error('RDF source lists need rdflib on the page'); }
219
+ if (!rdf.isReady()) throw new Error('rdflib is not available');
220
+
221
+ const store = rdf.graph();
222
+ rdf.parse(text, store, fileUri, 'text/turtle');
223
+
224
+ const sym = u => rdf.sym(u);
225
+ const valueOf = (subj, pred) => {
226
+ const o = store.any(subj, sym(pred), null);
227
+ return o ? o.value : '';
228
+ };
229
+ /** Yield every URI-valued child of `t` across all hierarchy predicates
230
+ * in both ontologies, in both directions. */
231
+ const childrenOf = function* (t) {
232
+ const tNode = sym(t);
233
+ // <child> rel <t> — forward
234
+ for (const pred of [NS.bk + 'subTopicOf', NS.skos + 'broader', NS.skos + 'topConceptOf']) {
235
+ for (const st of store.statementsMatching(null, sym(pred), tNode)) {
236
+ if (st.subject.termType === 'NamedNode') yield st.subject.value;
237
+ }
238
+ }
239
+ // <t> rel <child> — inverse
240
+ for (const pred of [NS.skos + 'narrower', NS.skos + 'hasTopConcept']) {
241
+ for (const st of store.statementsMatching(tNode, sym(pred), null)) {
242
+ if (st.object.termType === 'NamedNode') yield st.object.value;
243
+ }
244
+ }
245
+ };
246
+
247
+ // Topic label table — any defined Topic / Concept / ConceptScheme.
248
+ const topicLabel = new Map();
249
+ const topicTypes = [NS.bk + 'Topic', NS.skos + 'Concept', NS.skos + 'ConceptScheme'];
250
+ const labelPreds = [NS.ui + 'label', NS.skos + 'prefLabel'];
251
+ for (const t of topicTypes) {
252
+ for (const st of store.statementsMatching(null, sym(NS.rdf + 'type'), sym(t))) {
253
+ if (topicLabel.has(st.subject.value)) continue;
254
+ let label = '';
255
+ for (const p of labelPreds) {
256
+ label = valueOf(st.subject, p);
257
+ if (label) break;
258
+ }
259
+ topicLabel.set(st.subject.value, label || lastSegment(st.subject.value));
260
+ }
261
+ }
262
+
263
+ // Focus subtree: focus + transitive children across both vocabularies.
264
+ const subtree = new Set([focusUri]);
265
+ const queue = [focusUri];
266
+ while (queue.length) {
267
+ const t = queue.shift();
268
+ for (const child of childrenOf(t)) {
269
+ if (!subtree.has(child)) { subtree.add(child); queue.push(child); }
270
+ }
271
+ }
272
+
273
+ const feeds = [];
274
+
275
+ // Bookmark feeds: ui:Link with bk:recalls / bk:hasTopic / ui:label.
276
+ for (const st of store.statementsMatching(null, sym(NS.rdf + 'type'), sym(NS.ui + 'Link'))) {
277
+ const subj = st.subject;
278
+ const url = valueOf(subj, NS.bk + 'recalls');
279
+ if (!url) continue;
280
+ const topicUri = (store.any(subj, sym(NS.bk + 'hasTopic'), null) || {}).value || '';
281
+ if (!topicUri || !subtree.has(topicUri)) continue;
282
+ feeds.push({
283
+ uri: subj.value, // the feed's RDF subject (for editing)
284
+ label: valueOf(subj, NS.ui + 'label') || lastSegment(url),
285
+ url,
286
+ topic: topicLabel.get(topicUri) || lastSegment(topicUri),
287
+ topicUri,
288
+ });
289
+ }
290
+
291
+ // SKOS / DCAT feeds: any subject categorised into the subtree via
292
+ // dcat:theme or dct:subject. The fetch URL is dcat:accessURL (the feed
293
+ // endpoint) or dcat:landingPage, falling back to the subject's own IRI
294
+ // for the legacy "feed IRI is the URL" form. Label is dct:title.
295
+ const seenFeed = new Set();
296
+ for (const pred of [NS.dcat + 'theme', NS.dct + 'subject']) {
297
+ for (const st of store.statementsMatching(null, sym(pred), null)) {
298
+ const subj = st.subject;
299
+ if (subj.termType !== 'NamedNode' || seenFeed.has(subj.value)) continue;
300
+ const topicUri = st.object.value;
301
+ if (!subtree.has(topicUri)) continue;
302
+ const url = valueOf(subj, NS.dcat + 'accessURL')
303
+ || valueOf(subj, NS.dcat + 'landingPage')
304
+ || (/^https?:/.test(subj.value) ? subj.value : '');
305
+ if (!url) continue;
306
+ seenFeed.add(subj.value);
307
+ const posStr = valueOf(subj, 'http://schema.org/position');
308
+ feeds.push({
309
+ uri: subj.value, // the feed's RDF subject (for editing)
310
+ label: valueOf(subj, NS.dct + 'title')
311
+ || valueOf(subj, 'http://www.w3.org/2000/01/rdf-schema#label')
312
+ || lastSegment(url),
313
+ url,
314
+ topic: topicLabel.get(topicUri) || lastSegment(topicUri),
315
+ topicUri,
316
+ position: posStr === '' ? undefined : Number(posStr), // schema:position (for reorder)
317
+ });
318
+ }
319
+ }
320
+
321
+ // Topics in the focus subtree, in BFS order, with their labels — used by
322
+ // the "add source / add topic" forms in the all view to populate select
323
+ // dropdowns. Detect which ontology family is in use so writers can mint
324
+ // new triples in the same shape.
325
+ const topics = [...subtree].map(uri => ({
326
+ uri,
327
+ label: topicLabel.get(uri) || lastSegment(uri),
328
+ }));
329
+ const hasBookmark = store
330
+ .statementsMatching(null, sym(NS.rdf + 'type'), sym(NS.bk + 'Topic')).length > 0;
331
+
332
+ const catalogSt = store.statementsMatching(
333
+ null, sym(NS.rdf + 'type'), sym(NS.dcat + 'Catalog'))[0];
334
+
335
+ const out = dedupeFeeds(feeds);
336
+ Object.assign(out, {
337
+ topics,
338
+ fileUri,
339
+ focusUri,
340
+ catalogUri: catalogSt ? catalogSt.subject.value : '',
341
+ ontology: hasBookmark ? 'bookmark' : 'skos',
342
+ });
343
+ return out;
344
+ }
345
+
346
+ /**
347
+ * Resolve a `source` of the form `<rdfFile>#<Topic>` into the feed list
348
+ * scoped to that topic's `bk:subTopicOf` subtree. The fragment is required
349
+ * — without it the function throws.
350
+ *
351
+ * @param {string} sourceUri `<rdfFile>#<TopicName>`
352
+ * @param {{proxy?: string}} [options] CORS proxy pattern (only applied
353
+ * when the file is cross-origin)
354
+ * @returns {Promise<Array<{label:string,url:string,topic:string}>>}
355
+ */
356
+ export async function parseSourceList(sourceUri, { proxy } = {}) {
357
+ const abs = resolveUrl(sourceUri || '');
358
+ const hashIdx = abs.indexOf('#');
359
+ if (hashIdx === -1) {
360
+ throw new Error(
361
+ 'A topic IRI is required for view="topic" / "all" — e.g. source="feeds.ttl#News"',
362
+ );
363
+ }
364
+ const fileUri = abs.slice(0, hashIdx);
365
+ const resp = await feedFetch(fileUri, proxy);
366
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching source list`);
367
+ return feedsFromRdf(fileUri, abs, await resp.text());
368
+ }
369
+
370
+ /**
371
+ * Like {@link parseSourceList}, but returns the **nested** topic tree rooted
372
+ * at the focus topic rather than a flat source list. `parseSourceList`
373
+ * collapses every leaf onto its immediate topic, which loses the upper
374
+ * grouping tiers; <sol-gallery> needs the full hierarchy (e.g. the
375
+ * Art / Life groups above each image sub-topic).
376
+ *
377
+ * Each node is `{ uri, label, topics: TreeNode[], collections: {label,url,uri}[] }`.
378
+ * Leaves are `a ui:Link` with `bk:recalls` (the URL), `bk:hasTopic` (their
379
+ * topic), and `ui:label`. Subtree membership follows `bk:subTopicOf`
380
+ * (and the SKOS equivalents, for parity with parseSourceList).
381
+ *
382
+ * @param {string} sourceUri `<rdfFile>#<TopicName>`
383
+ * @param {{proxy?: string}} [options]
384
+ * @returns {Promise<{uri:string,label:string,topics:Array,collections:Array}>}
385
+ */
386
+ export async function parseBookmarkTree(sourceUri, { proxy } = {}) {
387
+ const abs = resolveUrl(sourceUri || '');
388
+ const hashIdx = abs.indexOf('#');
389
+ if (hashIdx === -1) {
390
+ throw new Error('A topic IRI is required — e.g. source="images.ttl#Images"');
391
+ }
392
+ const fileUri = abs.slice(0, hashIdx);
393
+ const resp = await feedFetch(fileUri, proxy);
394
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching source list`);
395
+
396
+ let rdf;
397
+ try { ({ rdf } = await import('../../core/rdf.js')); }
398
+ catch { throw new Error('RDF source lists need rdflib on the page'); }
399
+ if (!rdf.isReady()) throw new Error('rdflib is not available');
400
+
401
+ const store = rdf.graph();
402
+ rdf.parse(await resp.text(), store, fileUri, 'text/turtle');
403
+ const sym = u => rdf.sym(u);
404
+ const valueOf = (subj, pred) => {
405
+ const o = store.any(subj, sym(pred), null);
406
+ return o ? o.value : '';
407
+ };
408
+
409
+ // Topic labels (bk:Topic / skos), preferring ui:label then skos:prefLabel.
410
+ const topicLabel = new Map();
411
+ for (const t of [NS.bk + 'Topic', NS.skos + 'Concept', NS.skos + 'ConceptScheme']) {
412
+ for (const st of store.statementsMatching(null, sym(NS.rdf + 'type'), sym(t))) {
413
+ if (topicLabel.has(st.subject.value)) continue;
414
+ const label = valueOf(st.subject, NS.ui + 'label')
415
+ || valueOf(st.subject, NS.skos + 'prefLabel')
416
+ || lastSegment(st.subject.value);
417
+ topicLabel.set(st.subject.value, label);
418
+ }
419
+ }
420
+
421
+ // Direct child topics of a topic URI (subTopicOf + SKOS, both directions).
422
+ const childTopics = (uri) => {
423
+ const node = sym(uri);
424
+ const out = [];
425
+ for (const pred of [NS.bk + 'subTopicOf', NS.skos + 'broader', NS.skos + 'topConceptOf']) {
426
+ for (const st of store.statementsMatching(null, sym(pred), node)) {
427
+ if (st.subject.termType === 'NamedNode' && topicLabel.has(st.subject.value)) {
428
+ out.push(st.subject.value);
429
+ }
430
+ }
431
+ }
432
+ for (const pred of [NS.skos + 'narrower', NS.skos + 'hasTopConcept']) {
433
+ for (const st of store.statementsMatching(node, sym(pred), null)) {
434
+ if (st.object.termType === 'NamedNode' && topicLabel.has(st.object.value)) {
435
+ out.push(st.object.value);
436
+ }
437
+ }
438
+ }
439
+ return out;
440
+ };
441
+
442
+ // Collection leaves grouped by topic, in file order. Accepts the bookmark
443
+ // form (ui:Link + bk:recalls + bk:hasTopic + ui:label) and the SKOS/DCAT
444
+ // form (dcat:theme|dct:subject → topic, dcat:landingPage|dcat:accessURL →
445
+ // the page URL, dct:title → label).
446
+ const byTopic = new Map();
447
+ const seenColl = new Set();
448
+ const pushColl = (subjValue, topicUri, coll) => {
449
+ if (!topicUri || !coll.url || seenColl.has(subjValue)) return;
450
+ seenColl.add(subjValue);
451
+ if (!byTopic.has(topicUri)) byTopic.set(topicUri, []);
452
+ byTopic.get(topicUri).push(coll);
453
+ };
454
+ // Bookmark leaves.
455
+ for (const st of store.statementsMatching(null, sym(NS.rdf + 'type'), sym(NS.ui + 'Link'))) {
456
+ const subj = st.subject;
457
+ const url = valueOf(subj, NS.bk + 'recalls');
458
+ const topicUri = (store.any(subj, sym(NS.bk + 'hasTopic'), null) || {}).value || '';
459
+ pushColl(subj.value, topicUri, { uri: subj.value, label: valueOf(subj, NS.ui + 'label') || lastSegment(url), url });
460
+ }
461
+ // SKOS / DCAT leaves.
462
+ for (const pred of [NS.dcat + 'theme', NS.dct + 'subject']) {
463
+ for (const st of store.statementsMatching(null, sym(pred), null)) {
464
+ const subj = st.subject;
465
+ if (subj.termType !== 'NamedNode') continue;
466
+ const url = valueOf(subj, NS.dcat + 'landingPage')
467
+ || valueOf(subj, NS.dcat + 'accessURL')
468
+ || (/^https?:/.test(subj.value) ? subj.value : '');
469
+ pushColl(subj.value, st.object.value, {
470
+ uri: subj.value,
471
+ label: valueOf(subj, NS.dct + 'title') || valueOf(subj, NS.ui + 'label') || lastSegment(url),
472
+ url,
473
+ });
474
+ }
475
+ }
476
+
477
+ // Build the tree depth-first, guarding against cycles.
478
+ const seen = new Set();
479
+ const build = (uri) => {
480
+ seen.add(uri);
481
+ return {
482
+ uri,
483
+ label: topicLabel.get(uri) || lastSegment(uri),
484
+ collections: byTopic.get(uri) || [],
485
+ topics: childTopics(uri).filter(c => !seen.has(c)).map(build),
486
+ };
487
+ };
488
+ return build(abs);
489
+ }
490
+
491
+ /* ── schema:ItemList readers ──────────────────────────────────────────── */
492
+
493
+ const SCHEMA = 'http://schema.org/';
494
+ const HYDRA = 'http://www.w3.org/ns/hydra/core#';
495
+
496
+ /**
497
+ * Parse a Turtle document and return the engines in `<listUri>`'s
498
+ * `schema:itemListElement` set, sorted by `schema:position` (items
499
+ * without a position fall to the end, ties resolved by first-seen
500
+ * triple order).
501
+ *
502
+ * <#SearchEngines> a schema:ItemList ;
503
+ * schema:itemListElement :ddg , :google .
504
+ * :ddg a hydra:IriTemplate ;
505
+ * dct:title "DuckDuckGo" ; schema:position 1 ;
506
+ * hydra:template "https://duckduckgo.com/?q={query}" .
507
+ *
508
+ * Each returned record is `{ id, label, template, position }`. `id`
509
+ * is the IRI's fragment (or last path segment); `label` falls back
510
+ * through `dct:title` → `schema:name` → `rdfs:label` → fragment.
511
+ */
512
+ async function enginesFromRdf(fileUri, listUri, text) {
513
+ let rdf;
514
+ try { ({ rdf } = await import('../../core/rdf.js')); }
515
+ catch { throw new Error('RDF source lists need rdflib on the page'); }
516
+ if (!rdf.isReady()) throw new Error('rdflib is not available');
517
+
518
+ const store = rdf.graph();
519
+ rdf.parse(text, store, fileUri, 'text/turtle');
520
+
521
+ const sym = u => rdf.sym(u);
522
+ const valueOf = (subj, pred) => {
523
+ const o = store.any(subj, sym(pred), null);
524
+ return o ? o.value : '';
525
+ };
526
+
527
+ const list = sym(listUri);
528
+ const engines = [];
529
+ let seq = 0;
530
+ for (const st of store.statementsMatching(list, sym(SCHEMA + 'itemListElement'), null)) {
531
+ const subj = st.object;
532
+ if (subj.termType !== 'NamedNode') continue;
533
+ const template = valueOf(subj, HYDRA + 'template');
534
+ if (!template) continue;
535
+ const posStr = valueOf(subj, SCHEMA + 'position');
536
+ const position = posStr ? Number(posStr) : Number.POSITIVE_INFINITY;
537
+ engines.push({
538
+ id: lastSegment(subj.value),
539
+ label:
540
+ valueOf(subj, 'http://purl.org/dc/terms/title')
541
+ || valueOf(subj, SCHEMA + 'name')
542
+ || valueOf(subj, 'http://www.w3.org/2000/01/rdf-schema#label')
543
+ || lastSegment(subj.value),
544
+ template,
545
+ position,
546
+ _seq: seq++,
547
+ });
548
+ }
549
+ engines.sort((a, b) => (a.position - b.position) || (a._seq - b._seq));
550
+ return engines.map(({ _seq, ...rest }) => rest);
551
+ }
552
+
553
+ /**
554
+ * Resolve a `source` of the form `<rdfFile>#<ListName>` into a
555
+ * position-sorted array of `{ id, label, template, position }` engine
556
+ * records. The fragment is required.
557
+ *
558
+ * @param {string} sourceUri `<rdfFile>#<ListName>`
559
+ * @param {{proxy?: string}} [options] CORS proxy pattern
560
+ */
561
+ export async function parseEngineList(sourceUri, { proxy } = {}) {
562
+ const abs = resolveUrl(sourceUri || '');
563
+ const hashIdx = abs.indexOf('#');
564
+ if (hashIdx === -1) {
565
+ throw new Error(
566
+ 'An ItemList IRI is required — e.g. source="search-engines.ttl#SearchEngines"',
567
+ );
568
+ }
569
+ const fileUri = abs.slice(0, hashIdx);
570
+ const resp = await feedFetch(fileUri, proxy);
571
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching engine list`);
572
+ return enginesFromRdf(fileUri, abs, await resp.text());
573
+ }
@@ -0,0 +1,64 @@
1
+ export const csvHelp = {
2
+ title: 'CSV Format Reference',
3
+ sections: [
4
+ {
5
+ heading: 'Basic Format',
6
+ items: [
7
+ {
8
+ title: 'Header Row + Data',
9
+ description: 'First row is the header; each subsequent row is a record.',
10
+ code: `Name,City,Country\nAmara Okafor,Lagos,Nigeria\nKenji Watanabe,Osaka,Japan\nCatalina Ruiz,Bogotá,Colombia`
11
+ },
12
+ {
13
+ title: 'Quoted Fields',
14
+ description: 'Wrap fields in double-quotes if they contain commas, newlines, or quotes.',
15
+ code: `Name,Note\n"Okafor, Amara","Senior engineer; leads the platform team"\n"Nguyen, Thien","Quote: ""ship it"""`
16
+ }
17
+ ]
18
+ },
19
+ {
20
+ heading: 'Delimiters',
21
+ items: [
22
+ { title: 'Comma (,)', description: 'Standard CSV.', code: `City,Country\nNairobi,Kenya\nMumbai,India` },
23
+ { title: 'Semicolon (;)', description: 'Common in European locales.', code: `City;Country\nBogotá;Colombia\nAmman;Jordan` },
24
+ { title: 'Tab (TSV)', description: 'Tab-separated values.', code: "City\tCountry\nAddis Ababa\tEthiopia\nSeoul\tSouth Korea" },
25
+ { title: 'Pipe (|)', description: 'Alternative delimiter.', code: `City|Country\nJakarta|Indonesia\nLagos|Nigeria` }
26
+ ]
27
+ },
28
+ {
29
+ heading: 'Special Cases',
30
+ items: [
31
+ {
32
+ title: 'Embedded Quotes',
33
+ description: 'Escape a quote inside a quoted field by doubling it.',
34
+ code: `Title,Quote\nBook,"She said, ""Hello!"""`
35
+ },
36
+ {
37
+ title: 'Empty Fields',
38
+ description: 'Adjacent delimiters produce empty values.',
39
+ code: `First,Middle,Last\nAmara,,Okafor\nSoo-Jin,,Park`
40
+ },
41
+ {
42
+ title: 'Numeric Data',
43
+ description: 'Pure-number columns get statistical analysis automatically.',
44
+ code: `Product,Units,Price\nWax print fabric,120,8.50\nKente cloth,45,22.00\nBatik sarong,80,14.75`
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ heading: 'Statistics Panel',
50
+ items: [
51
+ {
52
+ title: 'Numeric Columns',
53
+ description: 'Shows min, max, mean, median, and sum.',
54
+ code: `Score,Grade\n88,B+\n94,A\n76,C+\n91,A-`
55
+ },
56
+ {
57
+ title: 'Text Columns',
58
+ description: 'Shows unique-value count and most-common value.',
59
+ code: `Region,Office\nAfrica,Lagos\nAsia,Mumbai\nAfrica,Nairobi\nAsia,Seoul`
60
+ }
61
+ ]
62
+ }
63
+ ]
64
+ };
@@ -0,0 +1,41 @@
1
+ export const graphvizHelp = {
2
+ title: 'Graphviz DOT Reference',
3
+ sections: [
4
+ {
5
+ heading: 'Graph Types',
6
+ items: [
7
+ { title: 'Directed graph', description: 'Arrows show direction.', code: `digraph {\n Amara -> Kenji;\n Kenji -> Priya;\n}` },
8
+ { title: 'Undirected graph', description: 'Lines with no direction.', code: `graph {\n Lagos -- Nairobi;\n Nairobi -- Addis_Ababa;\n}` }
9
+ ]
10
+ },
11
+ {
12
+ heading: 'Node Attributes',
13
+ items: [
14
+ { title: 'Label and shape', description: 'Custom text and shape per node.', code: `digraph {\n A [label="Amara\\nLagos", shape=ellipse];\n B [label="Kenji\\nOsaka", shape=box];\n A -> B;\n}` },
15
+ { title: 'Common shapes', description: 'box, circle, ellipse, diamond, triangle, plaintext.', code: `A [shape=box];\nB [shape=diamond];\nC [shape=circle];` }
16
+ ]
17
+ },
18
+ {
19
+ heading: 'Edge Attributes',
20
+ items: [
21
+ { title: 'Labels and styles', description: 'Annotate and style edges.', code: `Amara -> Diego [label="mentors", style=dashed];\nDiego -> Nadia [label="reports to", color=blue];` }
22
+ ]
23
+ },
24
+ {
25
+ heading: 'Graph Attributes',
26
+ items: [
27
+ { title: 'Global defaults', description: 'Set style for all nodes or edges at once.', code: `digraph {\n graph [rankdir=LR, bgcolor=transparent];\n node [shape=ellipse, style=filled, fillcolor=lightyellow];\n edge [fontsize=10];\n Lagos -> Mumbai -> Seoul;\n}` }
28
+ ]
29
+ },
30
+ {
31
+ heading: 'Subgraphs',
32
+ items: [
33
+ {
34
+ title: 'Clusters',
35
+ description: 'Group nodes with a named subgraph (prefix "cluster_").',
36
+ code: `digraph {\n subgraph cluster_africa {\n label="Africa";\n Lagos; Nairobi; Addis_Ababa;\n }\n subgraph cluster_asia {\n label="Asia";\n Mumbai; Seoul; Jakarta;\n }\n Lagos -> Mumbai;\n}`
37
+ }
38
+ ]
39
+ }
40
+ ]
41
+ };