@rathnasgala/theme 0.0.16 → 0.0.18

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala/theme",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "Managed Gala static-site theme payload",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "themePackage": {
4
4
  "name": "@rathnasgala/theme",
5
- "version": "0.0.16",
5
+ "version": "0.0.18",
6
6
  "availableDesignThemes": [
7
7
  "editorial"
8
8
  ],
@@ -14,14 +14,14 @@
14
14
  "eleventy.config.js": "efe587447e3dc374a220c7fa037fdf2fffc02d09ba43a4d2b96d6c6aae0e3325",
15
15
  "lib/build-manifest.js": "88949f0112d683ca809bb67ca969e15634792423559a67bc79a534cd807e5535",
16
16
  "lib/engagement-snapshot.js": "298c11b1ab135a3a98a4e31b6320ab60dfd7fda6a9d9053df37d86282a5a6687",
17
- "lib/performance-budget.js": "124c43494c092a956accec46d1463ea063ed73363d0db01f24aaea987459d683",
17
+ "lib/performance-budget.js": "c279622dfde237cd6e3c64775c91d770a0e1f54f5f94257ccc95d9a44de2c4c9",
18
18
  "lib/provider-fixtures/embeds.v1.json": "5f26909fb433135321ddf7fdd14e45aeb702805c88f5676941166c5735143ee2",
19
19
  "lib/provider-fixtures/share-intents.v1.json": "66a8eedfcff711253ee45ceb94b53f7d5a6be3ddbbb3d1f0d4e31a114326cc0a",
20
20
  "lib/publication-state.js": "1b59c29a18bbb1193bddea898dac3a4fb832c7bc4751263be086f3d64392dea0",
21
21
  "lib/render-markdown.js": "40e6d6743b76de6c16d138475f188a4263967530c370d8b26a363f4df58c2caf",
22
22
  "lib/seo.js": "107de8804931945b8e73894877fc394d6b535a9d0d969a4ba5b68f2e136e0a17",
23
23
  "lib/share-targets.js": "458aff86fab2b62a364442cd8c612565c6e0b53eef2f20cb6237cc562b1e0731",
24
- "lib/site-config.js": "ab7cc170dc30c74ea5ef08b8ff6a82f9820998ae4190a06d64375dfd7d515864",
24
+ "lib/site-config.js": "da7d2dffab62c7346c9930494b85fc6ea735d4f50565bbd0454a04393f8cad96",
25
25
  "package-lock.json": "c97a6a2f794aef083a39a8d2628eccc4a3c484e1c6225899b77ec1fb7c466d9e",
26
26
  "package.json": "4c60bea06f39aec755ac68caf8d8841c1d59d0f6121fad7f4396231ce14712dc",
27
27
  "src/404.njk": "be0d44370ac5402156aaf3d32ea581057bc61ec94c1a5f92bb809733af7a8a20",
@@ -30,18 +30,25 @@
30
30
  "src/_data/feedLinks.js": "06c93352ebd888acb431a1c9a8be6ecff757895468e9f2d336761038751c2d5e",
31
31
  "src/_data/languages.js": "1358748f7fa2f74406d3295a030827d6229a53e94da72cf712e719d7a356aeac",
32
32
  "src/_data/site.js": "d9c001f4cc12a34027dd92303530b11b5402d47613459bb997e22c4bcbea326d",
33
- "src/_includes/components/ui.njk": "b95eca21eb78182989c114f8d03342769553470672e7402e1330e9070c293ff5",
34
- "src/_includes/layouts/base.njk": "d132b0a920e3dd9b60543394da4498b4d25b3821bdaaea0b3a5da55e9200b663",
33
+ "src/_includes/components/ui.njk": "23eb647d8658868449ec6bb809e965a85302124a97639f94d5d28af72df56e4c",
34
+ "src/_includes/layouts/base.njk": "6914da799eb31fea3fc230ab196252129ccca90f27ccb08bbcee81741a91ced4",
35
35
  "src/_includes/layouts/post.njk": "1accd2832064ad19ee7bf18daf5fa1e60569f32c9b7a547934a6361f747d7eb7",
36
36
  "src/assets/embed-codepen.svg": "e9def697f38c466fb2b6652535c4c814e8cbbbefc6e052a69f335aeb27e8356b",
37
37
  "src/assets/embed-gist.svg": "fcd9bc2bf29a5c118492b853c0a01390b7539c38ddb985da3c2a6e2f246a39f7",
38
38
  "src/assets/embed-x.svg": "0ae74edc6790ec7e933067fe5392bce756e93fa7ad71caacf1c1ca99fa4ec9b6",
39
39
  "src/assets/embed-youtube.svg": "10d192f2b14b67664fea88c8cf246321d20190def51d59fad5762e552219b067",
40
- "src/assets/interactions.js": "caaf22c9e64bc1be8b92d9facbaa52f2c1c9de2685c25fe470557fd7fe2a52e2",
40
+ "src/assets/engagement-comments.js": "b20d9268c9d908b4453a42188eaca25349f25534fb04a9293c9e688a982cd8f8",
41
+ "src/assets/engagement-transport.js": "4f9ca0cd669bf8abc25f2909104e3e4b231657ef97d36a17d06e53454d711786",
42
+ "src/assets/interactions.js": "dd4e343505e528d89596ca67db679b52c989e022f93399193e4377db01fc8a08",
41
43
  "src/assets/preferences.js": "789127bee013ed6a1bbf8cefd7b9109291c16220e7a6e09314e3c93c54731d1d",
42
44
  "src/assets/search.js": "df425f0239277792ad39f2138bc89cb66cc4ecda5ed59042d46abc2f05372088",
43
- "src/assets/theme-mode.js": "3797c84dbaa090b5b21427a31928b5dd97f13d04d99833f5e1e74c6b50e01dcc",
44
- "src/assets/theme.css": "2c835fb79531d7b4a71bc2d41a6deb6e6b3689bf8603d903a3ba93add66c0d60",
45
+ "src/assets/theme-mode.js": "944b29db11a577961a60ddcfa52f07ef2411f3f3631bea85bd79be16428c08be",
46
+ "src/assets/theme.css": "2a2367f2c875121cc95bc789a0d5889960813e660ca7722b328b39b4ff5068a8",
47
+ "src/assets/vendor/hooks.js": "ae6ec58592683ca64fa4d65b20d40b103df62a673bcc4712515cf165950b07f3",
48
+ "src/assets/vendor/htm.js": "e4dac9bc40a6f2ec590413d787ae30bbcfdfcde4f28971ee65d9578271b7aad4",
49
+ "src/assets/vendor/preact.js": "712517dd2a42c3c22b5ab0264ad7e9a2b1e8be6cf97fa00dbedd98203617e616",
50
+ "src/assets/vendor/signals-core.js": "1e10560fec5118c9e35eb312c869fe51f4870424766ef2ec0f7cd069bd898c8b",
51
+ "src/assets/vendor/signals.js": "717a88151bacf4f8b79b528a96d71be20a133fb3345230c1146929903cecfd19",
45
52
  "src/cname.11ty.js": "206fc224df91c569e18312158e7f69803061f2952513f501ada54bd06c4f8006",
46
53
  "src/contact.njk": "f4d74d038781e15af0acef571901c91e7753a2e21b6bef617429585698839a34",
47
54
  "src/feed.11ty.js": "009bd2384e08e02a9405683bab1653bab3aeaec16accb0fd01ba69b24c777da9",
@@ -24,7 +24,16 @@ function assertWithin(actual, maximum, label) {
24
24
  export async function enforcePerformanceBudgets({ outputDirectory, budgets }) {
25
25
  const output = path.resolve(outputDirectory);
26
26
  const files = await filesBelow(output);
27
- const managedAssets = files.filter((file) => path.dirname(file) === path.join(output, 'assets'));
27
+ /*
28
+ * Everything under `assets`, nested included. This used to compare `path.dirname` against the
29
+ * assets directory exactly, so anything in a subdirectory was invisible to the budget — the
30
+ * moment the reader runtime moved into `assets/vendor` it would have stopped being counted, and
31
+ * a budget that quietly ignores two thirds of the JavaScript it is meant to guard is worse than
32
+ * having none.
33
+ */
34
+ const assets = path.join(output, 'assets');
35
+ const managedAssets = files.filter(
36
+ (file) => file === assets || file.startsWith(assets + path.sep));
28
37
  const javascriptBytes = await totalBytes(managedAssets.filter((file) => file.endsWith('.js')));
29
38
  const cssBytes = await totalBytes(managedAssets.filter((file) => file.endsWith('.css')));
30
39
  assertWithin(javascriptBytes, budgets.managedJavaScriptBytes, 'Managed JavaScript');
@@ -7,19 +7,39 @@ const BUDGET_KEYS = Object.freeze([
7
7
  'managedCssBytes',
8
8
  'ordinaryHtmlBytes'
9
9
  ]);
10
+ /*
11
+ * Only what the theme actually implements. A value here is a promise that choosing it changes how
12
+ * a publication looks; anything the CSS does not answer to has no business being offered.
13
+ */
10
14
  const DESIGN_VALUES = Object.freeze({
11
15
  layout: Object.freeze(['article-first', 'portfolio']),
12
- palette: Object.freeze(['default', 'ocean'])
16
+ palette: Object.freeze(['default', 'ocean']),
17
+ typography: Object.freeze(['system', 'editorial', 'humanist', 'mono']),
18
+ density: Object.freeze(['compact', 'comfortable', 'spacious']),
19
+ spacing: Object.freeze(['compact', 'comfortable', 'spacious']),
20
+ radius: Object.freeze(['sharp', 'soft', 'round']),
21
+ motion: Object.freeze(['none', 'subtle', 'expressive']),
22
+ componentStyle: Object.freeze(['quiet', 'outlined', 'raised'])
13
23
  });
14
24
  const CONTACT_KEYS = Object.freeze([
15
25
  'enabled', 'websiteEnabled', 'phoneEnabled'
16
26
  ]);
17
27
 
28
+ /*
29
+ * `layout` and `palette` have always been required. The rest were added later and are optional:
30
+ * a publication written before they existed is still a valid publication, and the theme falls
31
+ * back to its own defaults for anything absent. Present-but-unknown is still an error, because
32
+ * that is a writer expecting something the CSS will not do.
33
+ */
34
+ const REQUIRED_DESIGN = Object.freeze(['layout', 'palette']);
35
+
18
36
  function validateImplementedDesign(config) {
19
37
  if (config?.design == null) return;
20
38
  for (const [field, allowed] of Object.entries(DESIGN_VALUES)) {
21
- if (!allowed.includes(config.design[field])) {
22
- throw new TypeError(`Unsupported design.${field}: ${config.design[field]}`);
39
+ const value = config.design[field];
40
+ if (value === undefined && !REQUIRED_DESIGN.includes(field)) continue;
41
+ if (!allowed.includes(value)) {
42
+ throw new TypeError(`Unsupported design.${field}: ${value}`);
23
43
  }
24
44
  }
25
45
  }
@@ -33,13 +33,12 @@
33
33
  ] %}<button type="button" data-reaction="{{ reaction.value }}" aria-pressed="false">{{ reaction.label }}</button>{% endfor %}
34
34
  </div>
35
35
  <button type="button" data-follow-article aria-pressed="false">Follow article</button>
36
- <form data-comment-create>
37
- <label>Comment<textarea name="body" maxlength="5000" required></textarea></label>
38
- <p data-comment-reply-context hidden></p>
39
- <button type="submit">Post comment</button>
40
- <button type="button" data-cancel-reply hidden>Cancel reply</button>
41
- </form>
42
36
  </section>
37
+ {# The conversation is an island: it owns its own state, because a thread five deep with a page
38
+ cursor, an open reply form and an edit in progress is state, and hand-written DOM kept losing
39
+ track of it. Nothing here is needed by a search engine — comments have always arrived from the
40
+ API after load — so rendering them on the client costs nothing the article cares about. #}
41
+ <div data-gala-comments></div>
43
42
  <output data-engagement-status aria-live="polite"></output>
44
43
  </section>
45
44
  {% endmacro %}
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
- <html lang="{{ post.language or site.site.defaultLanguage }}" data-theme="{{ site.design.theme }}" data-layout="{{ site.design.layout }}" data-palette="{{ site.design.palette }}" data-mode="system">
2
+ <html lang="{{ post.language or site.site.defaultLanguage }}" data-theme="{{ site.design.theme }}" data-layout="{{ site.design.layout }}" data-palette="{{ site.design.palette }}" data-typography="{{ site.design.typography }}" data-density="{{ site.design.density }}" data-spacing="{{ site.design.spacing }}" data-radius="{{ site.design.radius }}" data-motion="{{ site.design.motion }}" data-component-style="{{ site.design.componentStyle }}" data-mode="{{ site.design.colorMode | default('system') }}">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -27,29 +27,30 @@
27
27
  <script src="{{ '/assets/theme-mode.js' | url }}" defer></script>
28
28
  <script src="{{ '/assets/preferences.js' | url }}" defer></script>
29
29
  <script src="{{ '/assets/interactions.js' | url }}" defer></script>
30
+ <script type="module" src="{{ '/assets/engagement-comments.js' | url }}"></script>
30
31
  <script src="{{ '/assets/search.js' | url }}" defer></script>
31
32
  </head>
32
33
  <body>
33
34
  <header class="gala-site-header">
34
35
  <a class="gala-icon-control" href="{{ '/' | url }}" aria-label="Home" title="Home">
35
- <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 11.5 12 4l9 7.5v8a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg>
36
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10.2 12 3.5l9 6.7"/><path d="M5.4 8.9V19a1.4 1.4 0 0 0 1.4 1.4h3.1v-5.1h4.2v5.1h3.1A1.4 1.4 0 0 0 18.6 19V8.9"/></svg>
36
37
  </a>
37
38
  <nav class="gala-site-header__actions" aria-label="Site controls">
38
39
  {% if site.contact.enabled %}<a class="gala-icon-control" href="{{ '/contact/' | url }}" aria-label="Contact author" title="Contact">
39
- <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 5h18v14H3V5Zm2 2v.4l7 5 7-5V7H5Zm14 10V9.8l-7 5-7-5V17h14Z"/></svg>
40
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5.4" width="18" height="13.2" rx="2.2"/><path d="m3.9 7.3 7.2 5.1a1.6 1.6 0 0 0 1.8 0l7.2-5.1"/></svg>
40
41
  </a>{% endif %}
41
42
  <button class="gala-icon-control" type="button" data-theme-mode-toggle aria-label="Color mode: system. Activate for light." title="Appearance">
42
- <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 0 18V3Zm0 2v14a7 7 0 0 1 0-14Z"/></svg>
43
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20.1 13.6A8.2 8.2 0 1 1 10.4 3.9a6.4 6.4 0 0 0 9.7 9.7Z"/></svg>
43
44
  <span class="gala-visually-hidden" data-theme-mode-label>Theme: system</span>
44
45
  </button>
45
46
  <button class="gala-icon-control" type="button" data-open-dialog="gala-settings-dialog" aria-label="Settings" title="Settings">
46
- <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M10.6 2h2.8l.7 2.2 1.5.9 2.3-.5 1.4 2.4-1.6 1.7v1.7l1.6 1.7-1.4 2.4-2.3-.5-1.5.9-.7 2.2h-2.8l-.7-2.2-1.5-.9-2.3.5-1.4-2.4 1.6-1.7V8.7L4.7 7l1.4-2.4 2.3.5 1.5-.9.7-2.2Zm1.4 6.5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>
47
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2.9"/><path d="M19.3 14.6a1.5 1.5 0 0 0 .3 1.7l.1.1a1.8 1.8 0 1 1-2.6 2.6l-.1-.1a1.5 1.5 0 0 0-2.5 1.1v.3a1.8 1.8 0 0 1-3.6 0v-.2a1.5 1.5 0 0 0-2.6-1.1l-.1.1a1.8 1.8 0 1 1-2.6-2.6l.1-.1a1.5 1.5 0 0 0-1.1-2.5h-.3a1.8 1.8 0 0 1 0-3.6h.2a1.5 1.5 0 0 0 1.1-2.6l-.1-.1a1.8 1.8 0 1 1 2.6-2.6l.1.1a1.5 1.5 0 0 0 1.7.3h.1a1.5 1.5 0 0 0 .9-1.4v-.3a1.8 1.8 0 0 1 3.6 0v.2a1.5 1.5 0 0 0 2.5 1.1l.1-.1a1.8 1.8 0 1 1 2.6 2.6l-.1.1a1.5 1.5 0 0 0-.3 1.7v.1a1.5 1.5 0 0 0 1.4.9h.3a1.8 1.8 0 0 1 0 3.6h-.2a1.5 1.5 0 0 0-1.4.9Z"/></svg>
47
48
  </button>
48
49
  <button class="gala-icon-control" type="button" data-open-dialog="gala-search-dialog" aria-label="Search" title="Search">
49
- <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M10.5 3a7.5 7.5 0 1 0 4.7 13.3l4.8 4.8 1.4-1.4-4.8-4.8A7.5 7.5 0 0 0 10.5 3Zm0 2a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Z"/></svg>
50
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20.3 20.3-4.4-4.4"/></svg>
50
51
  </button>
51
52
  <button class="gala-icon-control" type="button" data-open-dialog="gala-account-dialog" data-user-control aria-label="Sign in or view account" title="Account">
52
- <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9Zm0 11c-5 0-8 2.5-8 5.5V21h16v-1.5C20 16.5 17 14 12 14Z"/></svg>
53
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 20.4v-1.7a3.9 3.9 0 0 0-3.9-3.9H8.9A3.9 3.9 0 0 0 5 18.7v1.7"/><circle cx="12" cy="7.6" r="3.9"/></svg>
53
54
  </button>
54
55
  </nav>
55
56
  </header>
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The conversation under an article.
3
+ *
4
+ * Built as an island rather than by hand-writing DOM, because the state here is real: a thread
5
+ * five deep, a page of roots with more behind a cursor, a reply form belonging to one comment, an
6
+ * edit in progress, and a reader who may sign in halfway through. The imperative version rebuilt
7
+ * the whole region from a fresh fetch after every write, which is how a posted comment could take
8
+ * a browser cache's word for it and not appear at all — and a comment that is not rendered has no
9
+ * Reply button, so the thread died with it.
10
+ *
11
+ * The article itself is never touched. Eleventy renders the post and everything search engines
12
+ * read; this mounts only into the comments container, which was always filled from the API and so
13
+ * has nothing to lose by being rendered here.
14
+ */
15
+ import { h, render } from './vendor/preact.js';
16
+ import { useCallback, useEffect, useMemo, useState } from './vendor/hooks.js';
17
+ import htm from './vendor/htm.js';
18
+ // Importing the signals integration is what lets a component re-render when it reads `.value`.
19
+ import './vendor/signals.js';
20
+ import {
21
+ engagementErrorMessage, requestSignIn, sendEngagementWrite, sessionUser,
22
+ } from './engagement-transport.js';
23
+
24
+ const html = htm.bind(h);
25
+
26
+ /** The server's own limit. A reply deeper than this is refused, so it is not offered. */
27
+ const MAXIMUM_DEPTH = 5;
28
+
29
+ const isoTime = (value) => {
30
+ const at = Date.parse(value);
31
+ return Number.isFinite(at) ? new Date(at).toLocaleDateString() : '';
32
+ };
33
+
34
+ /** Roots in order, each followed by its own descendants, from one flat API page. */
35
+ function toThreads(items) {
36
+ const byParent = new Map();
37
+ for (const item of items) {
38
+ const key = item.parentCommentId ?? '';
39
+ if (!byParent.has(key)) byParent.set(key, []);
40
+ byParent.get(key).push(item);
41
+ }
42
+ const build = (parentId, depth) => (byParent.get(parentId) ?? []).map((comment) => ({
43
+ ...comment,
44
+ depth,
45
+ replies: build(comment.commentId, depth + 1),
46
+ }));
47
+ return build('', 0);
48
+ }
49
+
50
+ function Comment({ comment, reader, onReply, onEdit, onDelete, busy }) {
51
+ const [replying, setReplying] = useState(false);
52
+ const [editing, setEditing] = useState(false);
53
+ const [draft, setDraft] = useState('');
54
+ const mine = !comment.deleted && reader && comment.author?.userId === reader.id;
55
+ const canReply = !comment.deleted && comment.depth < MAXIMUM_DEPTH;
56
+
57
+ const submitReply = async (event) => {
58
+ event.preventDefault();
59
+ if (!draft.trim()) return;
60
+ const posted = await onReply(comment.commentId, draft.trim());
61
+ if (posted) { setDraft(''); setReplying(false); }
62
+ };
63
+
64
+ const submitEdit = async (event) => {
65
+ event.preventDefault();
66
+ if (!draft.trim()) return;
67
+ const saved = await onEdit(comment.commentId, draft.trim());
68
+ if (saved) setEditing(false);
69
+ };
70
+
71
+ return html`
72
+ <li class="gala-comment" data-comment-id=${comment.commentId} data-depth=${comment.depth}>
73
+ <p class="gala-comment__meta">
74
+ <strong>${comment.author?.displayName ?? '[deleted]'}</strong>
75
+ ${comment.createdAt && html`<time datetime=${comment.createdAt}>${isoTime(comment.createdAt)}</time>`}
76
+ ${comment.editedAt && html`<span class="gala-comment__edited">edited</span>`}
77
+ ${comment.pending && html`<span class="gala-comment__pending">sending…</span>`}
78
+ </p>
79
+
80
+ ${editing
81
+ ? html`<form class="gala-comment__form" onSubmit=${submitEdit}>
82
+ <label class="gala-visually-hidden" for=${`edit-${comment.commentId}`}>Edit your comment</label>
83
+ <textarea id=${`edit-${comment.commentId}`} rows="3" value=${draft}
84
+ onInput=${(e) => setDraft(e.target.value)}></textarea>
85
+ <div class="gala-comment__controls">
86
+ <button type="submit" disabled=${busy}>Save</button>
87
+ <button type="button" onClick=${() => setEditing(false)}>Cancel</button>
88
+ </div>
89
+ </form>`
90
+ : html`<p class="gala-comment__body">${comment.deleted ? '[deleted]' : comment.body}</p>`}
91
+
92
+ ${!comment.deleted && html`
93
+ <div class="gala-comment-actions">
94
+ ${canReply && html`<button type="button" data-reply-comment=${comment.commentId}
95
+ onClick=${() => { setReplying((open) => !open); setDraft(''); }}>Reply</button>`}
96
+ ${mine && html`<button type="button" data-edit-comment=${comment.commentId}
97
+ onClick=${() => { setEditing(true); setDraft(comment.body ?? ''); }}>Edit</button>`}
98
+ ${mine && html`<button type="button" data-delete-comment=${comment.commentId}
99
+ onClick=${() => onDelete(comment.commentId)} disabled=${busy}>Delete</button>`}
100
+ </div>`}
101
+
102
+ ${replying && html`
103
+ <form class="gala-comment__form" onSubmit=${submitReply}>
104
+ <label class="gala-visually-hidden" for=${`reply-${comment.commentId}`}>
105
+ Reply to ${comment.author?.displayName ?? 'this comment'}
106
+ </label>
107
+ <textarea id=${`reply-${comment.commentId}`} rows="3" value=${draft} autofocus
108
+ placeholder="Write a reply" onInput=${(e) => setDraft(e.target.value)}></textarea>
109
+ <div class="gala-comment__controls">
110
+ <button type="submit" disabled=${busy || !draft.trim()}>Post reply</button>
111
+ <button type="button" onClick=${() => setReplying(false)}>Cancel</button>
112
+ </div>
113
+ </form>`}
114
+
115
+ ${comment.replies.length > 0 && html`
116
+ <ol class="gala-comment-replies">
117
+ ${comment.replies.map((reply) => html`
118
+ <${Comment} key=${reply.commentId} comment=${reply} reader=${reader} busy=${busy}
119
+ onReply=${onReply} onEdit=${onEdit} onDelete=${onDelete} />`)}
120
+ </ol>`}
121
+ </li>`;
122
+ }
123
+
124
+ function Comments({ endpoint }) {
125
+ // Reading `.value` during render subscribes this component to the signal, so a sign-in
126
+ // anywhere on the page re-renders the conversation without anything having to tell it.
127
+ const reader = sessionUser.value;
128
+ const [items, setItems] = useState([]);
129
+ const [cursor, setCursor] = useState(null);
130
+ const [total, setTotal] = useState(0);
131
+ const [status, setStatus] = useState('');
132
+ const [busy, setBusy] = useState(false);
133
+ const [draft, setDraft] = useState('');
134
+ const [loading, setLoading] = useState(true);
135
+
136
+ /* `fresh` skips the browser cache. The endpoint is `max-age=60, public`, which is right for a
137
+ cold visit and wrong straight after a write: the browser would answer from its own cache with
138
+ the state from before the reader wrote anything. */
139
+ const load = useCallback(async (nextCursor = '', { append = false, fresh = false } = {}) => {
140
+ const url = new URL(endpoint);
141
+ if (nextCursor) url.searchParams.set('commentsCursor', nextCursor);
142
+ const response = await fetch(url, {
143
+ headers: { Accept: 'application/json' },
144
+ credentials: 'omit',
145
+ cache: fresh ? 'no-store' : 'default',
146
+ });
147
+ if (!response.ok) throw new Error(`Comments returned HTTP ${response.status}`);
148
+ const payload = await response.json();
149
+ const page = payload?.data?.comments;
150
+ if (!page) throw new TypeError('Comment page is invalid');
151
+ setItems((current) => (append ? [...current, ...page.items] : page.items));
152
+ setCursor(page.nextCursor ?? null);
153
+ if (Number.isSafeInteger(page.totalCount)) setTotal(page.totalCount);
154
+ return page;
155
+ }, [endpoint]);
156
+
157
+ useEffect(() => {
158
+ let live = true;
159
+ load().catch(() => { if (live) setStatus('Comments are temporarily unavailable.'); })
160
+ .finally(() => { if (live) setLoading(false); });
161
+ return () => { live = false; };
162
+ }, [load]);
163
+
164
+ // Signing in changes what the reader may do, not what the article says, so only re-read when
165
+ // the reader actually changes.
166
+ useEffect(() => {
167
+ if (!reader) return undefined;
168
+ load('', { fresh: true }).catch(() => {});
169
+ return undefined;
170
+ }, [reader?.id]);
171
+
172
+ const write = useCallback(async (operation, payload, optimistic) => {
173
+ if (!reader) { requestSignIn({ kind: 'comment' }); return false; }
174
+ setBusy(true);
175
+ setStatus('');
176
+ if (optimistic) setItems((current) => [optimistic, ...current]);
177
+ try {
178
+ await sendEngagementWrite(operation, payload);
179
+ /* Re-read past the cache so what is on screen is what the server actually holds — the
180
+ optimistic row above is a promise to the reader, not a source of truth. */
181
+ await load('', { fresh: true });
182
+ return true;
183
+ } catch (error) {
184
+ if (optimistic) {
185
+ setItems((current) => current.filter((item) => item.commentId !== optimistic.commentId));
186
+ }
187
+ setStatus(engagementErrorMessage(error.message));
188
+ return false;
189
+ } finally {
190
+ setBusy(false);
191
+ }
192
+ }, [reader?.id, load]);
193
+
194
+ const articleId = useMemo(() => {
195
+ const match = /\/v1\/articles\/([^/]+)\/engagement/.exec(endpoint);
196
+ return match ? match[1] : '';
197
+ }, [endpoint]);
198
+
199
+ const post = async (event) => {
200
+ event.preventDefault();
201
+ const body = draft.trim();
202
+ if (!body) return;
203
+ const posted = await write('comment.create', { articleId, body }, {
204
+ commentId: `pending-${crypto.randomUUID()}`,
205
+ parentCommentId: null,
206
+ body,
207
+ depth: 0,
208
+ createdAt: new Date().toISOString(),
209
+ author: { userId: reader?.id, displayName: reader?.displayName },
210
+ pending: true,
211
+ });
212
+ if (posted) setDraft('');
213
+ };
214
+
215
+ const reply = (parentCommentId, body) =>
216
+ write('comment.create', { articleId, parentCommentId, body });
217
+ const edit = (commentId, body) => write('comment.edit', { articleId, commentId, body });
218
+ const remove = (commentId) => write('comment.delete', { articleId, commentId });
219
+
220
+ const threads = useMemo(() => toThreads(items), [items]);
221
+
222
+ return html`
223
+ <section class="gala-comments-island" aria-label="Comments">
224
+ <h2 class="gala-comments__heading">${total === 1 ? '1 comment' : `${total} comments`}</h2>
225
+
226
+ ${reader
227
+ ? html`<form class="gala-comment__form" onSubmit=${post}>
228
+ <label class="gala-visually-hidden" for="gala-new-comment">Add a comment</label>
229
+ <textarea id="gala-new-comment" rows="3" value=${draft} placeholder="Add a comment"
230
+ onInput=${(e) => setDraft(e.target.value)}></textarea>
231
+ <button type="submit" disabled=${busy || !draft.trim()}>Post comment</button>
232
+ </form>`
233
+ : html`<p class="gala-comments__prompt">
234
+ <button type="button" onClick=${() => requestSignIn({ kind: 'comment' })}>
235
+ Sign in to join the conversation
236
+ </button>
237
+ </p>`}
238
+
239
+ ${status && html`<p class="gala-comments__status" role="status">${status}</p>`}
240
+
241
+ ${loading
242
+ ? html`<p class="gala-comments__status" role="status">Loading comments…</p>`
243
+ : threads.length === 0
244
+ ? html`<p class="gala-comments__empty">No comments yet.</p>`
245
+ : html`<ol class="gala-comments">
246
+ ${threads.map((comment) => html`
247
+ <${Comment} key=${comment.commentId} comment=${comment} reader=${reader}
248
+ busy=${busy} onReply=${reply} onEdit=${edit} onDelete=${remove} />`)}
249
+ </ol>`}
250
+
251
+ ${cursor && html`
252
+ <button type="button" class="gala-comments__more" disabled=${busy}
253
+ onClick=${() => { setBusy(true); load(cursor, { append: true })
254
+ .catch(() => setStatus('Could not load more comments.'))
255
+ .finally(() => setBusy(false)); }}>
256
+ Show more comments
257
+ </button>`}
258
+ </section>`;
259
+ }
260
+
261
+ for (const mount of document.querySelectorAll('[data-gala-comments]')) {
262
+ const endpoint = mount.closest('[data-engagement-url]')?.dataset.engagementUrl;
263
+ if (endpoint) render(html`<${Comments} endpoint=${endpoint} />`, mount);
264
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The reader's identity, and the only way to write on their behalf.
3
+ *
4
+ * Both belong to the account frame, which is served by the API and runs on its origin. The
5
+ * reader's token lives there and nowhere else — this page never sees it, and never should: the
6
+ * page is the writer's own site, and a reader's credentials are not the writer's to hold. So a
7
+ * write is a request posted into the frame, and the frame answers with a result.
8
+ *
9
+ * `sessionUser` is a signal rather than a variable because more than one island depends on it and
10
+ * they must not each keep their own copy going stale in its own way.
11
+ */
12
+ import { signal } from './vendor/signals.js';
13
+
14
+ export const sessionUser = signal(null);
15
+
16
+ const sessionFrame = document.querySelector('[data-gala-session-frame]');
17
+ const frameOrigin = sessionFrame ? new URL(sessionFrame.src).origin : null;
18
+ const pending = new Map();
19
+
20
+ /** Readable text for the codes the API actually returns. */
21
+ export function engagementErrorMessage(code) {
22
+ if (code === 'AUTHENTICATION_REQUIRED' || code === 'INVALID_BEARER_TOKEN'
23
+ || code === 'REAUTHENTICATION_REQUIRED') return 'Sign in again to continue.';
24
+ if (code === 'ENGAGEMENT_RATE_LIMITED') return 'You are posting too quickly; try again shortly.';
25
+ if (code === 'CONTACT_RATE_LIMITED') return 'You are sending too quickly; try again later.';
26
+ if (code === 'RESOURCE_NOT_FOUND') return 'That item is no longer available.';
27
+ if (code === 'INVALID_ENGAGEMENT_WRITE' || code === 'INVALID_CONTACT_SUBMISSION'
28
+ || code === 'INVALID_REQUEST') return 'Check your entry and try again.';
29
+ if (code === 'ACCESS_DENIED') return 'That action is not available for this account.';
30
+ if (code === 'IDEMPOTENCY_CONFLICT' || code === 'ENGAGEMENT_STATE_CONFLICT') {
31
+ return 'The item changed; reload and try again.';
32
+ }
33
+ return 'The action could not be completed. Try again.';
34
+ }
35
+
36
+ export function sendEngagementWrite(operation, payload) {
37
+ if (!sessionFrame || !sessionUser.value) {
38
+ return Promise.reject(new Error('AUTHENTICATION_REQUIRED'));
39
+ }
40
+ const requestId = crypto.randomUUID();
41
+ return new Promise((resolve, reject) => {
42
+ pending.set(requestId, { resolve, reject });
43
+ sessionFrame.contentWindow.postMessage(
44
+ { type: 'gala-engagement-write', requestId, operation, payload }, frameOrigin);
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Asks the page to start a sign-in and to put the reader back where they were afterwards.
50
+ *
51
+ * Dispatched as an event rather than called directly: opening the window and replaying the
52
+ * interrupted intent belongs to the page, and an island should not have to know how that works.
53
+ */
54
+ export function requestSignIn(intent) {
55
+ document.dispatchEvent(new CustomEvent('gala-request-sign-in', { detail: intent }));
56
+ }
57
+
58
+ if (sessionFrame) {
59
+ window.addEventListener('message', (event) => {
60
+ if (event.origin !== frameOrigin || event.source !== sessionFrame.contentWindow) return;
61
+ if (event.data?.type === 'gala-engagement-result') {
62
+ const waiting = pending.get(event.data.requestId);
63
+ if (!waiting) return;
64
+ pending.delete(event.data.requestId);
65
+ if (event.data.ok === true) waiting.resolve(event.data.result);
66
+ else waiting.reject(new Error(event.data.error?.code || 'ENGAGEMENT_WRITE_FAILED'));
67
+ return;
68
+ }
69
+ if (event.data?.type !== 'gala-session') return;
70
+ const user = event.data.user;
71
+ sessionUser.value = user && typeof user.id === 'string' ? user : null;
72
+ });
73
+ }