@rathnasgala/theme 0.0.1 → 0.0.6

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.
@@ -6,16 +6,19 @@
6
6
  "scripts": {
7
7
  "build": "eleventy",
8
8
  "test": "node --test",
9
- "lint": "node --check eleventy.config.js && node --check lib/build-manifest.js && node --check lib/engagement-snapshot.js && node --check lib/publication-state.js && node --check lib/render-markdown.js && node --check lib/seo.js && node --check lib/site-config.js && node --check src/_data/buildManifest.js && node --check src/_data/engagementSnapshot.js && node --check src/_data/feedLinks.js && node --check src/_data/languages.js && node --check src/_data/site.js && node --check src/assets/interactions.js && node --check src/assets/preferences.js && node --check src/assets/search.js && node --check src/assets/theme-mode.js && node --check src/feed.11ty.js && node --check src/languages.11ty.js && node --check src/posts.11ty.js && node --check src/redirects.11ty.js && node --check src/search-index.11ty.js && node --check src/sitemap.11ty.js && node --check test/build-manifest.test.js && node --check test/component-contract.test.js && node --check test/config.test.js && node --check test/eleventy-manifest-build.test.js && node --check test/engagement-snapshot.test.js && node --check test/feed.test.js && node --check test/fixtures/xss-payloads.js && node --check test/managed-files.test.js && node --check test/preferences.test.js && node --check test/publication-state.test.js && node --check test/render-markdown.test.js && node --check test/search.test.js && node --check test/seo.test.js && node --check test/supporting-surfaces.test.js && node --check test/theme-contract.test.js && node --check test/theme-contrast.test.js && node --check test/theme-mode.test.js"
9
+ "lint": "node --check eleventy.config.js && node --check lib/build-manifest.js && node --check lib/engagement-snapshot.js && node --check lib/performance-budget.js && node --check lib/publication-state.js && node --check lib/render-markdown.js && node --check lib/seo.js && node --check lib/share-targets.js && node --check lib/site-config.js && node --check scripts/push.js && node --check src/_data/buildManifest.js && node --check src/_data/engagementSnapshot.js && node --check src/_data/feedLinks.js && node --check src/_data/languages.js && node --check src/_data/site.js && node --check src/assets/interactions.js && node --check src/assets/preferences.js && node --check src/assets/search.js && node --check src/assets/theme-mode.js && node --check src/feed.11ty.js && node --check src/languages.11ty.js && node --check src/posts.11ty.js && node --check src/redirects.11ty.js && node --check src/search-index.11ty.js && node --check src/sitemap.11ty.js && node --check test/build-manifest.test.js && node --check test/component-contract.test.js && node --check test/config.test.js && node --check test/eleventy-manifest-build.test.js && node --check test/engagement-client.test.js && node --check test/engagement-snapshot.test.js && node --check test/feed.test.js && node --check test/fixtures/xss-payloads.js && node --check test/managed-files.test.js && node --check test/performance-budget.test.js && node --check test/preferences.test.js && node --check test/publication-state.test.js && node --check test/render-markdown.test.js && node --check test/search.test.js && node --check test/seo.test.js && node --check test/share-targets.test.js && node --check test/supporting-surfaces.test.js && node --check test/theme-contract.test.js && node --check test/theme-contrast.test.js && node --check test/theme-mode.test.js && node --check test/theme-release-workflow.test.js",
10
+ "push": "node scripts/push.js"
10
11
  },
11
12
  "engines": {
12
- "node": ">=18"
13
+ "node": ">=20"
13
14
  },
14
15
  "devDependencies": {
15
16
  "@11ty/eleventy": "3.1.6",
17
+ "github-slugger": "2.0.0",
16
18
  "markdown-it": "15.0.0",
17
19
  "markdown-it-footnote": "4.0.0",
18
20
  "sanitize-html": "2.17.6",
21
+ "shiki": "4.4.3",
19
22
  "yaml": "2.9.0"
20
23
  }
21
24
  }
@@ -13,5 +13,24 @@
13
13
  {% macro tableOfContents(items) %}<nav class="gala-toc" aria-label="Table of contents"><ol>{% for item in items %}<li><a href="#{{ item.id }}">{{ item.text }}</a></li>{% endfor %}</ol></nav>{% endmacro %}
14
14
  {% macro shareControl(canonicalUrl, targets=[]) %}<section class="gala-share" aria-label="Share this article"><button type="button" data-copy-url="{{ canonicalUrl | escape }}" aria-label="Copy canonical URL">Copy link</button><output class="gala-share__status" aria-live="polite"></output><input class="gala-share__fallback" value="{{ canonicalUrl | escape }}" readonly aria-label="Canonical URL">{% if targets.length %}<ul>{% for target in targets %}<li><a href="{{ target.url | escape }}" rel="noopener noreferrer" aria-label="Share on {{ target.label | escape }}">{{ target.label }}</a></li>{% endfor %}</ul>{% endif %}</section>{% endmacro %}
15
15
  {% macro languageSwitcher(links, currentLanguage) %}{% if links.length > 2 %}<label>Preferred language <select data-language-preference data-navigate-on-selection>{% for link in links %}{% if link.hreflang != "x-default" %}<option value="{{ link.hreflang | escape }}" data-url="{{ link.href | escape }}"{% if link.hreflang == currentLanguage %} selected{% endif %}>{{ link.hreflang }}</option>{% endif %}{% endfor %}</select></label>{% endif %}{% endmacro %}
16
- {% macro engagementSummary(articleId, engagement) %}<section class="gala-engagement" aria-label="Engagement" data-article-id="{{ articleId | escape }}">{% if engagement.available %}<dl data-engagement-snapshot><div><dt>Reactions</dt><dd>{{ engagement.reactions }}</dd></div><div><dt>Comments</dt><dd>{{ engagement.comments }}</dd></div><div><dt>Views</dt><dd>{{ engagement.views }}</dd></div></dl>{% else %}<p class="gala-engagement__placeholder" role="status">Engagement data unavailable.</p>{% endif %}<output data-engagement-status aria-live="polite"></output></section>{% endmacro %}
16
+ {% macro engagementSummary(articleId, engagement, apiBaseUrl) %}
17
+ <section class="gala-engagement" aria-label="Engagement" data-article-id="{{ articleId | escape }}" data-engagement-url="{{ apiBaseUrl | escape }}/v1/articles/{{ articleId | escape }}/engagement">
18
+ {% if engagement.available %}<dl data-engagement-snapshot><div><dt>Reactions</dt><dd>{{ engagement.reactions }}</dd></div><div><dt>Comments</dt><dd>{{ engagement.comments }}</dd></div><div><dt>Views</dt><dd>{{ engagement.views }}</dd></div></dl>{% else %}<p class="gala-engagement__placeholder" role="status">Loading engagement data.</p>{% endif %}
19
+ <div data-engagement-live></div>
20
+ <section class="gala-engagement-actions" aria-label="Reader actions" data-engagement-actions hidden>
21
+ <div class="gala-reaction-actions" role="group" aria-label="React to this article">
22
+ {% for reaction in ['like', 'love', 'insightful', 'curious', 'celebrate', 'support'] %}<button type="button" data-reaction="{{ reaction }}" aria-pressed="false">{{ reaction | capitalize }}</button>{% endfor %}
23
+ </div>
24
+ <button type="button" data-follow-article aria-pressed="false">Follow article</button>
25
+ <form data-comment-create>
26
+ <label>Comment<textarea name="body" maxlength="5000" required></textarea></label>
27
+ <p data-comment-reply-context hidden></p>
28
+ <button type="submit">Post comment</button>
29
+ <button type="button" data-cancel-reply hidden>Cancel reply</button>
30
+ </form>
31
+ </section>
32
+ <p data-engagement-sign-in>Sign in from the account menu to comment, react, or follow.</p>
33
+ <output data-engagement-status aria-live="polite"></output>
34
+ </section>
35
+ {% endmacro %}
17
36
  {% macro pageFooter(siteName, links=[]) %}<footer class="gala-page-footer"><p>&copy; {{ siteName }}</p>{% if links.length %}<nav aria-label="Footer"><ul>{% for link in links %}<li><a href="{{ link.url }}">{{ link.label }}</a></li>{% endfor %}</ul></nav>{% endif %}</footer>{% endmacro %}
@@ -27,14 +27,65 @@
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
- {% if searchPage %}<script src="{{ '/assets/search.js' | url }}" defer></script>{% endif %}
30
+ <script src="{{ '/assets/search.js' | url }}" defer></script>
31
31
  </head>
32
32
  <body>
33
- <header>
34
- <button type="button" data-theme-mode-toggle aria-label="Color mode: system. Activate for light.">Theme: system</button>
35
- <a href="{{ '/settings/' | url }}">Settings</a>
36
- <a href="{{ '/search/' | url }}">Search</a>
33
+ <header class="gala-site-header">
34
+ <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
+ </a>
37
+ <nav class="gala-site-header__actions" aria-label="Site controls">
38
+ <button class="gala-icon-control" type="button" data-theme-mode-toggle aria-label="Color mode: system. Activate for light." title="Appearance">
39
+ <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>
40
+ <span class="gala-visually-hidden" data-theme-mode-label>Theme: system</span>
41
+ </button>
42
+ <button class="gala-icon-control" type="button" data-open-dialog="gala-settings-dialog" aria-label="Settings" title="Settings">
43
+ <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>
44
+ </button>
45
+ <button class="gala-icon-control" type="button" data-open-dialog="gala-search-dialog" aria-label="Search" title="Search">
46
+ <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>
47
+ </button>
48
+ <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">
49
+ <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>
50
+ </button>
51
+ </nav>
37
52
  </header>
38
53
  <main id="main-content">{{ content | safe }}</main>
54
+ <dialog id="gala-settings-dialog" class="gala-dialog" aria-labelledby="gala-settings-title">
55
+ <form method="dialog" class="gala-dialog__close"><button type="submit" aria-label="Close settings">Close</button></form>
56
+ <h2 id="gala-settings-title">Reader settings</h2>
57
+ <section aria-labelledby="gala-dialog-theme-setting">
58
+ <h3 id="gala-dialog-theme-setting">Appearance</h3>
59
+ <button type="button" data-theme-mode-toggle aria-label="Color mode: system. Activate for light."><span data-theme-mode-label>Theme: system</span></button>
60
+ </section>
61
+ <section aria-labelledby="gala-dialog-language-setting">
62
+ <h3 id="gala-dialog-language-setting">Language preference</h3>
63
+ <label>Preferred language
64
+ <select data-language-preference>{% for language in languages %}<option value="{{ language | escape }}">{{ language }}</option>{% endfor %}</select>
65
+ </label>
66
+ </section>
67
+ <p><a href="{{ '/settings/' | url }}">Open settings page</a></p>
68
+ </dialog>
69
+ <dialog id="gala-search-dialog" class="gala-dialog" aria-labelledby="gala-search-title">
70
+ <form method="dialog" class="gala-dialog__close"><button type="submit" aria-label="Close search">Close</button></form>
71
+ <h2 id="gala-search-title">Search</h2>
72
+ <section data-gala-search data-index-url="{{ '/search-index.json' | url }}">
73
+ <form class="gala-search" action="{{ '/search/' | url }}" role="search">
74
+ <label for="gala-dialog-search-query">Search published posts</label>
75
+ <input id="gala-dialog-search-query" name="q" type="search" autocomplete="off">
76
+ <button type="submit">Search</button>
77
+ </form>
78
+ <p data-search-status role="status" aria-live="polite">Enter a search term.</p>
79
+ <ol class="gala-search-results" data-search-results></ol>
80
+ </section>
81
+ <p><a href="{{ '/search/' | url }}">Open search page</a></p>
82
+ </dialog>
83
+ {% if site.site.id and site.site.id != "unavailable" %}
84
+ <dialog id="gala-account-dialog" class="gala-dialog gala-account-dialog" aria-labelledby="gala-account-title">
85
+ <form method="dialog" class="gala-dialog__close"><button type="submit" aria-label="Close account">Close</button></form>
86
+ <h2 id="gala-account-title">Gala account</h2>
87
+ <iframe data-gala-session-frame title="Gala account and sign in" src="{{ site.platform.apiBaseUrl or 'https://api.gala67.com' }}/v1/widget/session?siteId={{ site.site.id | escape }}"></iframe>
88
+ </dialog>
89
+ {% endif %}
39
90
  </body>
40
91
  </html>
@@ -1,11 +1,12 @@
1
1
  ---
2
2
  layout: layouts/base.njk
3
3
  ---
4
- {% from "components/ui.njk" import engagementSummary, languageSwitcher, shareControl %}
4
+ {% from "components/ui.njk" import engagementSummary, languageSwitcher, shareControl, tableOfContents %}
5
5
  <article>
6
6
  {% if post.publicationState == "published" %}<h1>{{ post.frontmatter.title }}</h1>{% endif %}
7
7
  {% if post.publicationState == "published" %}{{ languageSwitcher(hreflangLinks, post.language) }}{% endif %}
8
+ {% if postTableOfContents.length %}{{ tableOfContents(postTableOfContents) }}{% endif %}
8
9
  {{ content | safe }}
9
- {% if post.publicationState == "published" %}{{ engagementSummary(post.id, engagement) }}{% endif %}
10
- {% if post.publicationState == "published" %}{{ shareControl(post.canonicalUrl) }}{% endif %}
10
+ {% if post.publicationState == "published" %}{{ engagementSummary(post.id, engagement, site.platform.apiBaseUrl or "https://api.gala67.com") }}{% endif %}
11
+ {% if post.publicationState == "published" %}{{ shareControl(post.canonicalUrl, shareTargets) }}{% endif %}
11
12
  </article>
@@ -8,6 +8,96 @@ function selectableFallback(control) {
8
8
  }
9
9
 
10
10
  document.addEventListener('click', async (event) => {
11
+ const dialogControl = event.target.closest('[data-open-dialog]');
12
+ if (dialogControl) {
13
+ const dialog = document.getElementById(dialogControl.dataset.openDialog);
14
+ if (dialog instanceof HTMLDialogElement) dialog.showModal();
15
+ return;
16
+ }
17
+
18
+ const reaction = event.target.closest('[data-reaction]');
19
+ if (reaction) {
20
+ const region = reaction.closest('[data-engagement-url]');
21
+ if (!region) return;
22
+ const active = reaction.getAttribute('aria-pressed') !== 'true';
23
+ const saved = await mutateEngagement(region, active ? 'reaction.add' : 'reaction.remove', {
24
+ articleId: region.dataset.articleId,
25
+ reaction: reaction.dataset.reaction
26
+ });
27
+ if (!saved) return;
28
+ reaction.setAttribute('aria-pressed', String(active));
29
+ return;
30
+ }
31
+
32
+ const follow = event.target.closest('[data-follow-article]');
33
+ if (follow) {
34
+ const region = follow.closest('[data-engagement-url]');
35
+ if (!region) return;
36
+ const active = follow.getAttribute('aria-pressed') !== 'true';
37
+ const saved = await mutateEngagement(region, active ? 'follow.add' : 'follow.remove', {
38
+ targetType: 'articles', targetId: region.dataset.articleId
39
+ });
40
+ if (!saved) return;
41
+ follow.setAttribute('aria-pressed', String(active));
42
+ follow.textContent = active ? 'Unfollow article' : 'Follow article';
43
+ return;
44
+ }
45
+
46
+ const reply = event.target.closest('[data-reply-comment]');
47
+ if (reply) {
48
+ const region = reply.closest('[data-engagement-url]');
49
+ const form = region?.querySelector('[data-comment-create]');
50
+ if (!form) return;
51
+ form.dataset.parentCommentId = reply.dataset.replyComment;
52
+ const context = form.querySelector('[data-comment-reply-context]');
53
+ if (context) {
54
+ context.hidden = false;
55
+ context.textContent = `Replying to ${reply.dataset.replyAuthor || 'comment'}.`;
56
+ }
57
+ const cancel = form.querySelector('[data-cancel-reply]');
58
+ if (cancel) cancel.hidden = false;
59
+ form.querySelector('textarea')?.focus();
60
+ return;
61
+ }
62
+
63
+ const cancelReply = event.target.closest('[data-cancel-reply]');
64
+ if (cancelReply) {
65
+ clearReply(cancelReply.closest('[data-comment-create]'));
66
+ return;
67
+ }
68
+
69
+ const edit = event.target.closest('[data-edit-comment]');
70
+ if (edit) {
71
+ showCommentEditor(edit);
72
+ return;
73
+ }
74
+
75
+ const cancelEdit = event.target.closest('[data-cancel-comment-edit]');
76
+ if (cancelEdit) {
77
+ cancelEdit.closest('[data-comment-editor]')?.remove();
78
+ return;
79
+ }
80
+
81
+ const remove = event.target.closest('[data-delete-comment]');
82
+ if (remove) {
83
+ const region = remove.closest('[data-engagement-url]');
84
+ if (!region) return;
85
+ await mutateEngagement(region, 'comment.delete', {
86
+ articleId: region.dataset.articleId,
87
+ commentId: remove.dataset.deleteComment
88
+ });
89
+ return;
90
+ }
91
+
92
+ const moreComments = event.target.closest('[data-comments-cursor]');
93
+ if (moreComments) {
94
+ const region = moreComments.closest('[data-engagement-url]');
95
+ if (!region) return;
96
+ moreComments.disabled = true;
97
+ await refreshEngagement(region, moreComments.dataset.commentsCursor, true);
98
+ return;
99
+ }
100
+
11
101
  const share = event.target.closest('[data-copy-url]');
12
102
  if (share) {
13
103
  const value = share.dataset.copyUrl;
@@ -52,3 +142,297 @@ codeBlocks.forEach((pre) => {
52
142
  control.setAttribute('aria-label', 'Copy code block');
53
143
  wrapper.prepend(control);
54
144
  });
145
+
146
+ function textElement(name, text, className) {
147
+ const element = document.createElement(name);
148
+ element.textContent = text;
149
+ if (className) element.className = className;
150
+ return element;
151
+ }
152
+
153
+ function engagementCounts(data) {
154
+ const reactionTotal = Object.values(data.reactions ?? {})
155
+ .reduce((total, count) => total + (Number.isSafeInteger(count) ? count : 0), 0);
156
+ return [
157
+ ['Reactions', reactionTotal],
158
+ ['Comments', Number.isSafeInteger(data.comments?.totalCount) ? data.comments.totalCount : 0],
159
+ ['Views', Number.isSafeInteger(data.views?.count) ? data.views.count : 0]
160
+ ];
161
+ }
162
+
163
+ function commentList(items, currentUser) {
164
+ const roots = document.createElement('ol');
165
+ roots.className = 'gala-comments';
166
+ const nodes = new Map();
167
+ for (const comment of items) {
168
+ const item = document.createElement('li');
169
+ item.dataset.commentId = comment.commentId;
170
+ const author = comment.author?.displayName ?? '[deleted]';
171
+ item.append(textElement('strong', author));
172
+ const body = textElement('p', comment.deleted ? '[deleted]' : comment.body);
173
+ body.dataset.commentBody = '';
174
+ item.append(body);
175
+ if (!comment.deleted && currentUser) {
176
+ const actions = document.createElement('div');
177
+ actions.className = 'gala-comment-actions';
178
+ const reply = textElement('button', 'Reply');
179
+ reply.type = 'button';
180
+ reply.dataset.replyComment = comment.commentId;
181
+ reply.dataset.replyAuthor = author;
182
+ actions.append(reply);
183
+ if (comment.author?.userId === currentUser.id) {
184
+ const edit = textElement('button', 'Edit');
185
+ edit.type = 'button';
186
+ edit.dataset.editComment = comment.commentId;
187
+ const remove = textElement('button', 'Delete');
188
+ remove.type = 'button';
189
+ remove.dataset.deleteComment = comment.commentId;
190
+ actions.append(edit, remove);
191
+ }
192
+ item.append(actions);
193
+ }
194
+ nodes.set(comment.commentId, item);
195
+ }
196
+ for (const comment of items) {
197
+ const item = nodes.get(comment.commentId);
198
+ const parent = comment.parentCommentId ? nodes.get(comment.parentCommentId) : null;
199
+ if (!parent) {
200
+ roots.append(item);
201
+ continue;
202
+ }
203
+ let replies = parent.querySelector(':scope > ol');
204
+ if (!replies) {
205
+ replies = document.createElement('ol');
206
+ replies.className = 'gala-comment-replies';
207
+ parent.append(replies);
208
+ }
209
+ replies.append(item);
210
+ }
211
+ return roots;
212
+ }
213
+
214
+ function renderEngagement(region, payload, appendComments = false) {
215
+ const data = payload?.data;
216
+ if (!data || typeof data !== 'object') throw new TypeError('Engagement data is invalid');
217
+ const live = region.querySelector('[data-engagement-live]');
218
+ if (!live) return;
219
+ if (!appendComments) live.replaceChildren();
220
+
221
+ if (!appendComments && data.profile) {
222
+ const profile = document.createElement('section');
223
+ profile.className = 'gala-engagement-profile';
224
+ profile.setAttribute('aria-label', 'Author profile');
225
+ profile.append(textElement('h2', data.profile.displayName));
226
+ if (data.profile.username) profile.append(textElement('p', `@${data.profile.username}`));
227
+ if (Number.isSafeInteger(data.profile.followerCount)) {
228
+ profile.append(textElement('p', `${data.profile.followerCount} followers`));
229
+ }
230
+ live.append(profile);
231
+ }
232
+
233
+ if (!appendComments) {
234
+ const summary = document.createElement('dl');
235
+ for (const [label, value] of engagementCounts(data)) {
236
+ const item = document.createElement('div');
237
+ item.append(textElement('dt', label), textElement('dd', String(value)));
238
+ summary.append(item);
239
+ }
240
+ live.append(summary);
241
+ }
242
+
243
+ if (Array.isArray(data.comments?.items) && data.comments.items.length > 0) {
244
+ live.append(commentList(data.comments.items, sessionUser));
245
+ }
246
+ live.querySelector('[data-comments-cursor]')?.remove();
247
+ if (data.comments?.nextCursor) {
248
+ const more = textElement('button', 'Load more comments');
249
+ more.type = 'button';
250
+ more.dataset.commentsCursor = data.comments.nextCursor;
251
+ live.append(more);
252
+ }
253
+ region.querySelector('[data-engagement-snapshot]')?.remove();
254
+ region.querySelector('.gala-engagement__placeholder')?.remove();
255
+ }
256
+
257
+ function clearReply(form) {
258
+ if (!form) return;
259
+ delete form.dataset.parentCommentId;
260
+ const context = form.querySelector('[data-comment-reply-context]');
261
+ if (context) {
262
+ context.hidden = true;
263
+ context.textContent = '';
264
+ }
265
+ const cancel = form.querySelector('[data-cancel-reply]');
266
+ if (cancel) cancel.hidden = true;
267
+ }
268
+
269
+ function showCommentEditor(control) {
270
+ const item = control.closest('[data-comment-id]');
271
+ if (!item || item.querySelector('[data-comment-editor]')) return;
272
+ const form = document.createElement('form');
273
+ form.dataset.commentEditor = control.dataset.editComment;
274
+ const textarea = document.createElement('textarea');
275
+ textarea.name = 'body';
276
+ textarea.maxLength = 5000;
277
+ textarea.required = true;
278
+ textarea.value = item.querySelector('[data-comment-body]')?.textContent ?? '';
279
+ const save = textElement('button', 'Save');
280
+ save.type = 'submit';
281
+ const cancel = textElement('button', 'Cancel');
282
+ cancel.type = 'button';
283
+ cancel.dataset.cancelCommentEdit = '';
284
+ form.append(textarea, save, cancel);
285
+ item.append(form);
286
+ textarea.focus();
287
+ }
288
+
289
+ async function refreshEngagement(region, commentsCursor = '', appendComments = false) {
290
+ const status = region.querySelector('[data-engagement-status]');
291
+ try {
292
+ const requestUrl = new URL(region.dataset.engagementUrl);
293
+ if (commentsCursor) requestUrl.searchParams.set('commentsCursor', commentsCursor);
294
+ const response = await fetch(requestUrl, {
295
+ headers: { Accept: 'application/json' },
296
+ credentials: 'omit'
297
+ });
298
+ if (!response.ok) throw new Error(`Engagement returned HTTP ${response.status}`);
299
+ const payload = await response.json();
300
+ renderEngagement(region, payload, appendComments);
301
+ if (status) status.textContent = payload.errors?.length
302
+ ? 'Some engagement data is temporarily unavailable.' : '';
303
+ } catch {
304
+ if (status) status.textContent = 'Live engagement data is temporarily unavailable.';
305
+ }
306
+ }
307
+
308
+ function viewCampaign() {
309
+ const query = new URL(window.location.href).searchParams;
310
+ const campaign = {};
311
+ for (const name of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) {
312
+ const value = query.get(name);
313
+ if (value && [...value].length <= 128) campaign[name] = value;
314
+ }
315
+ return campaign;
316
+ }
317
+
318
+ function recordView(region) {
319
+ const requestUrl = new URL(region.dataset.engagementUrl);
320
+ requestUrl.pathname = requestUrl.pathname.replace(/\/engagement$/, '/views');
321
+ fetch(requestUrl, {
322
+ method: 'POST',
323
+ headers: { 'Content-Type': 'application/json' },
324
+ body: JSON.stringify({
325
+ language: document.documentElement.lang || undefined,
326
+ referrer: document.referrer || undefined,
327
+ campaign: viewCampaign()
328
+ }),
329
+ credentials: 'omit',
330
+ keepalive: true
331
+ }).catch(() => {});
332
+ }
333
+
334
+ document.addEventListener('submit', async (event) => {
335
+ const create = event.target.closest('[data-comment-create]');
336
+ const editor = event.target.closest('[data-comment-editor]');
337
+ if (!create && !editor) return;
338
+ event.preventDefault();
339
+ const form = create ?? editor;
340
+ const region = form.closest('[data-engagement-url]');
341
+ const body = form.elements.body?.value;
342
+ if (!region || typeof body !== 'string' || body.trim() === '') return;
343
+ if (create) {
344
+ const saved = await mutateEngagement(region, 'comment.create', {
345
+ articleId: region.dataset.articleId,
346
+ parentCommentId: create.dataset.parentCommentId || null,
347
+ body
348
+ });
349
+ if (!saved) return;
350
+ create.reset();
351
+ clearReply(create);
352
+ } else {
353
+ const saved = await mutateEngagement(region, 'comment.edit', {
354
+ articleId: region.dataset.articleId,
355
+ commentId: editor.dataset.commentEditor,
356
+ body
357
+ });
358
+ if (!saved) return;
359
+ editor.remove();
360
+ }
361
+ });
362
+
363
+ let sessionUser = null;
364
+ const pendingEngagementWrites = new Map();
365
+
366
+ document.querySelectorAll('[data-engagement-url]').forEach((region) => {
367
+ refreshEngagement(region);
368
+ recordView(region);
369
+ });
370
+
371
+ const sessionFrame = document.querySelector('[data-gala-session-frame]');
372
+
373
+ function engagementErrorMessage(code) {
374
+ if (code === 'AUTHENTICATION_REQUIRED' || code === 'INVALID_BEARER_TOKEN'
375
+ || code === 'REAUTHENTICATION_REQUIRED') return 'Sign in again to continue.';
376
+ if (code === 'ENGAGEMENT_RATE_LIMITED') return 'You are posting too quickly; try again shortly.';
377
+ if (code === 'RESOURCE_NOT_FOUND') return 'That item is no longer available.';
378
+ if (code === 'INVALID_ENGAGEMENT_WRITE' || code === 'INVALID_REQUEST') return 'Check your entry and try again.';
379
+ if (code === 'ACCESS_DENIED') return 'That action is not available for this account.';
380
+ if (code === 'IDEMPOTENCY_CONFLICT' || code === 'ENGAGEMENT_STATE_CONFLICT') return 'The item changed; reload and try again.';
381
+ return 'The action could not be completed. Try again.';
382
+ }
383
+
384
+ function sendEngagementWrite(operation, payload) {
385
+ if (!sessionFrame || !sessionUser) return Promise.reject(new Error('AUTHENTICATION_REQUIRED'));
386
+ const requestId = crypto.randomUUID();
387
+ return new Promise((resolve, reject) => {
388
+ pendingEngagementWrites.set(requestId, { resolve, reject });
389
+ sessionFrame.contentWindow.postMessage({
390
+ type: 'gala-engagement-write', requestId, operation, payload
391
+ }, new URL(sessionFrame.src).origin);
392
+ });
393
+ }
394
+
395
+ async function mutateEngagement(region, operation, payload) {
396
+ const status = region.querySelector('[data-engagement-status]');
397
+ try {
398
+ if (status) status.textContent = 'Saving…';
399
+ await sendEngagementWrite(operation, payload);
400
+ if (status) status.textContent = 'Saved.';
401
+ await refreshEngagement(region);
402
+ return true;
403
+ } catch (error) {
404
+ if (status) status.textContent = engagementErrorMessage(error.message);
405
+ return false;
406
+ }
407
+ }
408
+
409
+ if (sessionFrame) {
410
+ const sessionOrigin = new URL(sessionFrame.src).origin;
411
+ window.addEventListener('message', (event) => {
412
+ if (event.origin !== sessionOrigin || event.source !== sessionFrame.contentWindow) return;
413
+ if (event.data?.type === 'gala-engagement-result') {
414
+ const pending = pendingEngagementWrites.get(event.data.requestId);
415
+ if (!pending) return;
416
+ pendingEngagementWrites.delete(event.data.requestId);
417
+ if (event.data.ok === true) pending.resolve(event.data.result);
418
+ else pending.reject(new Error(event.data.error?.code || 'ENGAGEMENT_WRITE_FAILED'));
419
+ return;
420
+ }
421
+ if (event.data?.type !== 'gala-session') return;
422
+ sessionUser = event.data.user && typeof event.data.user.id === 'string' ? event.data.user : null;
423
+ const control = document.querySelector('[data-user-control]');
424
+ const displayName = sessionUser?.displayName;
425
+ if (control) {
426
+ control.setAttribute('aria-label', displayName
427
+ ? `Account: ${displayName}` : 'Sign in or view account');
428
+ control.title = displayName ? `Account: ${displayName}` : 'Account';
429
+ }
430
+ document.querySelectorAll('[data-engagement-url]').forEach((region) => {
431
+ const actions = region.querySelector('[data-engagement-actions]');
432
+ const signIn = region.querySelector('[data-engagement-sign-in]');
433
+ if (actions) actions.hidden = !sessionUser;
434
+ if (signIn) signIn.hidden = Boolean(sessionUser);
435
+ refreshEngagement(region);
436
+ });
437
+ });
438
+ }
@@ -1,6 +1,4 @@
1
- const search = document.querySelector('[data-gala-search]');
2
-
3
- if (search) {
1
+ document.querySelectorAll('[data-gala-search]').forEach((search) => {
4
2
  const form = search.querySelector('form');
5
3
  const query = form?.elements.namedItem('q');
6
4
  const status = search.querySelector('[data-search-status]');
@@ -78,4 +76,4 @@ if (search) {
78
76
  const initial = new URLSearchParams(window.location.search).get('q') ?? '';
79
77
  if (query) query.value = initial;
80
78
  run(initial);
81
- }
79
+ });
@@ -14,7 +14,9 @@ function applyMode(mode) {
14
14
  document.documentElement.dataset.mode = mode;
15
15
  const next = MODES[(MODES.indexOf(mode) + 1) % MODES.length];
16
16
  document.querySelectorAll('[data-theme-mode-toggle]').forEach((control) => {
17
- control.textContent = `Theme: ${mode}`;
17
+ const label = control.querySelector?.('[data-theme-mode-label]');
18
+ if (label) label.textContent = `Theme: ${mode}`;
19
+ else control.textContent = `Theme: ${mode}`;
18
20
  control.setAttribute('aria-label', `Color mode: ${mode}. Activate for ${next}.`);
19
21
  });
20
22
  }
@@ -38,6 +38,45 @@ body {
38
38
  font-family: var(--gala-font-body);
39
39
  }
40
40
 
41
+ .gala-site-header {
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: space-between;
45
+ max-inline-size: var(--gala-content-width);
46
+ margin-inline: auto;
47
+ margin-block-end: 1.5rem;
48
+ }
49
+
50
+ .gala-site-header__actions { display: flex; gap: 0.5rem; }
51
+ .gala-icon-control {
52
+ display: inline-grid;
53
+ inline-size: 2.75rem;
54
+ block-size: 2.75rem;
55
+ box-sizing: border-box;
56
+ padding: 0.6rem;
57
+ place-items: center;
58
+ color: inherit;
59
+ border: 0.125rem solid transparent;
60
+ border-radius: 50%;
61
+ }
62
+ .gala-icon-control:hover { background: var(--gala-color-surface); }
63
+ .gala-icon-control:focus-visible { outline: 0.1875rem solid currentcolor; outline-offset: 0.1875rem; }
64
+ .gala-icon-control svg { inline-size: 100%; block-size: 100%; fill: currentcolor; }
65
+
66
+ .gala-dialog {
67
+ inline-size: min(42rem, calc(100% - 2rem));
68
+ max-block-size: calc(100% - 2rem);
69
+ box-sizing: border-box;
70
+ padding: 1.5rem;
71
+ color: var(--gala-color-text);
72
+ background: var(--gala-color-background);
73
+ border: 0.125rem solid var(--gala-color-border);
74
+ border-radius: var(--gala-radius-control);
75
+ }
76
+ .gala-dialog::backdrop { background: rgb(0 0 0 / 65%); }
77
+ .gala-dialog__close { float: inline-end; }
78
+ .gala-account-dialog iframe { inline-size: 100%; min-block-size: 22rem; border: 0; }
79
+
41
80
  main,
42
81
  .gala-page-footer {
43
82
  max-inline-size: var(--gala-content-width);
@@ -107,11 +146,32 @@ button:focus-visible {
107
146
  .gala-engagement dl > div { display: grid; gap: 0.25rem; }
108
147
  .gala-engagement dd { margin: 0; font-size: 1.25rem; font-weight: 700; }
109
148
  .gala-engagement__placeholder { display: grid; min-block-size: var(--gala-widget-min-block-size); align-content: center; margin: 0; }
149
+ .gala-engagement-profile { margin-block: 1.5rem; padding: 1rem; background: var(--gala-color-surface); border-radius: var(--gala-radius-control); }
150
+ .gala-engagement-profile > * { margin-block: 0 0.5rem; }
151
+ .gala-comments { display: grid; gap: 1rem; padding-inline-start: 1.5rem; }
152
+ .gala-comments li { padding: 1rem; background: var(--gala-color-surface); border-radius: var(--gala-radius-control); }
153
+ .gala-engagement-actions { display: grid; gap: 1rem; margin-block-start: 1.5rem; }
154
+ .gala-engagement-actions[hidden] { display: none; }
155
+ .gala-reaction-actions,
156
+ .gala-comment-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
157
+ .gala-engagement-actions form,
158
+ [data-comment-editor] { display: grid; gap: 0.5rem; max-inline-size: 42rem; }
159
+ .gala-engagement-actions label { display: grid; gap: 0.5rem; }
160
+ .gala-engagement-actions textarea,
161
+ [data-comment-editor] textarea { min-block-size: 7rem; padding: 0.75rem; font: inherit; }
110
162
  .gala-page-footer { margin-block-start: 4rem; padding-block-start: 1rem; border-block-start: 0.125rem solid var(--gala-color-border); }
111
163
  .gala-error-page { min-block-size: 60vh; display: grid; align-content: center; justify-items: start; }
112
164
  .gala-code-block { position: relative; }
113
165
  .gala-code-block > button { position: absolute; inset-block-start: 0.5rem; inset-inline-end: 0.5rem; background: var(--gala-color-background); }
114
166
  .gala-code-block pre { overflow: auto; padding: 3.5rem 1rem 1rem; background: var(--gala-color-surface); border-radius: var(--gala-radius-control); }
167
+ .shiki,
168
+ .shiki span { color: var(--shiki-light); background-color: var(--shiki-light-bg); }
169
+ :root[data-mode='dark'] .shiki,
170
+ :root[data-mode='dark'] .shiki span { color: var(--shiki-dark); background-color: var(--shiki-dark-bg); }
171
+ @media (prefers-color-scheme: dark) {
172
+ :root[data-mode='system'] .shiki,
173
+ :root[data-mode='system'] .shiki span { color: var(--shiki-dark); background-color: var(--shiki-dark-bg); }
174
+ }
115
175
  .gala-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
116
176
 
117
177
  @keyframes gala-spin { to { transform: rotate(1turn); } }