@rathnasgala/theme 2.0.1 → 2.0.3

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": "2.0.1",
3
+ "version": "2.0.3",
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": "2.0.1",
5
+ "version": "2.0.3",
6
6
  "availableDesignThemes": [
7
7
  "editorial",
8
8
  "modern",
@@ -18,56 +18,58 @@
18
18
  ".gala/publish.yml.template": "73080241d89bd09964b797a8bcc8e18471412e11bc873efad76305b60f497d08",
19
19
  ".gitignore": "b5fa24e10155097a0d372926fd2e59e6072e7cc2cb6399bbfb4beb8b33ea315b",
20
20
  "eleventy.config.js": "0b72076f2f884a3a4a5b2ea4a124e0c1308c735526275bb847a3d6ebd082a186",
21
+ "lib/accent.js": "e6c921e0a3afe094b18709e3e40baa06692017fa6576d84b1ec6b7d788da8dd0",
21
22
  "lib/build-manifest.js": "88949f0112d683ca809bb67ca969e15634792423559a67bc79a534cd807e5535",
22
23
  "lib/engagement-snapshot.js": "298c11b1ab135a3a98a4e31b6320ab60dfd7fda6a9d9053df37d86282a5a6687",
23
24
  "lib/performance-budget.js": "c279622dfde237cd6e3c64775c91d770a0e1f54f5f94257ccc95d9a44de2c4c9",
24
25
  "lib/provider-fixtures/embeds.v1.json": "5f26909fb433135321ddf7fdd14e45aeb702805c88f5676941166c5735143ee2",
25
26
  "lib/provider-fixtures/share-intents.v1.json": "66a8eedfcff711253ee45ceb94b53f7d5a6be3ddbbb3d1f0d4e31a114326cc0a",
26
27
  "lib/publication-state.js": "1b59c29a18bbb1193bddea898dac3a4fb832c7bc4751263be086f3d64392dea0",
27
- "lib/render-markdown.js": "40e6d6743b76de6c16d138475f188a4263967530c370d8b26a363f4df58c2caf",
28
- "lib/seo.js": "107de8804931945b8e73894877fc394d6b535a9d0d969a4ba5b68f2e136e0a17",
28
+ "lib/render-markdown.js": "275b7da721aef1f68cda515673153471c0d597061e8fdf867c5f2b32c98b91db",
29
+ "lib/seo.js": "19cf1d67d5b711b8d3a533f207f267e770ee46fd4a4e6561c5f7fd4e9c1989ff",
29
30
  "lib/share-targets.js": "458aff86fab2b62a364442cd8c612565c6e0b53eef2f20cb6237cc562b1e0731",
30
- "lib/site-config.js": "450b532049c9bc1b3dc44c4879e74c33b39c7b9a3e5267b15df98991a2255a20",
31
+ "lib/site-config.js": "679c0eaea066bbe7569bf0211696868106dd1d3bf6118fcfb7aeb75962055384",
31
32
  "package-lock.json": "dc11470b1ad897c58d91745f23ff25711c593e62972ec9fb83f4e10fd77e1ecd",
32
33
  "package.json": "1b3b70f6e94391d309625cb8cef503ee92ecd282bec587643b535ae7a14c24c0",
33
- "src/404.njk": "be0d44370ac5402156aaf3d32ea581057bc61ec94c1a5f92bb809733af7a8a20",
34
+ "scripts/build-reader.js": "743194c8dc9cfb9b6631db1732b5f36a92e3a4f905a2965c71d56e1ef50c2e45",
35
+ "scripts/lint.js": "c4bb3c961686043fa8c79bed546758ef09d44441421d8209fec9d69f74171025",
34
36
  "src/_data/buildManifest.js": "628163305984e0d3537e27b0d4050f5ba5ead12a834878266d2ce8004ffe1b1e",
35
37
  "src/_data/engagementSnapshot.js": "9217dacebfaf008f7a0ad67ff4e8d1fa8fba609a9dc7e3430c78c5b32024da0e",
36
38
  "src/_data/feedLinks.js": "06c93352ebd888acb431a1c9a8be6ecff757895468e9f2d336761038751c2d5e",
37
39
  "src/_data/languages.js": "1358748f7fa2f74406d3295a030827d6229a53e94da72cf712e719d7a356aeac",
38
- "src/_data/site.js": "6f9b754876ab7cfa93f02a38be2962edce8245423c354426a1465513caec7911",
39
- "src/_includes/components/ui.njk": "94042d32664ab4a7a426c0155b43200c87ad1472cba19dcc605e43830967a8b7",
40
- "src/_includes/layouts/base.njk": "5da6243e6f5242c233808601f299b5ef3aa82fd2a332f89e271c0c886b8d2bcd",
41
- "src/_includes/layouts/post.njk": "ba46fbf621fb0134137536897408eff0c4caaacace726f6ca18d0f40c101effc",
40
+ "src/_data/site.js": "432be73b49c20f088c304a2f7d7cd85906a747e0f3b381a4b395c5c44d68bb57",
41
+ "src/_includes/components/ui.njk": "0634c206c2db0507f77686635e3b406e1d8878011fd7eac64563df8499fe8f60",
42
+ "src/_includes/layouts/base.njk": "28043f394af8e2e3b09b6218ed3de2a506535fd5d59e67225c0fbd4431f967ff",
43
+ "src/_includes/layouts/post.njk": "be1069e47be0da7cc32be9db6163c8de6180ab0bdd1d4a2320485bc720017cea",
44
+ "src/404.njk": "be0d44370ac5402156aaf3d32ea581057bc61ec94c1a5f92bb809733af7a8a20",
45
+ "src/accent.11ty.js": "52e7d3eb731c5f4649c14c4745f65da026c25b58cf462d075b8ef363f0e487dc",
42
46
  "src/assets/embed-codepen.svg": "e9def697f38c466fb2b6652535c4c814e8cbbbefc6e052a69f335aeb27e8356b",
43
47
  "src/assets/embed-gist.svg": "fcd9bc2bf29a5c118492b853c0a01390b7539c38ddb985da3c2a6e2f246a39f7",
44
48
  "src/assets/embed-x.svg": "0ae74edc6790ec7e933067fe5392bce756e93fa7ad71caacf1c1ca99fa4ec9b6",
45
49
  "src/assets/embed-youtube.svg": "10d192f2b14b67664fea88c8cf246321d20190def51d59fad5762e552219b067",
46
- "src/assets/engagement-comments.js": "4b12de4d05f479b8f98ccf2da07bb39fe279291842637bdb052cdaa7794761a2",
50
+ "src/assets/engagement-comments.js": "eda0fee471c7e42ee01a25b8aa14ed6ae9690d52b3fbb34027d97534ab62998b",
47
51
  "src/assets/engagement-transport.js": "d4108cded823f859e7a8d6e36716162422ceaf5e4b94a57ed9bd7941d5096216",
48
52
  "src/assets/favicon.svg": "8615e3d96fe37ae9ed14afb1886e7c0e690eef5b10be66ca91cccfd085a74bb6",
49
- "src/assets/interactions.js": "a3290243edd874ff0a20864ea9c3883b5c00a5dae1dd5eabe83ef8dc7d0e0f9f",
53
+ "src/assets/interactions.js": "d7ac42be1c3453fb5a7bc1b7de52bca2bc7322e60ef235ce4953f5382a3550c5",
50
54
  "src/assets/preferences.js": "789127bee013ed6a1bbf8cefd7b9109291c16220e7a6e09314e3c93c54731d1d",
55
+ "src/assets/reader.js": "96018c305177c5cbea77b4958ccba7800be82ace513f47fc811bdd41fb8eee63",
51
56
  "src/assets/search.js": "df425f0239277792ad39f2138bc89cb66cc4ecda5ed59042d46abc2f05372088",
52
57
  "src/assets/theme-mode.js": "944b29db11a577961a60ddcfa52f07ef2411f3f3631bea85bd79be16428c08be",
53
- "src/assets/theme.css": "c3e6f8367c824ff129f318113e51f8db3c9cb584c472f9be6ea8710804c8d433",
58
+ "src/assets/theme.css": "c3bd809a149f4c2dab8429d2a7aafa9e0a3674e26fa0ee19b1f654299f16e43d",
59
+ "src/client/reader.js": "5a61d629517c5315b3cb14dbae6cee226ba828e95132100f2ad36e614c57470a",
54
60
  "src/cname.11ty.js": "206fc224df91c569e18312158e7f69803061f2952513f501ada54bd06c4f8006",
55
61
  "src/contact.njk": "f4d74d038781e15af0acef571901c91e7753a2e21b6bef617429585698839a34",
56
62
  "src/feed.11ty.js": "009bd2384e08e02a9405683bab1653bab3aeaec16accb0fd01ba69b24c777da9",
57
63
  "src/index.njk": "5c313f0fcdad3ddbfb79376bc9671b3f37539ef70c14bdbbd26d6e2e6de250e8",
58
64
  "src/languages.11ty.js": "399559199c65570d7d5efd88e20254838e316f81c06d7b28ec34990e963db915",
59
- "src/posts.11ty.js": "89c0e4742a67cd03f9fd13c8c8d4ca56375d97628b3cb439af7f9c92c240104a",
65
+ "src/posts.11ty.js": "c0bdc81183f5849670d100ceb97ecad0d1eb05f0de3eea34a0ce7427fd2e7e2a",
60
66
  "src/redirects.11ty.js": "3c163246a59f5364861db6a6f05ae88afc938a5379d86cf42776d8d49b0031f3",
61
67
  "src/search-index.11ty.js": "700dc2a2be0ef20de3a828a4612fcf7a082691eade29c6e1d75f09c24b6c6024",
62
68
  "src/search.njk": "63f2e8aeebb63c90da2e375eece639ce096cad1eff7f28628cafc46faf7ceee6",
63
69
  "src/sitemap.11ty.js": "4f233e94fb4a913ad5659116518432ed264984a7b622eba3558c77b4ec6435dc",
64
- "static/robots.txt": "16ceb5ee3e0dc13aa9adf31a3ebbe45a1d965b8c2b9f72eaf84e5911e140ed95",
65
- "scripts/build-reader.js": "743194c8dc9cfb9b6631db1732b5f36a92e3a4f905a2965c71d56e1ef50c2e45",
66
- "src/client/reader.js": "5a61d629517c5315b3cb14dbae6cee226ba828e95132100f2ad36e614c57470a",
67
- "src/assets/reader.js": "cb53c4702bae396c708b6763352843868447ca9838bbf2ee451bb81c7c0b5b6c",
68
- "scripts/lint.js": "c4bb3c961686043fa8c79bed546758ef09d44441421d8209fec9d69f74171025",
69
- "src/styles/theme.css": "5ba8ccced10ceecd4ead7e60980d6d2f792eb9abff1748484d1a3de0273d2dc8",
70
- "src/accent.11ty.js": "52e7d3eb731c5f4649c14c4745f65da026c25b58cf462d075b8ef363f0e487dc"
70
+ "src/styles/theme.css": "24c8f4b7553bd17a2f8ac4762c16a81e18209bcbc6f1870aaca305da8a56b1e5",
71
+ "static/favicon.ico": "ad3cb0b02f0164f3af6280d0bfe712879d55cec0e9a7a567f658aaddca5a55a2",
72
+ "static/robots.txt": "16ceb5ee3e0dc13aa9adf31a3ebbe45a1d965b8c2b9f72eaf84e5911e140ed95"
71
73
  },
72
74
  "artifactSources": {
73
75
  ".gitignore": ".gala/artifact-files/gitignore"
@@ -0,0 +1,105 @@
1
+ /**
2
+ * A writer picks one colour; this makes it work in both modes.
3
+ *
4
+ * Offering a handful of named presets was not picking a colour, and an unconstrained colour picker
5
+ * is worse: most colours a person likes fail contrast against one ground or the other, and a
6
+ * writer cannot see that until the site is deployed. So the seed is treated as a *hue* the writer
7
+ * chose, and the lightness is moved — as little as possible — until the result is readable on the
8
+ * background it will actually sit on.
9
+ *
10
+ * The maths is OKLab, so a hue keeps its identity while its lightness changes. Doing this in sRGB
11
+ * shifts the colour itself, and a writer who picked a warm red gets back a different red.
12
+ */
13
+
14
+ const LIGHT_GROUND = '#fdfcfb';
15
+ const DARK_GROUND = '#131211';
16
+ /** WCAG AA for body-sized text, which is what a link is. */
17
+ const REQUIRED = 4.5;
18
+
19
+ export function parseHex(value) {
20
+ if (typeof value !== 'string') return null;
21
+ const hex = value.trim().replace(/^#/, '');
22
+ const full = hex.length === 3 ? [...hex].map((digit) => digit + digit).join('') : hex;
23
+ if (!/^[0-9a-f]{6}$/i.test(full)) return null;
24
+ return [0, 2, 4].map((offset) => Number.parseInt(full.slice(offset, offset + 2), 16));
25
+ }
26
+
27
+ const toLinear = (channel) => {
28
+ const value = channel / 255;
29
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
30
+ };
31
+
32
+ const toSrgb = (value) => {
33
+ const channel = value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055;
34
+ return Math.min(255, Math.max(0, Math.round(channel * 255)));
35
+ };
36
+
37
+ export function relativeLuminance([red, green, blue]) {
38
+ return 0.2126 * toLinear(red) + 0.7152 * toLinear(green) + 0.0722 * toLinear(blue);
39
+ }
40
+
41
+ export function contrast(a, b) {
42
+ const [high, low] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
43
+ return (high + 0.05) / (low + 0.05);
44
+ }
45
+
46
+ function rgbToOklab([red, green, blue]) {
47
+ const [r, g, b] = [red, green, blue].map(toLinear);
48
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
49
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
50
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
51
+ return [
52
+ 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
53
+ 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
54
+ 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
55
+ ];
56
+ }
57
+
58
+ function oklabToRgb([lightness, a, b]) {
59
+ const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
60
+ const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
61
+ const s = (lightness - 0.0894841775 * a - 1.2914855480 * b) ** 3;
62
+ return [
63
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
64
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
65
+ -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
66
+ ].map(toSrgb);
67
+ }
68
+
69
+ const hex = ([red, green, blue]) =>
70
+ `#${[red, green, blue].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
71
+
72
+ /**
73
+ * The writer's colour, moved along its own lightness until it is readable on `ground`.
74
+ *
75
+ * Searched from the seed outwards in both directions so the answer is the nearest readable
76
+ * version of what they chose, rather than always darker or always lighter.
77
+ */
78
+ function readableOn(seed, ground) {
79
+ const groundRgb = parseHex(ground);
80
+ if (contrast(seed, groundRgb) >= REQUIRED) return hex(seed);
81
+
82
+ const [lightness, a, b] = rgbToOklab(seed);
83
+ let nearest = null;
84
+ for (let step = 0.01; step <= 1; step += 0.01) {
85
+ for (const direction of [-1, 1]) {
86
+ const candidate = lightness + direction * step;
87
+ if (candidate < 0 || candidate > 1) continue;
88
+ const rgb = oklabToRgb([candidate, a, b]);
89
+ if (contrast(rgb, groundRgb) >= REQUIRED) { nearest = rgb; break; }
90
+ }
91
+ if (nearest) break;
92
+ }
93
+ // Only reachable for a ground no colour can meet, which neither of ours is.
94
+ return hex(nearest ?? seed);
95
+ }
96
+
97
+ /**
98
+ * The two values the page needs: one readable on the light ground, one on the dark.
99
+ * Returns null for anything that is not a colour, so the caller falls back to the look's own.
100
+ */
101
+ export function accentPair(seed) {
102
+ const rgb = parseHex(seed);
103
+ if (!rgb) return null;
104
+ return { light: readableOn(rgb, LIGHT_GROUND), dark: readableOn(rgb, DARK_GROUND) };
105
+ }
@@ -195,7 +195,7 @@ markdown.renderer.rules.fence = (tokens, index, options, environment, self) => {
195
195
  lang: language,
196
196
  themes: SHIKI_THEMES,
197
197
  defaultColor: false
198
- }));
198
+ }).replaceAll(/ style="[^"]*"/g, ''));
199
199
  const tokensByIndex = environment.galaHighlightTokens ??= [];
200
200
  tokensByIndex.push(token);
201
201
  return `<div class="gala-highlight-placeholder" data-highlight-token="${token}"></div>`;
@@ -134,9 +134,12 @@ export function postSeo({ post, site, renderedHtml }) {
134
134
  ? fallbackDescription(renderedHtml)
135
135
  : authoredDescription;
136
136
  const siteName = requiredText(site.site?.name, 'site name');
137
- const author = [post.frontmatter?.author, site.site?.author, siteName]
137
+ const configuredAuthor = site.site?.authorProfile?.displayName || site.site?.author;
138
+ const author = [post.frontmatter?.author, configuredAuthor, siteName]
138
139
  .find((value) => typeof value === 'string' && value.trim() !== '')
139
140
  .trim();
141
+ const authorProfile = author === configuredAuthor ? site.site?.authorProfile : null;
142
+ const publisherProfile = site.site?.publisher;
140
143
  const pageUrl = absoluteHttpsUrl(post.pageUrl, 'page URL').toString();
141
144
  const selectedCanonical = canonicalUrl({ pageUrl, canonicalOverride: post.canonicalUrl });
142
145
  const language = Intl.getCanonicalLocales(requiredText(post.language, 'post language'))[0];
@@ -168,8 +171,19 @@ export function postSeo({ post, site, renderedHtml }) {
168
171
  datePublished,
169
172
  dateModified,
170
173
  inLanguage: language,
171
- author: { '@type': 'Person', name: author },
172
- publisher: { '@type': 'Organization', name: siteName },
174
+ author: {
175
+ '@type': 'Person',
176
+ name: author,
177
+ ...(authorProfile?.profileUrl ? { url: authorProfile.profileUrl } : {}),
178
+ ...(authorProfile?.avatarUrl ? { image: authorProfile.avatarUrl } : {})
179
+ },
180
+ publisher: {
181
+ '@type': 'Organization',
182
+ name: publisherProfile?.name || siteName,
183
+ ...(publisherProfile?.url ? { url: publisherProfile.url } : {}),
184
+ ...(publisherProfile?.logoUrl
185
+ ? { logo: { '@type': 'ImageObject', url: publisherProfile.logoUrl } } : {})
186
+ },
173
187
  ...(imageUrl == null ? {} : { image: imageUrl })
174
188
  };
175
189
  const breadcrumbList = {
@@ -24,6 +24,8 @@ const LEGACY_DESIGN_VALUES = Object.freeze({
24
24
  const CONTACT_KEYS = Object.freeze([
25
25
  'enabled', 'websiteEnabled', 'phoneEnabled'
26
26
  ]);
27
+ const PROFILE_KEYS = Object.freeze(['displayName', 'bio', 'avatarUrl', 'profileUrl']);
28
+ const PUBLISHER_KEYS = Object.freeze(['name', 'url', 'logoUrl']);
27
29
 
28
30
  /*
29
31
  * `layout` and `palette` have always been required. The rest were added later and are optional:
@@ -104,6 +106,60 @@ export function validateContact(value) {
104
106
  });
105
107
  }
106
108
 
109
+ function profileText(value, field, maximum) {
110
+ if (value == null || value === 'unavailable') return '';
111
+ if (typeof value !== 'string') throw new TypeError(`${field} must be a string`);
112
+ const normalized = value.trim();
113
+ if ([...normalized].length > maximum) {
114
+ throw new TypeError(`${field} must contain at most ${maximum} characters`);
115
+ }
116
+ return normalized;
117
+ }
118
+
119
+ function profileUrl(value, field) {
120
+ const normalized = profileText(value, field, 2048);
121
+ if (normalized === '') return '';
122
+ let parsed;
123
+ try { parsed = new URL(normalized); } catch { throw new TypeError(`${field} must be an HTTPS URL`); }
124
+ if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') {
125
+ throw new TypeError(`${field} must be a credential-free HTTPS URL`);
126
+ }
127
+ return normalized;
128
+ }
129
+
130
+ export function validateProfile(site) {
131
+ const legacyAuthor = profileText(site?.author, 'site.author', 120);
132
+ const author = site?.authorProfile;
133
+ const publisher = site?.publisher;
134
+ if (author != null && (Array.isArray(author) || typeof author !== 'object')) {
135
+ throw new TypeError('site.authorProfile must be a mapping');
136
+ }
137
+ if (publisher != null && (Array.isArray(publisher) || typeof publisher !== 'object')) {
138
+ throw new TypeError('site.publisher must be a mapping');
139
+ }
140
+ const unknownAuthor = Object.keys(author ?? {}).filter((key) => !PROFILE_KEYS.includes(key));
141
+ const unknownPublisher = Object.keys(publisher ?? {}).filter((key) => !PUBLISHER_KEYS.includes(key));
142
+ if (unknownAuthor.length) throw new TypeError(`Unsupported author profile field: ${unknownAuthor.join(', ')}`);
143
+ if (unknownPublisher.length) throw new TypeError(`Unsupported publisher field: ${unknownPublisher.join(', ')}`);
144
+ const normalized = Object.freeze({
145
+ author: Object.freeze({
146
+ displayName: profileText(author?.displayName, 'site.authorProfile.displayName', 120) || legacyAuthor,
147
+ bio: profileText(author?.bio, 'site.authorProfile.bio', 500),
148
+ avatarUrl: profileUrl(author?.avatarUrl, 'site.authorProfile.avatarUrl'),
149
+ profileUrl: profileUrl(author?.profileUrl, 'site.authorProfile.profileUrl')
150
+ }),
151
+ publisher: Object.freeze({
152
+ name: profileText(publisher?.name, 'site.publisher.name', 120),
153
+ url: profileUrl(publisher?.url, 'site.publisher.url'),
154
+ logoUrl: profileUrl(publisher?.logoUrl, 'site.publisher.logoUrl')
155
+ })
156
+ });
157
+ if (!normalized.publisher.name && (normalized.publisher.url || normalized.publisher.logoUrl)) {
158
+ throw new TypeError('site.publisher.name is required when publisher URLs are set');
159
+ }
160
+ return normalized;
161
+ }
162
+
107
163
  export async function loadSiteConfiguration({
108
164
  root = process.cwd(),
109
165
  configPath = process.env.GALA_CONFIG_PATH ?? 'site.config.yml'
@@ -126,5 +182,11 @@ export async function loadSiteConfiguration({
126
182
  validatePerformanceBudgets(config?.performance?.budgets);
127
183
  config.statistics = validateStatistics(config.statistics);
128
184
  config.contact = validateContact(config.contact);
185
+ const profile = validateProfile(config.site);
186
+ config.site = {
187
+ ...(config.site ?? {}),
188
+ authorProfile: profile.author,
189
+ publisher: profile.publisher
190
+ };
129
191
  return config;
130
192
  }
@@ -3,6 +3,22 @@ import { loadSiteConfiguration } from '../../lib/site-config.js';
3
3
 
4
4
  export default async function siteConfiguration() {
5
5
  const configuration = await loadSiteConfiguration();
6
+ const repositoryOwner = configuration?.site?.repository?.split('/')[0];
7
+ const author = configuration?.site?.authorProfile?.displayName || configuration?.site?.author;
8
+ const name = configuration?.site?.name;
9
+ const fallback = author && author !== 'unavailable'
10
+ ? author
11
+ : repositoryOwner && repositoryOwner !== 'unavailable' ? repositoryOwner : 'Publication';
12
+ configuration.site = {
13
+ ...configuration.site,
14
+ name: name && name !== 'unavailable' && name !== 'New Gala Site' ? name : fallback,
15
+ author: author && author !== 'unavailable' && author !== 'New Gala Site' ? author : undefined,
16
+ authorProfile: {
17
+ ...configuration.site.authorProfile,
18
+ displayName: author && author !== 'unavailable' && author !== 'New Gala Site'
19
+ ? author : fallback
20
+ }
21
+ };
6
22
  /*
7
23
  * The writer picks one colour; the page needs two, because the same colour cannot be readable on
8
24
  * both a light and a dark ground. Derived here rather than in CSS so the contrast is exact — a
@@ -10,45 +10,57 @@
10
10
  {% macro loading(label='Loading') %}<div class="gala-loading" role="status"><span class="gala-loading__indicator" aria-hidden="true"></span><span class="gala-visually-hidden">{{ label }}</span></div>{% endmacro %}
11
11
  {% macro pagination(previous='', next='') %}<nav class="gala-pagination" aria-label="Pagination">{% if previous %}<a href="{{ previous }}" rel="prev">Previous</a>{% endif %}{% if next %}<a href="{{ next }}" rel="next">Next</a>{% endif %}</nav>{% endmacro %}
12
12
  {% macro search(action='/search/') %}<form class="gala-search" action="{{ action }}" role="search"><label for="gala-search-query">Search</label><input id="gala-search-query" name="q" type="search" autocomplete="off"><button type="submit">Search</button></form>{% endmacro %}
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 %}
13
+ {% macro tableOfContents(items) %}<details class="gala-toc" open><summary>On this page</summary><nav aria-label="Table of contents"><ol>{% for item in items %}<li><a href="#{{ item.id }}">{{ item.text }}</a></li>{% endfor %}</ol></nav></details>{% 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, canonicalUrl, engagement, apiBaseUrl) %}
17
- <section id="conversation" class="gala-engagement" aria-label="Article conversation" data-article-id="{{ articleId | escape }}" data-engagement-url="{{ apiBaseUrl | escape }}/v1/articles/{{ articleId | escape }}/engagement">
16
+ {% macro reactionIcon(value) %}<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">{% if value == 'like' %}<path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"/>{% elif value == 'insightful' %}<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6M10 22h4"/>{% elif value == 'celebrate' %}<path d="M5.8 11.3 2 22l10.7-3.79M4 3h.01M22 8h.01M15 2h.01M22 20h.01"/><path d="m22 2-2.24.75a2.9 2.9 0 0 0-1.96 3.12c.1.86-.57 1.63-1.45 1.63h-.38c-.86 0-1.6.6-1.76 1.44L14 10M22 13l-.82-.33c-.86-.34-1.82.2-1.98 1.11-.11.7-.72 1.22-1.43 1.22H17M11 2l.33.82c.34.86-.2 1.82-1.11 1.98C9.52 4.9 9 5.52 9 6.23V7"/><path d="M11 13c1.93 1.93 2.83 4.17 2 5-.83.83-3.07-.07-5-2-1.93-1.93-2.83-4.17-2-5 .83-.83 3.07.07 5 2Z"/>{% elif value == 'funny' %}<path d="M15 10V9M16.472 15a6 6 0 0 1-8.943 0M9 10V9"/><circle cx="12" cy="12" r="10"/>{% elif value == 'mind-blown' %}<path d="M12 18V5M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5M17.997 5.125a4 4 0 0 1 2.526 5.77M18 18a4 4 0 0 0 2-7.464M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517M6 18a4 4 0 0 1-2-7.464M6.003 5.125a4 4 0 0 0-2.526 5.77"/>{% else %}<path d="M11 14h2a2 2 0 0 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 16"/><path d="m14.45 13.39 5.05-4.694C20.196 8 21 6.85 21 5.75a2.75 2.75 0 0 0-4.797-1.837.276.276 0 0 1-.406 0A2.75 2.75 0 0 0 11 5.75c0 1.2.802 2.248 1.5 2.946L16 11.95M2 15l6 6"/><path d="m7 20 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a1 1 0 0 0-2.75-2.91"/>{% endif %}</svg>{% endmacro %}
17
+ {% macro actionRail(canonicalUrl, engagement) %}
18
18
  <aside class="gala-action-rail" aria-label="Reader actions">
19
- <div class="gala-reaction-actions" role="group" aria-label="React to this article">
19
+ <dl class="gala-action-stats" data-action-stats aria-label="Article statistics">
20
+ {% set reactionCount = engagement.reactions if engagement.available else 0 %}{% set commentCount = engagement.comments if engagement.available else 0 %}{% set viewCount = engagement.views if engagement.available else 0 %}
21
+ <div title="{{ reactionCount }} reactions"><dt><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8Z"/></svg><span class="gala-visually-hidden">Reactions</span></dt><dd data-engagement-stat="reactions" data-stat-label="Reactions">{{ reactionCount if reactionCount >= 1000 else '&lt;1K' | safe }}</dd></div>
22
+ <div title="{{ commentCount }} comments"><dt><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z"/></svg><span class="gala-visually-hidden">Comments</span></dt><dd data-engagement-stat="comments" data-stat-label="Comments">{{ commentCount if commentCount >= 1000 else '&lt;1K' | safe }}</dd></div>
23
+ <div title="{{ viewCount }} views"><dt><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg><span class="gala-visually-hidden">Views</span></dt><dd data-engagement-stat="views" data-stat-label="Views">{{ viewCount if viewCount >= 1000 else '&lt;1K' | safe }}</dd></div>
24
+ </dl>
25
+ <span class="gala-action-rail__separator gala-action-rail__separator--stats" aria-hidden="true"></span>
26
+ <div class="gala-action-group gala-action-group--reactions" role="group" aria-label="React to this article">
27
+ <div class="gala-reaction-actions">
20
28
  {% for reaction in [
21
- { value: 'like', label: 'Like', icon: '♥' },
22
- { value: 'insightful', label: 'Insightful', icon: '✦' },
23
- { value: 'celebrate', label: 'Celebrate', icon: '★' },
24
- { value: 'funny', label: 'Funny', icon: '☺' },
25
- { value: 'mind-blown', label: 'Mind blown', icon: '◉' },
26
- { value: 'thank-you', label: 'Thank you', icon: '✓' }
27
- ] %}<button type="button" data-reaction="{{ reaction.value }}" aria-label="{{ reaction.label }}" title="{{ reaction.label }}" aria-pressed="false"><span aria-hidden="true">{{ reaction.icon }}</span></button>{% endfor %}
29
+ { value: 'like', label: 'Like' },
30
+ { value: 'insightful', label: 'Insightful' },
31
+ { value: 'celebrate', label: 'Celebrate' },
32
+ { value: 'funny', label: 'Funny' },
33
+ { value: 'mind-blown', label: 'Mind blown' },
34
+ { value: 'thank-you', label: 'Thank you' }
35
+ ] %}<button type="button" data-reaction="{{ reaction.value }}" aria-label="{{ reaction.label }}" title="{{ reaction.label }}" aria-pressed="false">{{ reactionIcon(reaction.value) }}</button>{% endfor %}
36
+ </div>
28
37
  </div>
29
- <button type="button" data-follow-article aria-label="Follow article" title="Follow article" aria-pressed="false"><span aria-hidden="true">+</span><span class="gala-visually-hidden" data-follow-label>Follow article</span></button>
30
- <div class="gala-share" aria-label="Share this article">
31
- <button type="button" data-copy-url="{{ canonicalUrl | escape }}" aria-label="Copy link" title="Copy link">Copy</button>
32
- <button type="button" data-native-share="{{ canonicalUrl | escape }}" aria-label="Share" title="Share">Share</button>
33
- <a href="https://x.com/intent/post?url={{ canonicalUrl | urlencode }}" rel="noopener noreferrer" aria-label="Share on X" title="Share on X">X</a>
34
- <a href="https://wa.me/?text={{ canonicalUrl | urlencode }}" rel="noopener noreferrer" aria-label="Share on WhatsApp" title="Share on WhatsApp">WA</a>
38
+ <span class="gala-action-rail__separator" aria-hidden="true"></span>
39
+ <div class="gala-action-group gala-action-group--utilities" role="group" aria-label="Reader utilities">
40
+ <div class="gala-utility-actions">
41
+ <button type="button" data-follow-article aria-label="Follow article" title="Follow article" aria-pressed="false"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/></svg><span class="gala-visually-hidden" data-follow-label>Follow article</span></button>
42
+ <a href="#comments" aria-label="Comments" title="Comments"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z"/></svg></a>
43
+ <div class="gala-share" aria-label="Share this article">
44
+ <button type="button" data-copy-url="{{ canonicalUrl | escape }}" aria-label="Copy link" title="Copy link"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
45
+ <button type="button" data-native-share="{{ canonicalUrl | escape }}" aria-label="Share" title="Share"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4"/></svg></button>
35
46
  <output class="gala-share__status gala-visually-hidden" aria-live="polite"></output>
36
47
  <input class="gala-share__fallback" value="{{ canonicalUrl | escape }}" readonly aria-label="Canonical URL">
48
+ </div>
49
+ </div>
37
50
  </div>
38
51
  </aside>
39
- <div class="gala-conversation">
40
- <header class="gala-conversation__header"><p class="gala-eyebrow">Discussion</p><h2>Continue the conversation</h2></header>
41
- {% 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 %}
42
- <div data-engagement-live></div>
52
+ {% endmacro %}
53
+ {% macro conversation(engagement) %}
54
+ <div id="conversation" class="gala-conversation" aria-label="Article conversation">
55
+ <header class="gala-conversation__header"><h2>Conversation</h2></header>
43
56
  {# Readers see what they can do before they are asked who they are. Signing in is requested
44
57
  at the moment of intent, and the intent is replayed once they are back. #}
45
58
  {# The conversation is an island: it owns its own state, because a thread five deep with a page
46
59
  cursor, an open reply form and an edit in progress is state, and hand-written DOM kept losing
47
60
  track of it. Nothing here is needed by a search engine — comments have always arrived from the
48
61
  API after load — so rendering them on the client costs nothing the article cares about. #}
49
- <div data-gala-comments></div>
50
- <output data-engagement-status aria-live="polite"></output>
62
+ <div id="comments" data-gala-comments></div>
63
+ <output class="gala-conversation__status" data-engagement-status aria-live="polite">Loading comments…</output>
51
64
  </div>
52
- </section>
53
65
  {% endmacro %}
54
66
  {% 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 %}
@@ -29,7 +29,7 @@
29
29
  <link rel="icon" href="{{ '/assets/favicon.svg' | url }}" type="image/svg+xml">
30
30
  <script src="{{ '/assets/reader.js' | url }}" defer></script>
31
31
  </head>
32
- <body>
32
+ <body{% if post %} class="gala-page--article"{% endif %}>
33
33
  <a class="gala-skip-link" href="#main-content">Skip to content</a>
34
34
  <header class="gala-site-header">
35
35
  <a class="gala-site-name" href="{{ '/' | url }}" aria-label="Home">{{ site.site.name }}</a>
@@ -56,7 +56,17 @@
56
56
  </header>
57
57
  <main id="main-content">{{ content | safe }}</main>
58
58
  <footer class="gala-page-footer">
59
- <p>&copy; {{ site.site.name }}</p>
59
+ <section class="gala-page-footer__identity" aria-label="Publication author">
60
+ {% if site.site.authorProfile.avatarUrl %}<img src="{{ site.site.authorProfile.avatarUrl | escape }}" alt="" width="40" height="40">{% endif %}
61
+ <div class="gala-page-footer__details">
62
+ <p class="gala-page-footer__line">
63
+ {% if site.site.authorProfile.profileUrl %}<a class="gala-page-footer__author" href="{{ site.site.authorProfile.profileUrl | escape }}" rel="author">{{ site.site.authorProfile.displayName }}</a>{% else %}<strong class="gala-page-footer__author">{{ site.site.authorProfile.displayName }}</strong>{% endif %}
64
+ {% if site.site.publisher.name %}<span>Published by {% if site.site.publisher.url %}<a href="{{ site.site.publisher.url | escape }}" rel="publisher">{{ site.site.publisher.name }}</a>{% else %}{{ site.site.publisher.name }}{% endif %}</span>{% endif %}
65
+ <span>&copy; {{ site.site.name }}</span>
66
+ </p>
67
+ {% if site.site.authorProfile.bio %}<p class="gala-page-footer__bio">{{ site.site.authorProfile.bio }}</p>{% endif %}
68
+ </div>
69
+ </section>
60
70
  {# Which repository sent the traffic, which footer the click came from, which language it
61
71
  was read in, and which author owns it. Sites registered before these managed values
62
72
  existed fall back rather than emitting the literal placeholder. #}
@@ -64,21 +74,20 @@
64
74
  {% set galaLinkLanguage = post.language or site.site.defaultLanguage %}
65
75
  {% set galaLinkSource = site.site.repository if site.site.repository and site.site.repository != 'unavailable' else 'gala-site' %}
66
76
  {% set galaLinkOwner = site.site.ownerId if site.site.ownerId and site.site.ownerId != 'unavailable' else site.site.id %}
67
- {% if attributionTier != 'PAID' %}<p><a href="https://gala67.com/?utm_source={{ galaLinkSource | urlencode }}&amp;utm_medium=referral&amp;utm_campaign=built-with-gala&amp;utm_content={{ galaLinkPlacement | urlencode }}&amp;utm_term={{ galaLinkLanguage | urlencode }}&amp;utm_id={{ galaLinkOwner | urlencode }}" rel="external">Built with Gala</a></p>{% endif %}
77
+ <nav aria-label="Publication footer"><ul>
78
+ {% if attributionTier != 'PAID' %}<li><a href="https://gala67.com/?utm_source={{ galaLinkSource | urlencode }}&amp;utm_medium=referral&amp;utm_campaign=powered-by-gala&amp;utm_content={{ galaLinkPlacement | urlencode }}&amp;utm_term={{ galaLinkLanguage | urlencode }}&amp;utm_id={{ galaLinkOwner | urlencode }}" rel="external">Powered by Gala</a></li>{% endif %}
79
+ <li><a href="https://app.gala67.com/s/terms" rel="external">Terms</a></li>
80
+ <li><a href="https://app.gala67.com/s/privacy" rel="external">Privacy</a></li>
81
+ </ul></nav>
68
82
  </footer>
69
83
  <dialog id="gala-settings-dialog" class="gala-dialog" aria-labelledby="gala-settings-title">
70
- <form method="dialog" class="gala-dialog__close"><button type="submit" aria-label="Close settings">Close</button></form>
71
- <h2 id="gala-settings-title">Reader settings</h2>
72
- <section aria-labelledby="gala-dialog-language-setting">
73
- <h3 id="gala-dialog-language-setting">Language preference</h3>
74
- <label>Preferred language
75
- <select data-language-preference>{% for language in languages %}<option value="{{ language | escape }}">{{ language }}</option>{% endfor %}</select>
76
- </label>
77
- </section>
84
+ <header class="gala-dialog__header"><h2 id="gala-settings-title">Reader settings</h2><form method="dialog" class="gala-dialog__close"><button type="submit" aria-label="Close settings">Close</button></form></header>
85
+ <label>Preferred language
86
+ <select data-language-preference>{% for language in languages %}<option value="{{ language | escape }}">{{ language }}</option>{% endfor %}</select>
87
+ </label>
78
88
  </dialog>
79
89
  <dialog id="gala-search-dialog" class="gala-dialog" aria-labelledby="gala-search-title">
80
- <form method="dialog" class="gala-dialog__close"><button type="submit" aria-label="Close search">Close</button></form>
81
- <h2 id="gala-search-title">Search</h2>
90
+ <header class="gala-dialog__header"><h2 id="gala-search-title">Search</h2><form method="dialog" class="gala-dialog__close"><button type="submit" aria-label="Close search">Close</button></form></header>
82
91
  <section data-gala-search data-index-url="{{ '/search-index.json' | url }}">
83
92
  <form class="gala-search" action="{{ '/search/' | url }}" role="search">
84
93
  <label for="gala-dialog-search-query">Search published posts</label>
@@ -88,13 +97,12 @@
88
97
  <p data-search-status role="status" aria-live="polite">Enter a search term.</p>
89
98
  <ol class="gala-search-results" data-search-results></ol>
90
99
  </section>
91
- <p><a href="{{ '/search/' | url }}">Open search page</a></p>
92
100
  </dialog>
93
101
  {% if site.site.id and site.site.id != "unavailable" %}
94
102
  <dialog id="gala-account-dialog" class="gala-dialog gala-account-dialog" aria-labelledby="gala-account-title">
95
103
  <form method="dialog" class="gala-dialog__close"><button type="submit" aria-label="Close account">Close</button></form>
96
104
  <h2 id="gala-account-title" class="gala-visually-hidden">Gala account</h2>
97
- <iframe data-gala-session-frame title="Gala account and sign in" allow="identity-credentials-get" src="{{ site.platform.apiBaseUrl or 'https://api.gala67.com' }}/v1/widget/session?siteId={{ site.site.id | escape }}"></iframe>
105
+ <iframe data-gala-session-frame title="Gala account and sign in" loading="lazy" allow="identity-credentials-get" src="{{ site.platform.apiBaseUrl or 'https://api.gala67.com' }}/v1/widget/session?siteId={{ site.site.id | escape }}"></iframe>
98
106
  </dialog>
99
107
  {% endif %}
100
108
  </body>
@@ -1,11 +1,16 @@
1
1
  ---
2
2
  layout: layouts/base.njk
3
3
  ---
4
- {% from "components/ui.njk" import engagementSummary, languageSwitcher, tableOfContents %}
4
+ {% from "components/ui.njk" import actionRail, conversation, languageSwitcher, tableOfContents %}
5
5
  <article id="article" class="gala-article">
6
- {% if post.publicationState == "published" %}<header class="gala-article-header"><p class="gala-eyebrow">Published {{ post.frontmatter.publishAfterDate }} · {{ post.language }}</p><h1>{{ post.frontmatter.title }}</h1><p class="gala-byline">By {{ post.frontmatter.author or site.site.author or site.site.name }}</p></header>{% endif %}
6
+ {% if post.publicationState == "published" %}<progress class="gala-reading-progress" data-reading-progress max="1" value="0" aria-label="Reading progress"></progress><header class="gala-article-header"><p class="gala-eyebrow">Published {{ post.frontmatter.publishAfterDate }} · {{ post.language }} · {{ readingMinutes }} min read</p><h1>{{ post.frontmatter.title }}</h1>{% set byline = post.frontmatter.author or site.site.author %}{% if byline and byline != "unavailable" and byline != "New Gala Site" and byline != site.site.name %}<p class="gala-byline">By {{ byline }}</p>{% endif %}</header>{% endif %}
7
7
  {% if post.publicationState == "published" %}{{ languageSwitcher(hreflangLinks, post.language) }}{% endif %}
8
- {% if postTableOfContents.length %}{{ tableOfContents(postTableOfContents) }}{% endif %}
9
- <div class="gala-markdown">{{ content | safe }}</div>
10
- {% if post.publicationState == "published" %}{{ engagementSummary(post.id, post.canonicalUrl, engagement, site.platform.apiBaseUrl or "https://api.gala67.com") }}{% endif %}
8
+ {% if post.publicationState == "published" %}<section class="gala-engagement" data-article-id="{{ post.id | escape }}" data-engagement-url="{{ site.platform.apiBaseUrl or 'https://api.gala67.com' }}/v1/articles/{{ post.id | escape }}/engagement">{% endif %}
9
+ <div class="gala-reading-layout">
10
+ {% if postTableOfContents.length %}{{ tableOfContents(postTableOfContents) }}{% else %}<span></span>{% endif %}
11
+ <div class="gala-markdown">{{ content | safe }}</div>
12
+ {% if post.publicationState == "published" %}<div class="gala-article-boundary"><strong>Conversation</strong></div><span class="gala-action-dock" data-action-dock aria-hidden="true"></span>{{ actionRail(post.canonicalUrl, engagement) }}{% endif %}
13
+ </div>
14
+ {% if post.publicationState == "published" %}{{ conversation(engagement) }}{% endif %}
15
+ {% if post.publicationState == "published" %}</section>{% endif %}
11
16
  </article>