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,388 @@
1
+ /**
2
+ * calendar-fetch.js — fetch + parse iCalendar (RFC 5545) into plain JS
3
+ * event objects. Thin translation layer over ical.js; nothing in the
4
+ * render layer should ever import ical.js directly.
5
+ *
6
+ * The library handles the parts of RFC 5545 that are genuinely subtle —
7
+ * embedded VTIMEZONE blocks, recurrence (`RRULE` + `EXDATE` +
8
+ * `RECURRENCE-ID` overrides), date-vs-datetime distinction, line
9
+ * unfolding, escape sequences — so this module is mostly bookkeeping:
10
+ * given an URL or a text blob, hand back `Event[]` with recurrences
11
+ * already expanded into a `[start, start + windowDays)` window.
12
+ *
13
+ * Companion to `feed-fetch.js` (RSS/Atom) and `rdf-config.js` (TTL
14
+ * PropertyValue config). Same CORS-then-proxy fetch pattern as
15
+ * `feed-fetch.js` — most provider ICS endpoints (Google, Apple,
16
+ * Outlook, Proton) don't send `Access-Control-Allow-Origin`, so the
17
+ * `proxy` option does real work in practice.
18
+ */
19
+
20
+ import ICAL from 'ical.js';
21
+
22
+ /**
23
+ * Plain JS event shape returned to callers. ical.js's `Time`,
24
+ * `Duration`, etc. are converted to native `Date` here so the render
25
+ * layer doesn't need to know about them.
26
+ *
27
+ * @typedef {Object} CalendarEvent
28
+ * @property {string} uid stable identity; recurring instances share the master UID
29
+ * @property {string} summary event title (may be empty for malformed ICS)
30
+ * @property {string=} description
31
+ * @property {string=} location
32
+ * @property {string=} url
33
+ * @property {Date} start in the JS engine's local timezone
34
+ * @property {Date} end
35
+ * @property {boolean} allDay DTSTART was a DATE (no time component)
36
+ * @property {boolean} isRecurringInstance true if produced by RRULE expansion
37
+ * @property {string=} calendar human label for multi-calendar merges (v2)
38
+ */
39
+
40
+ /**
41
+ * Build a publicly-fetchable ICS URL from a provider + calendar id.
42
+ * Only `google` actually composes a URL today — the rest of the
43
+ * known providers expect the user to paste the share link directly,
44
+ * but they still drive the header label and (later) any provider-
45
+ * specific quirks in the fetch path.
46
+ *
47
+ * @param {string} provider "google" | "apple" | "outlook" | "proton" | "ics"
48
+ * @param {string} calendarId provider-specific; for Google, the calendar email/id
49
+ * @returns {string|null} URL when we can build one, null when the caller should
50
+ * fall back to its own explicit URL
51
+ */
52
+ export function buildProviderUrl(provider, calendarId) {
53
+ if (!calendarId) return null;
54
+ switch ((provider || '').toLowerCase()) {
55
+ case 'google':
56
+ return `https://calendar.google.com/calendar/ical/${encodeURIComponent(calendarId)}/public/basic.ics`;
57
+ default:
58
+ // apple / outlook / proton / ics — pass-through, caller has the URL.
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Apply a proxy pattern to a URL. Mirrors `feed-fetch.js`: if the
65
+ * pattern contains the literal token `{url}`, the encoded target
66
+ * replaces it; otherwise the encoded target is appended to the end
67
+ * of the pattern.
68
+ *
69
+ * @param {string} proxy pattern (empty / null → no proxy)
70
+ * @param {string} target absolute URL to fetch
71
+ * @returns {string} the URL to actually request
72
+ */
73
+ export function applyProxy(proxy, target) {
74
+ if (!proxy) return target;
75
+ if (proxy.includes('{url}')) return proxy.replace('{url}', encodeURIComponent(target));
76
+ return proxy + encodeURIComponent(target);
77
+ }
78
+
79
+ /**
80
+ * Fetch an ICS document, falling back to the proxy on CORS / network
81
+ * failure. Returns the raw text body.
82
+ *
83
+ * @param {string} url
84
+ * @param {{proxy?: string, signal?: AbortSignal}} opts
85
+ */
86
+ async function fetchICSText(url, { proxy = '', signal } = {}) {
87
+ // Try bare first — Solid pods and self-hosted ICS often do CORS, and
88
+ // a successful bare fetch saves a hop through the proxy.
89
+ try {
90
+ const resp = await fetch(url, { signal });
91
+ if (resp.ok) {
92
+ const text = await resp.text();
93
+ // Sniff: some servers respond 200 to misrouted requests with HTML.
94
+ if (text.includes('BEGIN:VCALENDAR')) return text;
95
+ }
96
+ // fall through to proxy attempt — server returned non-ICS or non-2xx
97
+ } catch (e) {
98
+ // Network / CORS error — try the proxy if one is configured.
99
+ if (!proxy) throw e;
100
+ }
101
+
102
+ if (!proxy) throw new Error(`Couldn't fetch calendar — try setting proxy=`);
103
+ const proxied = applyProxy(proxy, url);
104
+ const resp = await fetch(proxied, { signal });
105
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching calendar`);
106
+ const text = await resp.text();
107
+ if (!text.includes('BEGIN:VCALENDAR')) {
108
+ throw new Error('Proxy returned a non-ICS body');
109
+ }
110
+ return text;
111
+ }
112
+
113
+ /** Convert an ical.js Time to a native JS Date in the local TZ. */
114
+ function timeToDate(t) {
115
+ // `toJSDate()` on a date-only Time still returns a Date pinned to
116
+ // 00:00 local; the caller flags it via `allDay` rather than dropping
117
+ // the time component.
118
+ return t.toJSDate();
119
+ }
120
+
121
+ /** Known video-meeting host substrings, in preference order. The
122
+ * first match in the DESCRIPTION wins over arbitrary other URLs
123
+ * (so a "see gitter.im/x for chat" line never beats the actual
124
+ * meet.jit.si link). */
125
+ const MEETING_HOSTS = [
126
+ 'meet.jit.si',
127
+ 'zoom.us',
128
+ 'meet.google.com',
129
+ 'teams.microsoft.com',
130
+ 'whereby.com',
131
+ 'us02web.zoom.us', 'us04web.zoom.us', 'us05web.zoom.us', 'us06web.zoom.us',
132
+ ];
133
+
134
+ /** Match any `http(s)://…` URL up to whitespace / quote / angle-bracket
135
+ * / common ICS line-ending punctuation. Tuned to be greedy enough for
136
+ * query strings but not so greedy that trailing punctuation (".", ",",
137
+ * ")") becomes part of the URL. */
138
+ const URL_RE = /https?:\/\/[^\s<>"'\\]+/g;
139
+
140
+ /** Trim trailing punctuation that's commonly stuck to a URL when it
141
+ * sits at the end of a sentence in DESCRIPTION text. */
142
+ function trimUrlTail(u) {
143
+ return u.replace(/[.,;:!?\])]+$/, '');
144
+ }
145
+
146
+ /**
147
+ * Pick the best "click target" URL for an event, in priority order:
148
+ *
149
+ * 1. The standard `URL` iCal property.
150
+ * 2. LOCATION if the whole field is a URL (frequent for Jitsi-only
151
+ * calendars like the Solid CG one).
152
+ * 3. The first URL in DESCRIPTION that matches a known meeting host.
153
+ * 4. The first URL in DESCRIPTION of any host (so the W3C
154
+ * events-page link still becomes a click target on calendars
155
+ * that don't carry the join URL inline).
156
+ *
157
+ * Also reports whether LOCATION was consumed as the URL — the caller
158
+ * suppresses the location text-row in that case so the URL doesn't
159
+ * render twice (once as the summary's href, once below as plain text).
160
+ *
161
+ * @param {ICAL.Event} event
162
+ * @returns {{ url: string|undefined, locationIsUrl: boolean }}
163
+ */
164
+ function pickMeetingUrl(event) {
165
+ const explicitUrl = event.component.getFirstPropertyValue('url');
166
+ if (explicitUrl) return { url: String(explicitUrl), locationIsUrl: false };
167
+
168
+ const loc = event.location || '';
169
+ if (/^https?:\/\/\S+$/i.test(loc.trim())) {
170
+ return { url: trimUrlTail(loc.trim()), locationIsUrl: true };
171
+ }
172
+
173
+ const desc = event.description || '';
174
+ const urls = (desc.match(URL_RE) || []).map(trimUrlTail);
175
+ if (!urls.length) return { url: undefined, locationIsUrl: false };
176
+
177
+ for (const u of urls) {
178
+ try {
179
+ const host = new URL(u).hostname.toLowerCase();
180
+ if (MEETING_HOSTS.some(h => host === h || host.endsWith('.' + h) || host.endsWith(h))) {
181
+ return { url: u, locationIsUrl: false };
182
+ }
183
+ } catch { /* skip malformed */ }
184
+ }
185
+ return { url: urls[0], locationIsUrl: false };
186
+ }
187
+
188
+ /**
189
+ * Translate one ical.js Event (master + an optional recurrence-instance
190
+ * detail) into the flat shape we expose. `details` is what
191
+ * `event.iterator()` hands back when expanding recurrences; for a
192
+ * non-recurring event it's just `{ startDate, endDate }` synthesised
193
+ * from the master.
194
+ */
195
+ function toCalendarEvent(event, details, { calendar } = {}) {
196
+ const startTime = details ? details.startDate : event.startDate;
197
+ const endTime = details ? details.endDate : event.endDate;
198
+ const { url, locationIsUrl } = pickMeetingUrl(event);
199
+ return {
200
+ uid: event.uid || '',
201
+ summary: event.summary || '',
202
+ description: event.description || undefined,
203
+ // Drop the location text when it WAS the meeting URL — otherwise
204
+ // the same link renders twice (once as the summary's href, once
205
+ // as plain text below).
206
+ location: locationIsUrl ? undefined : (event.location || undefined),
207
+ url,
208
+ start: timeToDate(startTime),
209
+ end: endTime ? timeToDate(endTime) : timeToDate(startTime),
210
+ allDay: !!startTime.isDate,
211
+ // True for every occurrence produced via recurrence expansion —
212
+ // including RECURRENCE-ID overrides, which are themselves single
213
+ // events (so `event.isRecurring()` is false on them) but only
214
+ // exist as part of a recurring series.
215
+ isRecurringInstance: !!details,
216
+ calendar,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Parse an ICS text blob and expand recurrences inside the given
222
+ * window. The window is `[start, start + windowDays)`, half-open at
223
+ * the end so an event whose start equals `start + windowDays` is
224
+ * excluded.
225
+ *
226
+ * @param {string} text
227
+ * @param {{start?: Date, windowDays?: number, maxEvents?: number, calendar?: string}} [opts]
228
+ * @returns {CalendarEvent[]} sorted ascending by start
229
+ */
230
+ export function parseICS(text, opts = {}) {
231
+ const start = opts.start instanceof Date ? opts.start : new Date();
232
+ const windowDays = Number.isFinite(opts.windowDays) ? opts.windowDays : 30;
233
+ const maxEvents = Number.isFinite(opts.maxEvents) ? opts.maxEvents : 1000;
234
+ const calendar = opts.calendar;
235
+
236
+ const winStart = start;
237
+ const winEnd = new Date(start.getTime() + windowDays * 86_400_000);
238
+
239
+ const jcal = ICAL.parse(text);
240
+ const root = new ICAL.Component(jcal);
241
+
242
+ // Embedded VTIMEZONE blocks need to be registered so any DTSTART
243
+ // with a matching TZID resolves correctly.
244
+ for (const vt of root.getAllSubcomponents('vtimezone')) {
245
+ const tz = new ICAL.Timezone(vt);
246
+ if (!ICAL.TimezoneService.has(tz.tzid)) ICAL.TimezoneService.register(tz.tzid, tz);
247
+ }
248
+
249
+ const events = [];
250
+ const vevents = root.getAllSubcomponents('vevent');
251
+
252
+ // Two passes for recurring events: first collect any RECURRENCE-ID
253
+ // override instances (they shadow specific occurrences of the master),
254
+ // then iterate the master and substitute overrides as they come up.
255
+ const overridesByUid = new Map(); // uid → Map<recurrence-id ISO, event>
256
+ const masters = [];
257
+ for (const ve of vevents) {
258
+ const ev = new ICAL.Event(ve);
259
+ if (ev.isRecurrenceException()) {
260
+ const key = ev.uid;
261
+ let bucket = overridesByUid.get(key);
262
+ if (!bucket) overridesByUid.set(key, bucket = new Map());
263
+ bucket.set(ev.recurrenceId.toString(), ev);
264
+ } else {
265
+ masters.push(ev);
266
+ }
267
+ }
268
+
269
+ // Cap on iterations per master — guards against pathological RRULEs
270
+ // (e.g. `FREQ=SECONDLY` someone might paste in) producing millions of
271
+ // instances before the window check trims them.
272
+ const ITER_CAP = 5000;
273
+
274
+ for (const ev of masters) {
275
+ if (events.length >= maxEvents) break;
276
+
277
+ if (ev.isRecurring()) {
278
+ const overrides = overridesByUid.get(ev.uid);
279
+ const iter = ev.iterator();
280
+ let next;
281
+ let n = 0;
282
+ while ((next = iter.next()) && n++ < ITER_CAP) {
283
+ const occStartDate = next.toJSDate();
284
+ if (occStartDate >= winEnd) break;
285
+ // Find the duration on either the master or the per-occurrence
286
+ // override; getOccurrenceDetails on the master returns the right
287
+ // end-date and substitutes the override summary/location etc.
288
+ const details = ev.getOccurrenceDetails(next);
289
+ if (details.endDate.toJSDate() < winStart) continue;
290
+ const useEv = overrides && overrides.get(next.toString())
291
+ ? overrides.get(next.toString())
292
+ : ev;
293
+ events.push(toCalendarEvent(useEv, details, { calendar }));
294
+ if (events.length >= maxEvents) break;
295
+ }
296
+ } else {
297
+ const evStart = timeToDate(ev.startDate);
298
+ const evEnd = ev.endDate ? timeToDate(ev.endDate) : evStart;
299
+ if (evEnd < winStart || evStart >= winEnd) continue;
300
+ events.push(toCalendarEvent(ev, null, { calendar }));
301
+ }
302
+ }
303
+
304
+ events.sort((a, b) => a.start - b.start);
305
+ return events;
306
+ }
307
+
308
+ /**
309
+ * Top-level helper: resolve the URL (provider URL builder or pass-
310
+ * through), fetch the ICS text (with proxy fallback), and parse it
311
+ * into the windowed `CalendarEvent[]`.
312
+ *
313
+ * @param {string} url
314
+ * @param {{provider?: string, calendarId?: string, proxy?: string,
315
+ * start?: Date, windowDays?: number, maxEvents?: number,
316
+ * calendar?: string, signal?: AbortSignal}} [opts]
317
+ */
318
+ export async function getCalendarEvents(url, opts = {}) {
319
+ const built = buildProviderUrl(opts.provider, opts.calendarId);
320
+ const target = built || url;
321
+ if (!target) throw new Error('No calendar URL — supply source= or calendar-id=');
322
+ const text = await fetchICSText(target, { proxy: opts.proxy, signal: opts.signal });
323
+ return parseICS(text, opts);
324
+ }
325
+
326
+ /**
327
+ * Multi-source helper: fetch every URL in parallel and merge the
328
+ * resulting events into one sorted-by-start list. A flaky single feed
329
+ * doesn't blank the whole result — its error is reported in `errors`
330
+ * and the surviving feeds still render.
331
+ *
332
+ * Each merged event carries a `calendar` label derived from the URL
333
+ * (host + first path segment), so the render layer can show the
334
+ * origin if it wants to.
335
+ *
336
+ * @param {string[]} urls
337
+ * @param {{provider?: string, proxy?: string, start?: Date,
338
+ * windowDays?: number, maxEvents?: number,
339
+ * signal?: AbortSignal}} [opts]
340
+ * @returns {Promise<{events: CalendarEvent[], errors: {url: string, message: string}[]}>}
341
+ */
342
+ export async function getMergedCalendarEvents(urls, opts = {}) {
343
+ if (!urls || !urls.length) {
344
+ throw new Error('No calendar URLs — supply at least one source');
345
+ }
346
+
347
+ const settled = await Promise.allSettled(
348
+ urls.map(async (u) => {
349
+ const text = await fetchICSText(u, { proxy: opts.proxy, signal: opts.signal });
350
+ return parseICS(text, { ...opts, calendar: shortLabelFromUrl(u) });
351
+ }),
352
+ );
353
+
354
+ const events = [];
355
+ const errors = [];
356
+ settled.forEach((r, i) => {
357
+ if (r.status === 'fulfilled') events.push(...r.value);
358
+ else errors.push({ url: urls[i], message: r.reason?.message || String(r.reason) });
359
+ });
360
+
361
+ events.sort((a, b) => a.start - b.start);
362
+ // The per-feed maxEvents already clipped each one; cap the merged
363
+ // total separately so a couple of big calendars don't blow the cap.
364
+ const cap = Number.isFinite(opts.maxEvents) ? opts.maxEvents : 1000;
365
+ if (events.length > cap) events.length = cap;
366
+ return { events, errors };
367
+ }
368
+
369
+ /** Short, human-ish label for an ICS URL — host + first path segment
370
+ * trimmed, used as the per-event `calendar` field when merging.
371
+ * "https://www.w3.org/groups/cg/solid/calendar/export/" → "w3.org/solid"
372
+ * "https://calendar.google.com/.../basic.ics" → "google" */
373
+ function shortLabelFromUrl(url) {
374
+ try {
375
+ const u = new URL(url);
376
+ const host = u.hostname.replace(/^www\./, '');
377
+ if (host === 'calendar.google.com' || host.endsWith('.google.com')) return 'google';
378
+ // Pick a useful path token: prefer something that isn't generic
379
+ // ("groups", "calendar", "export", etc.). Falls back to the host.
380
+ const skip = new Set(['groups', 'calendar', 'export', 'public', 'ical', 'cg', 'wg']);
381
+ const tok = u.pathname.split('/').find(s => s && !skip.has(s.toLowerCase()));
382
+ return tok ? `${host.split('.').slice(-2, -1)[0] || host}/${tok}` : host;
383
+ } catch {
384
+ return url;
385
+ }
386
+ }
387
+
388
+ export default getCalendarEvents;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Minimal CodeMirror 6 editor builder.
3
+ * Used by sol-live-edit when running standalone (not inside the podz bundle).
4
+ * podz itself uses podz-editor.js which has keybinding options and language support.
5
+ */
6
+
7
+ const ESM = 'https://esm.sh';
8
+
9
+ const LANG_MAP = {
10
+ ttl: [`${ESM}/codemirror-lang-turtle`, m => m.turtle],
11
+ n3: [`${ESM}/codemirror-lang-turtle`, m => m.turtle],
12
+ jsonld: [`${ESM}/@codemirror/lang-json@6`, m => m.json],
13
+ json: [`${ESM}/@codemirror/lang-json@6`, m => m.json],
14
+ md: [`${ESM}/@codemirror/lang-markdown@6`, m => m.markdown],
15
+ html: [`${ESM}/@codemirror/lang-html@6`, m => m.html],
16
+ htm: [`${ESM}/@codemirror/lang-html@6`, m => m.html],
17
+ js: [`${ESM}/@codemirror/lang-javascript@6`, m => m.javascript],
18
+ mjs: [`${ESM}/@codemirror/lang-javascript@6`, m => m.javascript],
19
+ css: [`${ESM}/@codemirror/lang-css@6`, m => m.css],
20
+ xml: [`${ESM}/@codemirror/lang-xml@6`, m => m.xml],
21
+ svg: [`${ESM}/@codemirror/lang-xml@6`, m => m.xml],
22
+ };
23
+
24
+ /**
25
+ * Create a minimal CodeMirror 6 EditorView in `parent`.
26
+ * @param {Element} parent — container DOM element
27
+ * @param {string} ext — file extension key (e.g. 'ttl', 'md', 'html')
28
+ * @param {ShadowRoot|null} root — shadow root for CSS injection (or null for light DOM)
29
+ * @param {Function} onChange — called when content changes
30
+ * @returns {Promise<EditorView>}
31
+ */
32
+ export async function buildEditor(parent, ext, root, onChange) {
33
+ const { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection }
34
+ = await import(`${ESM}/@codemirror/view@6`);
35
+ const { EditorState }
36
+ = await import(`${ESM}/@codemirror/state@6`);
37
+ const { defaultKeymap, history, historyKeymap, indentWithTab }
38
+ = await import(`${ESM}/@codemirror/commands@6`);
39
+ const { syntaxHighlighting, defaultHighlightStyle }
40
+ = await import(`${ESM}/@codemirror/language@6`);
41
+
42
+ let langExt = [];
43
+ const langEntry = ext && LANG_MAP[ext];
44
+ if (langEntry) {
45
+ try {
46
+ const [url, pick] = langEntry;
47
+ const m = await import(url);
48
+ const fn = pick(m);
49
+ if (fn) langExt = [fn()];
50
+ } catch (_) { /* CDN miss — degrade silently */ }
51
+ }
52
+
53
+ const updateListener = EditorView.updateListener.of(u => {
54
+ if (u.docChanged && onChange) onChange();
55
+ });
56
+
57
+ const view = new EditorView({
58
+ state: EditorState.create({
59
+ doc: '',
60
+ extensions: [
61
+ history(),
62
+ lineNumbers(),
63
+ highlightActiveLine(),
64
+ drawSelection(),
65
+ syntaxHighlighting(defaultHighlightStyle),
66
+ keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
67
+ ...langExt,
68
+ ...(ext === 'md' ? [EditorView.lineWrapping] : []),
69
+ updateListener,
70
+ EditorView.theme({
71
+ '&': { height: '100%' },
72
+ '.cm-editor': { height: '100%' },
73
+ '.cm-scroller': { overflow: 'auto', fontFamily: 'inherit' },
74
+ }),
75
+ ],
76
+ }),
77
+ parent,
78
+ root: root || undefined,
79
+ });
80
+
81
+ return view;
82
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * commons-fetch.js — Wikimedia Commons category → image list, for <sol-gallery>.
3
+ *
4
+ * Each gallery "collection" is a Commons *category* URL (e.g.
5
+ * https://commons.wikimedia.org/wiki/Category:Tarot_1JJ). The Commons
6
+ * MediaWiki API serves CORS-enabled JSON when called with `origin=*`, so —
7
+ * unlike RSS — no proxy is needed. One `generator=categorymembers` +
8
+ * `prop=imageinfo` call returns thumbnail URLs, full-size URLs, and license
9
+ * metadata together; an opaque `continue` token pages the rest.
10
+ *
11
+ * Returns plain data only (strings / numbers) — extmetadata HTML is reduced
12
+ * to text — so callers carry no sanitization burden.
13
+ */
14
+
15
+ const COMMONS_API = 'https://commons.wikimedia.org/w/api.php';
16
+
17
+ /** Collapse an HTML fragment (extmetadata values are HTML) to plain text. */
18
+ function toText(html) {
19
+ if (!html) return '';
20
+ const doc = new DOMParser().parseFromString(String(html), 'text/html');
21
+ return (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
22
+ }
23
+
24
+ /**
25
+ * Extract the `Category:Title` page title from a Commons category URL.
26
+ * Accepts the `/wiki/Category:Foo` and `?title=Category:Foo` forms and
27
+ * returns the decoded title with spaces (the API accepts either spaces or
28
+ * underscores). Returns '' when the URL isn't a category.
29
+ */
30
+ export function categoryTitleFromUrl(url) {
31
+ if (!url) return '';
32
+ let title = '';
33
+ try {
34
+ const u = new URL(url);
35
+ const m = u.pathname.match(/\/wiki\/(.+)$/);
36
+ title = m ? m[1] : (u.searchParams.get('title') || '');
37
+ } catch {
38
+ const m = String(url).match(/Category:[^?#]+/);
39
+ title = m ? m[0] : '';
40
+ }
41
+ try { title = decodeURIComponent(title); } catch { /* leave as-is */ }
42
+ title = title.replace(/_/g, ' ').trim();
43
+ return /^Category:/i.test(title) ? title : '';
44
+ }
45
+
46
+ /**
47
+ * Fetch one page of file members of a Commons category, with thumbnails.
48
+ *
49
+ * @param {string} categoryUrl a Commons `…/wiki/Category:X` URL
50
+ * @param {object} [opts]
51
+ * @param {number} [opts.thumbWidth=300] thumbnail width in px
52
+ * @param {number} [opts.limit=60] members per page (API max 500)
53
+ * @param {string} [opts.cont] continue token from a previous call
54
+ * @param {AbortSignal} [opts.signal]
55
+ * @returns {Promise<{images:Array<{title,name,thumb,full,width,height,descUrl,artist,license}>, cont:?string}>}
56
+ */
57
+ export async function getCategoryImages(categoryUrl, opts = {}) {
58
+ const { thumbWidth = 300, limit = 60, cont, signal } = opts;
59
+ const title = categoryTitleFromUrl(categoryUrl);
60
+ if (!title) throw new Error('Not a Commons category URL');
61
+
62
+ const params = new URLSearchParams({
63
+ action: 'query',
64
+ format: 'json',
65
+ origin: '*',
66
+ generator: 'categorymembers',
67
+ gcmtitle: title,
68
+ gcmtype: 'file',
69
+ gcmlimit: String(limit),
70
+ prop: 'imageinfo',
71
+ iiprop: 'url|size|extmetadata',
72
+ iiurlwidth: String(thumbWidth),
73
+ iiextmetadatafilter: 'Artist|LicenseShortName',
74
+ });
75
+ if (cont) params.set('gcmcontinue', cont);
76
+
77
+ const resp = await fetch(`${COMMONS_API}?${params}`, { signal });
78
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} from Commons`);
79
+ const data = await resp.json();
80
+ if (data.error) throw new Error(data.error.info || 'Commons API error');
81
+
82
+ const pages = data.query && data.query.pages ? Object.values(data.query.pages) : [];
83
+ // categorymembers preserves sort order via the `index` the generator adds.
84
+ pages.sort((a, b) => (a.index || 0) - (b.index || 0));
85
+
86
+ const images = [];
87
+ for (const p of pages) {
88
+ const ii = p.imageinfo && p.imageinfo[0];
89
+ if (!ii || !ii.thumburl) continue;
90
+ const meta = ii.extmetadata || {};
91
+ images.push({
92
+ title: (p.title || '').replace(/^File:/, ''),
93
+ name: p.title || '',
94
+ thumb: ii.thumburl,
95
+ full: ii.url,
96
+ width: ii.thumbwidth || 0,
97
+ height: ii.thumbheight || 0,
98
+ descUrl: ii.descriptionurl || '',
99
+ artist: toText(meta.Artist && meta.Artist.value),
100
+ license: toText(meta.LicenseShortName && meta.LicenseShortName.value),
101
+ });
102
+ }
103
+
104
+ const nextCont = data.continue && data.continue.gcmcontinue
105
+ ? data.continue.gcmcontinue
106
+ : null;
107
+ return { images, cont: nextCont };
108
+ }