@rathnasgala/theme 0.0.6 → 0.0.9

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.6",
3
+ "version": "0.0.9",
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.6",
5
+ "version": "0.0.9",
6
6
  "availableDesignThemes": [
7
7
  "editorial"
8
8
  ],
@@ -11,20 +11,21 @@
11
11
  "files": {
12
12
  ".gitignore": "b5fa24e10155097a0d372926fd2e59e6072e7cc2cb6399bbfb4beb8b33ea315b",
13
13
  ".gala/publish.yml.template": "73080241d89bd09964b797a8bcc8e18471412e11bc873efad76305b60f497d08",
14
- "eleventy.config.js": "24f25bd69108f17c80d85807f1bbf6851a44b77dfdddc8d8e234b85462cb2e04",
14
+ "eleventy.config.js": "efe587447e3dc374a220c7fa037fdf2fffc02d09ba43a4d2b96d6c6aae0e3325",
15
15
  "package.json": "cccea6fc4edf4b4b653bd5cfc0901ab20dd92ac8975408fb01ae3670005da641",
16
16
  "package-lock.json": "c97a6a2f794aef083a39a8d2628eccc4a3c484e1c6225899b77ec1fb7c466d9e",
17
17
  "lib/build-manifest.js": "88949f0112d683ca809bb67ca969e15634792423559a67bc79a534cd807e5535",
18
- "lib/site-config.js": "59579cc3253529c7201002ab40435eeaeb4359ca82ddbb473a026bc317f62fcb",
18
+ "lib/site-config.js": "ab7cc170dc30c74ea5ef08b8ff6a82f9820998ae4190a06d64375dfd7d515864",
19
19
  "lib/performance-budget.js": "124c43494c092a956accec46d1463ea063ed73363d0db01f24aaea987459d683",
20
20
  "lib/engagement-snapshot.js": "298c11b1ab135a3a98a4e31b6320ab60dfd7fda6a9d9053df37d86282a5a6687",
21
21
  "lib/publication-state.js": "1b59c29a18bbb1193bddea898dac3a4fb832c7bc4751263be086f3d64392dea0",
22
- "lib/render-markdown.js": "c7dac7e3599af445830825e76e285166e85835163caa8bef2c3a970a307ac750",
22
+ "lib/render-markdown.js": "40e6d6743b76de6c16d138475f188a4263967530c370d8b26a363f4df58c2caf",
23
23
  "lib/seo.js": "107de8804931945b8e73894877fc394d6b535a9d0d969a4ba5b68f2e136e0a17",
24
- "lib/share-targets.js": "5820466526ab2dcd2cd1689c163117c34df0a675dfa92e28e853f690423299b3",
25
- "lib/provider-fixtures/share-intents.v1.json": "2535cee63962f38691cb7b8c47d2414bf9cacbcee5cadba0bec0172e7b3961e4",
24
+ "lib/share-targets.js": "458aff86fab2b62a364442cd8c612565c6e0b53eef2f20cb6237cc562b1e0731",
25
+ "lib/provider-fixtures/embeds.v1.json": "5f26909fb433135321ddf7fdd14e45aeb702805c88f5676941166c5735143ee2",
26
+ "lib/provider-fixtures/share-intents.v1.json": "66a8eedfcff711253ee45ceb94b53f7d5a6be3ddbbb3d1f0d4e31a114326cc0a",
26
27
  "src/404.njk": "be0d44370ac5402156aaf3d32ea581057bc61ec94c1a5f92bb809733af7a8a20",
27
- "src/_includes/layouts/base.njk": "47ad79e1ce9520caacb4e564f3257335efc8889c242b2064ce2ed27c98d2cfcc",
28
+ "src/_includes/layouts/base.njk": "a37a0dfe534412bcade43719b2e2d6338463110105f84f3887e21ecc27e44b6c",
28
29
  "src/_includes/layouts/post.njk": "1accd2832064ad19ee7bf18daf5fa1e60569f32c9b7a547934a6361f747d7eb7",
29
30
  "src/_includes/components/ui.njk": "3a0eeb0f7b8f0ea46ed39d4c53b810a33b997766cbae540ef063c8ecdf641809",
30
31
  "src/_data/buildManifest.js": "628163305984e0d3537e27b0d4050f5ba5ead12a834878266d2ce8004ffe1b1e",
@@ -33,14 +34,19 @@
33
34
  "src/_data/languages.js": "1358748f7fa2f74406d3295a030827d6229a53e94da72cf712e719d7a356aeac",
34
35
  "src/_data/site.js": "d9c001f4cc12a34027dd92303530b11b5402d47613459bb997e22c4bcbea326d",
35
36
  "src/assets/theme-mode.js": "3797c84dbaa090b5b21427a31928b5dd97f13d04d99833f5e1e74c6b50e01dcc",
36
- "src/assets/interactions.js": "611e54376f03d9cd8abd55ef4fcbebe917a174c673dd15fcb3c4073cc6e79836",
37
+ "src/assets/interactions.js": "7b9db09494518813189c17e92e5b9621b8b17a455c895d8670452fdfa0ead557",
38
+ "src/assets/embed-codepen.svg": "e9def697f38c466fb2b6652535c4c814e8cbbbefc6e052a69f335aeb27e8356b",
39
+ "src/assets/embed-gist.svg": "fcd9bc2bf29a5c118492b853c0a01390b7539c38ddb985da3c2a6e2f246a39f7",
40
+ "src/assets/embed-x.svg": "0ae74edc6790ec7e933067fe5392bce756e93fa7ad71caacf1c1ca99fa4ec9b6",
41
+ "src/assets/embed-youtube.svg": "10d192f2b14b67664fea88c8cf246321d20190def51d59fad5762e552219b067",
37
42
  "src/assets/preferences.js": "789127bee013ed6a1bbf8cefd7b9109291c16220e7a6e09314e3c93c54731d1d",
38
43
  "src/assets/search.js": "df425f0239277792ad39f2138bc89cb66cc4ecda5ed59042d46abc2f05372088",
39
- "src/assets/theme.css": "8cad6e82e0017e7b62a5355f0386d854fb62e774c520b3ce25aaea5b19b4fa61",
44
+ "src/assets/theme.css": "66f4793e857af61cbee63d847418aac03ce948361025d78853ec4d05e0117f43",
45
+ "src/contact.njk": "f4d74d038781e15af0acef571901c91e7753a2e21b6bef617429585698839a34",
40
46
  "src/index.njk": "5c313f0fcdad3ddbfb79376bc9671b3f37539ef70c14bdbbd26d6e2e6de250e8",
41
47
  "src/feed.11ty.js": "009bd2384e08e02a9405683bab1653bab3aeaec16accb0fd01ba69b24c777da9",
42
48
  "src/languages.11ty.js": "399559199c65570d7d5efd88e20254838e316f81c06d7b28ec34990e963db915",
43
- "src/posts.11ty.js": "2f9d738075eab68961d7c8fe9286b61f12c2c677f7dad9d4858927ef51b66ff8",
49
+ "src/posts.11ty.js": "89c0e4742a67cd03f9fd13c8c8d4ca56375d97628b3cb439af7f9c92c240104a",
44
50
  "src/redirects.11ty.js": "3c163246a59f5364861db6a6f05ae88afc938a5379d86cf42776d8d49b0031f3",
45
51
  "src/search-index.11ty.js": "700dc2a2be0ef20de3a828a4612fcf7a082691eade29c6e1d75f09c24b6c6024",
46
52
  "src/search.njk": "63f2e8aeebb63c90da2e375eece639ce096cad1eff7f28628cafc46faf7ceee6",
@@ -23,6 +23,8 @@ async function verifiedMediaSource(postSource, mediaSource) {
23
23
 
24
24
  export default async function (eleventyConfig) {
25
25
  const [manifest, site] = await Promise.all([readBuildManifest(), loadSiteConfiguration()]);
26
+ const attributionTier = process.env.GALA_ATTRIBUTION_TIER === 'PAID' ? 'PAID' : 'FREE';
27
+ eleventyConfig.addGlobalData('attributionTier', attributionTier);
26
28
  eleventyConfig.setLibrary('md', markdownLibrary);
27
29
  eleventyConfig.addPassthroughCopy({ static: '/' });
28
30
  eleventyConfig.addPassthroughCopy({ 'src/assets': 'assets' });
@@ -0,0 +1,48 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "providers": [
4
+ {
5
+ "id": "youtube",
6
+ "source": "https://support.google.com/youtube/answer/171780",
7
+ "verifiedOn": "2026-08-14",
8
+ "status": "verified-iframe",
9
+ "patterns": [
10
+ "^https://(?:www\\.)?youtube\\.com/watch\\?v=([A-Za-z0-9_-]{11})(?:&[^\\s]*)?$",
11
+ "^https://youtu\\.be/([A-Za-z0-9_-]{11})(?:\\?[^\\s]*)?$"
12
+ ],
13
+ "activationTemplate": "https://www.youtube-nocookie.com/embed/{1}",
14
+ "label": "Watch on YouTube"
15
+ },
16
+ {
17
+ "id": "codepen",
18
+ "source": "https://blog.codepen.io/documentation/embedded-pens/",
19
+ "verifiedOn": "2026-08-14",
20
+ "status": "verified-iframe",
21
+ "patterns": [
22
+ "^https://codepen\\.io/([A-Za-z0-9_-]+)/pen/([A-Za-z0-9_-]+)/?$"
23
+ ],
24
+ "activationTemplate": "https://codepen.io/{1}/embed/{2}",
25
+ "label": "Run on CodePen"
26
+ },
27
+ {
28
+ "id": "gist",
29
+ "source": "https://docs.github.com/en/get-started/writing-on-github/editing-and-sharing-content-with-gists/creating-gists",
30
+ "verifiedOn": "2026-08-14",
31
+ "status": "verified-script-only",
32
+ "patterns": [
33
+ "^https://gist\\.github\\.com/([A-Za-z0-9_-]+)/([0-9a-fA-F]+)(?:#[A-Za-z0-9_-]+)?$"
34
+ ],
35
+ "labelTemplate": "View Gist by {1}"
36
+ },
37
+ {
38
+ "id": "x",
39
+ "source": "https://help.x.com/en/using-x/how-to-embed-a-post",
40
+ "verifiedOn": "2026-08-14",
41
+ "status": "verified-script-only",
42
+ "patterns": [
43
+ "^https://x\\.com/([A-Za-z0-9_]{1,15})/status/([0-9]+)$"
44
+ ],
45
+ "label": "View post on X"
46
+ }
47
+ ]
48
+ }
@@ -3,10 +3,10 @@
3
3
  "providers": {
4
4
  "bluesky": { "source": "https://docs.bsky.app/docs/advanced-guides/intent-links", "verifiedOn": "2026-08-13", "status": "verified", "template": "https://bsky.app/intent/compose?text={text}" },
5
5
  "x": { "source": "https://developer.x.com/en/docs/twitter-for-websites/tweet-button/guides/web-intent", "verifiedOn": "2026-08-13", "status": "verified", "template": "https://twitter.com/intent/tweet?text={text}&url={url}" },
6
- "linkedin": { "source": "https://www.linkedin.com/help/linkedin/answer/a522735", "status": "blocked-public-intent-contract-unverified" },
7
- "reddit": { "source": "https://www.reddit.com/dev/api/#POST_api_submit", "status": "blocked-public-intent-contract-unverified" },
6
+ "linkedin": { "source": "https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/plugins/share-plugin", "verifiedOn": "2026-08-14", "status": "verified", "template": "https://www.linkedin.com/sharing/share-offsite/?url={url}", "verification": "live redirect retained the encoded target URL" },
7
+ "reddit": { "source": "https://www.reddit.com/r/bugs/comments/1cobgwm/simple_reddit_share_links_on_external_sites_no/", "checkedOn": "2026-08-14", "status": "blocked-public-intent-contract-unverified", "reason": "the public submit URL loads, but desktop title and URL prefill are not a stable provider contract" },
8
8
  "hacker-news": { "source": "https://news.ycombinator.com/bookmarklet.html", "verifiedOn": "2026-08-13", "status": "verified", "template": "https://news.ycombinator.com/submitlink?u={url}&t={title}" },
9
- "whatsapp": { "source": "https://faq.whatsapp.com/5913398998672934", "status": "blocked-public-intent-contract-unverified" },
9
+ "whatsapp": { "source": "https://faq.whatsapp.com/5913398998672934", "verifiedOn": "2026-08-14", "status": "verified", "template": "https://wa.me/?text={text}", "verification": "live redirect retained the encoded message text" },
10
10
  "email": { "source": "https://www.rfc-editor.org/rfc/rfc6068", "verifiedOn": "2026-08-13", "status": "verified", "template": "mailto:?subject={title}&body={text}" },
11
11
  "mastodon": { "source": "https://docs.joinmastodon.org/user/external/#share", "verifiedOn": "2026-08-13", "status": "verified", "template": "https://{instance}/share?text={text}", "requiresInstance": true }
12
12
  }
@@ -4,6 +4,12 @@ import sanitizeHtml from 'sanitize-html';
4
4
  import { createHighlighter } from 'shiki';
5
5
  import GithubSlugger from 'github-slugger';
6
6
  import { randomBytes } from 'node:crypto';
7
+ import { readFileSync } from 'node:fs';
8
+
9
+ const embedFixture = JSON.parse(readFileSync(
10
+ new URL('./provider-fixtures/embeds.v1.json', import.meta.url),
11
+ 'utf8'
12
+ ));
7
13
 
8
14
  const SHIKI_THEMES = Object.freeze({ light: 'github-light', dark: 'github-dark' });
9
15
  const SHIKI_LANGUAGES = Object.freeze([
@@ -67,10 +73,115 @@ function headingAnchors(markdown) {
67
73
  });
68
74
  }
69
75
 
76
+ function fixtureEmbed(url, environment) {
77
+ for (const provider of embedFixture.providers) {
78
+ for (const pattern of provider.patterns) {
79
+ const match = new RegExp(pattern).exec(url);
80
+ if (!match) continue;
81
+ const replaceCaptures = (template) => template.replace(/\{([1-9])}/g,
82
+ (_placeholder, index) => match[Number(index)] ?? '');
83
+ return {
84
+ provider: provider.id,
85
+ source: url,
86
+ label: provider.label ?? replaceCaptures(provider.labelTemplate),
87
+ activation: provider.status === 'verified-iframe'
88
+ ? replaceCaptures(provider.activationTemplate)
89
+ : null
90
+ };
91
+ }
92
+ }
93
+ environment.galaWarnings ??= [];
94
+ environment.galaWarnings.push(`Unsupported embed provider: ${url}`);
95
+ return null;
96
+ }
97
+
98
+ function safeEmbedLink(url) {
99
+ try {
100
+ const parsed = new URL(url);
101
+ return parsed.protocol === 'https:' && parsed.username === '' && parsed.password === '';
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ function embedFacade(embed) {
108
+ const provider = markdown.utils.escapeHtml(embed.provider);
109
+ const source = markdown.utils.escapeHtml(embed.source);
110
+ const label = markdown.utils.escapeHtml(embed.label);
111
+ if (embed.activation == null) {
112
+ return `<figure class="gala-embed gala-embed--${provider}"><span class="gala-embed__icon" aria-hidden="true"></span><a href="${source}" rel="noopener noreferrer">${label}</a></figure>`;
113
+ }
114
+ const activation = markdown.utils.escapeHtml(embed.activation);
115
+ return `<figure class="gala-embed gala-embed--${provider}" data-gala-embed="${provider}"><span class="gala-embed__icon" aria-hidden="true"></span><button type="button" data-gala-embed-load="${provider}" data-gala-embed-src="${activation}">${label}</button><figcaption><a href="${source}" rel="noopener noreferrer">Open directly on ${provider === 'youtube' ? 'YouTube' : 'CodePen'}</a></figcaption></figure>`;
116
+ }
117
+
118
+ function embeds(markdown) {
119
+ markdown.block.ruler.before('paragraph', 'gala_embed', (state, startLine, _endLine, silent) => {
120
+ const start = state.bMarks[startLine] + state.tShift[startLine];
121
+ const end = state.eMarks[startLine];
122
+ const match = state.src.slice(start, end).match(/^\{%\s+embed\s+(https:\/\/\S+)\s+%}$/);
123
+ if (!match) return false;
124
+ if (silent) return true;
125
+ const resolved = fixtureEmbed(match[1], state.env);
126
+ if (resolved == null) {
127
+ const token = state.push('paragraph_open', 'p', 1);
128
+ token.map = [startLine, startLine + 1];
129
+ const inline = state.push('inline', '', 0);
130
+ inline.content = safeEmbedLink(match[1])
131
+ ? `[${match[1]}](${match[1]})`
132
+ : `\`${match[1].replaceAll('`', '\\`')}\``;
133
+ inline.children = [];
134
+ state.push('paragraph_close', 'p', -1);
135
+ } else {
136
+ const placeholder = randomBytes(18).toString('hex');
137
+ state.env.galaEmbeds ??= new Map();
138
+ state.env.galaEmbeds.set(placeholder, embedFacade(resolved));
139
+ const token = state.push('html_block', '', 0);
140
+ token.content = `<div class="gala-embed-placeholder" data-embed-token="${placeholder}"></div>`;
141
+ token.map = [startLine, startLine + 1];
142
+ }
143
+ state.line = startLine + 1;
144
+ return true;
145
+ });
146
+ }
147
+
148
+ function rejectRawHtmlMedia(markdown) {
149
+ markdown.core.ruler.after('inline', 'gala_reject_raw_html_media', (state) => {
150
+ const media = /<(?:img|audio|video|source|picture)\b/i;
151
+ for (const token of state.tokens) {
152
+ if ((token.type === 'html_block' || token.type === 'html_inline') && media.test(token.content)) {
153
+ throw new TypeError('Raw HTML media is not allowed; use Markdown image syntax');
154
+ }
155
+ for (const child of token.children ?? []) {
156
+ if (child.type === 'html_inline' && media.test(child.content)) {
157
+ throw new TypeError('Raw HTML media is not allowed; use Markdown image syntax');
158
+ }
159
+ }
160
+ }
161
+ });
162
+ }
163
+
70
164
  const markdown = new MarkdownIt({ html: true, linkify: true, typographer: false })
71
165
  .use(footnote)
72
166
  .use(admonitions)
73
- .use(headingAnchors);
167
+ .use(headingAnchors)
168
+ .use(embeds)
169
+ .use(rejectRawHtmlMedia);
170
+
171
+ const defaultImage = markdown.renderer.rules.image
172
+ ?? ((tokens, index, options, _environment, self) => self.renderToken(tokens, index, options));
173
+ markdown.renderer.rules.image = (tokens, index, options, environment, self) => {
174
+ const token = randomBytes(18).toString('hex');
175
+ const image = defaultImage(tokens, index, options, environment, self);
176
+ environment.galaImages ??= new Map();
177
+ environment.galaImages.set(token, sanitizeHtml(image, {
178
+ allowedTags: ['img'],
179
+ allowedAttributes: { img: ['src', 'alt', 'title'] },
180
+ allowedSchemesByTag: { img: ['https'] },
181
+ allowProtocolRelative: false
182
+ }));
183
+ return `<span class="gala-image-placeholder" data-image-token="${token}"></span>`;
184
+ };
74
185
 
75
186
  const defaultFence = markdown.renderer.rules.fence.bind(markdown.renderer.rules);
76
187
  markdown.renderer.rules.fence = (tokens, index, options, environment, self) => {
@@ -93,7 +204,7 @@ markdown.renderer.rules.fence = (tokens, index, options, environment, self) => {
93
204
  const sanitizeOptions = {
94
205
  allowedTags: [
95
206
  'a', 'abbr', 'aside', 'blockquote', 'br', 'code', 'del', 'div', 'em',
96
- 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img',
207
+ 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr',
97
208
  'li', 'mark', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table',
98
209
  'tbody', 'td', 'th', 'thead', 'tr', 'ul'
99
210
  ],
@@ -101,17 +212,16 @@ const sanitizeOptions = {
101
212
  a: ['href', 'title', 'rel'],
102
213
  aside: ['class', 'role'],
103
214
  code: ['class'],
104
- div: ['class', 'id', 'data-highlight-token'],
215
+ div: ['class', 'id', 'data-highlight-token', 'data-embed-token'],
105
216
  h1: ['id'],
106
217
  h2: ['id'],
107
218
  h3: ['id'],
108
219
  h4: ['id'],
109
220
  h5: ['id'],
110
221
  h6: ['id'],
111
- img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'decoding'],
112
222
  li: ['id'],
113
223
  ol: ['class'],
114
- span: ['class'],
224
+ span: ['class', 'data-image-token'],
115
225
  sup: ['class'],
116
226
  td: ['colspan', 'rowspan'],
117
227
  th: ['colspan', 'rowspan', 'scope']
@@ -121,17 +231,14 @@ const sanitizeOptions = {
121
231
  'admonition', 'admonition-note', 'admonition-tip', 'admonition-important',
122
232
  'admonition-warning', 'admonition-caution'
123
233
  ],
124
- div: ['gala-highlight-placeholder'],
234
+ div: ['gala-highlight-placeholder', 'gala-embed-placeholder'],
125
235
  code: [/^language-[a-z0-9_-]+$/],
126
236
  ol: ['footnotes-list'],
127
- span: ['footnote-backref'],
237
+ span: ['footnote-backref', 'gala-image-placeholder'],
128
238
  sup: ['footnote-ref']
129
239
  },
130
240
  allowedSchemes: ['https', 'mailto'],
131
- allowedSchemesByTag: {
132
- a: ['https', 'mailto'],
133
- img: ['https']
134
- },
241
+ allowedSchemesByTag: { a: ['https', 'mailto'] },
135
242
  allowProtocolRelative: false,
136
243
  enforceHtmlBoundary: true
137
244
  };
@@ -140,6 +247,9 @@ function render(source, inline, suppliedEnvironment) {
140
247
  const environment = suppliedEnvironment ?? {};
141
248
  environment.galaHighlights = [];
142
249
  environment.galaHighlightTokens = [];
250
+ environment.galaEmbeds = new Map();
251
+ environment.galaImages = new Map();
252
+ environment.galaWarnings = [];
143
253
  const unsafe = inline
144
254
  ? markdown.renderInline(source, environment)
145
255
  : markdown.render(source, environment);
@@ -150,6 +260,18 @@ function render(source, inline, suppliedEnvironment) {
150
260
  environment.galaHighlights[index]
151
261
  );
152
262
  }
263
+ for (const [token, facade] of environment.galaEmbeds) {
264
+ safe = safe.replace(
265
+ `<div class="gala-embed-placeholder" data-embed-token="${token}"></div>`,
266
+ facade
267
+ );
268
+ }
269
+ for (const [token, image] of environment.galaImages) {
270
+ safe = safe.replace(
271
+ `<span class="gala-image-placeholder" data-image-token="${token}"></span>`,
272
+ image
273
+ );
274
+ }
153
275
  return safe;
154
276
  }
155
277
 
@@ -162,7 +284,11 @@ export function renderMarkdownDocument(source) {
162
284
  if (typeof source !== 'string') throw new TypeError('Markdown source must be a string');
163
285
  const environment = {};
164
286
  const html = render(source, false, environment);
165
- return Object.freeze({ html, tableOfContents: Object.freeze(environment.galaToc ?? []) });
287
+ return Object.freeze({
288
+ html,
289
+ tableOfContents: Object.freeze(environment.galaToc ?? []),
290
+ warnings: Object.freeze(environment.galaWarnings ?? [])
291
+ });
166
292
  }
167
293
 
168
294
  export const markdownLibrary = Object.assign(Object.create(markdown), {
@@ -8,6 +8,8 @@ const fixture = JSON.parse(readFileSync(
8
8
  const LABELS = Object.freeze({
9
9
  bluesky: 'Bluesky',
10
10
  x: 'X',
11
+ linkedin: 'LinkedIn',
12
+ whatsapp: 'WhatsApp',
11
13
  'hacker-news': 'Hacker News',
12
14
  email: 'Email',
13
15
  mastodon: 'Mastodon'
@@ -30,7 +32,7 @@ function resolveProvider(provider, title, canonical, socialProfiles) {
30
32
  const profile = requireHttpsUrl(socialProfiles.mastodon, 'sharing.socialProfiles.mastodon');
31
33
  instance = profile.host;
32
34
  }
33
- const text = provider === 'bluesky' || provider === 'mastodon'
35
+ const text = provider === 'bluesky' || provider === 'mastodon' || provider === 'whatsapp'
34
36
  ? `${title}\n${canonical.href}`
35
37
  : provider === 'email' ? canonical.href : title;
36
38
  const replacements = {
@@ -7,6 +7,22 @@ const BUDGET_KEYS = Object.freeze([
7
7
  'managedCssBytes',
8
8
  'ordinaryHtmlBytes'
9
9
  ]);
10
+ const DESIGN_VALUES = Object.freeze({
11
+ layout: Object.freeze(['article-first', 'portfolio']),
12
+ palette: Object.freeze(['default', 'ocean'])
13
+ });
14
+ const CONTACT_KEYS = Object.freeze([
15
+ 'enabled', 'websiteEnabled', 'phoneEnabled'
16
+ ]);
17
+
18
+ function validateImplementedDesign(config) {
19
+ if (config?.design == null) return;
20
+ 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]}`);
23
+ }
24
+ }
25
+ }
10
26
 
11
27
  export function validatePerformanceBudgets(value) {
12
28
  if (value == null || Array.isArray(value) || typeof value !== 'object') {
@@ -40,6 +56,23 @@ export function validateStatistics(value) {
40
56
  return Object.freeze({ publicViewCounts: value.publicViewCounts === true });
41
57
  }
42
58
 
59
+ export function validateContact(value) {
60
+ if (value == null) return Object.freeze({ enabled: false, websiteEnabled: false, phoneEnabled: false });
61
+ if (Array.isArray(value) || typeof value !== 'object') throw new TypeError('contact must be a mapping');
62
+ const unknown = Object.keys(value).filter((key) => !CONTACT_KEYS.includes(key));
63
+ if (unknown.length > 0) throw new TypeError(`Unsupported contact option: ${unknown.join(', ')}`);
64
+ for (const field of ['enabled', 'websiteEnabled', 'phoneEnabled']) {
65
+ if (value[field] != null && typeof value[field] !== 'boolean') {
66
+ throw new TypeError(`contact.${field} must be a boolean`);
67
+ }
68
+ }
69
+ return Object.freeze({
70
+ enabled: value.enabled === true,
71
+ websiteEnabled: value.websiteEnabled === true,
72
+ phoneEnabled: value.phoneEnabled === true
73
+ });
74
+ }
75
+
43
76
  export async function loadSiteConfiguration({
44
77
  root = process.cwd(),
45
78
  configPath = process.env.GALA_CONFIG_PATH ?? 'site.config.yml'
@@ -58,7 +91,9 @@ export async function loadSiteConfiguration({
58
91
  throw new TypeError('site configuration must be a regular file');
59
92
  }
60
93
  const config = parse(await readFile(file, 'utf8'));
94
+ validateImplementedDesign(config);
61
95
  validatePerformanceBudgets(config?.performance?.budgets);
62
96
  config.statistics = validateStatistics(config.statistics);
97
+ config.contact = validateContact(config.contact);
63
98
  return config;
64
99
  }
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
- <html lang="{{ post.language or site.site.defaultLanguage }}" data-theme="{{ site.design.theme }}" 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-mode="system">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -35,6 +35,9 @@
35
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
36
  </a>
37
37
  <nav class="gala-site-header__actions" aria-label="Site controls">
38
+ {% 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
+ </a>{% endif %}
38
41
  <button class="gala-icon-control" type="button" data-theme-mode-toggle aria-label="Color mode: system. Activate for light." title="Appearance">
39
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>
40
43
  <span class="gala-visually-hidden" data-theme-mode-label>Theme: system</span>
@@ -51,6 +54,10 @@
51
54
  </nav>
52
55
  </header>
53
56
  <main id="main-content">{{ content | safe }}</main>
57
+ <footer class="gala-page-footer">
58
+ <p>&copy; {{ site.site.name }}</p>
59
+ {% if attributionTier != 'PAID' %}<p><a href="https://gala67.com/?utm_source=gala-site&amp;utm_medium=referral" rel="external">Built with Gala</a></p>{% endif %}
60
+ </footer>
54
61
  <dialog id="gala-settings-dialog" class="gala-dialog" aria-labelledby="gala-settings-title">
55
62
  <form method="dialog" class="gala-dialog__close"><button type="submit" aria-label="Close settings">Close</button></form>
56
63
  <h2 id="gala-settings-title">Reader settings</h2>
@@ -84,7 +91,7 @@
84
91
  <dialog id="gala-account-dialog" class="gala-dialog gala-account-dialog" aria-labelledby="gala-account-title">
85
92
  <form method="dialog" class="gala-dialog__close"><button type="submit" aria-label="Close account">Close</button></form>
86
93
  <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>
94
+ <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>
88
95
  </dialog>
89
96
  {% endif %}
90
97
  </body>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="CodePen"><path d="m12 1 10 6.5v9L12 23 2 16.5v-9zm0 3.4L6.2 8.2 12 12l5.8-3.8zm-7 6.2v3l4.5-1.5zm14 0-4.5 1.5 4.5 1.5zM6.2 15.8l5.8 3.8 5.8-3.8-5.8-3.7z" fill="currentColor"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="GitHub Gist"><path d="M7 4 2 12l5 8h3l-5-8 5-8zm10 0-5 8 5 8h3l-5-8 5-8z" fill="currentColor"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="X"><path d="M3 2h5.4l4.7 6.3L18.5 2H21l-6.7 8.2L22 22h-5.4l-5.2-7L5.5 22H3l7.2-8.9zm4.1 2 10.5 16h1.3L8.4 4z" fill="currentColor"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="YouTube"><rect x="1" y="4" width="22" height="16" rx="4" fill="#f00"/><path d="m10 8 6 4-6 4z" fill="#fff"/></svg>
@@ -8,6 +8,26 @@ function selectableFallback(control) {
8
8
  }
9
9
 
10
10
  document.addEventListener('click', async (event) => {
11
+ const embed = event.target.closest('[data-gala-embed-load]');
12
+ if (embed) {
13
+ const container = embed.closest('[data-gala-embed]');
14
+ const source = embed.dataset.galaEmbedSrc;
15
+ const provider = embed.dataset.galaEmbedLoad;
16
+ if (!container || !source || !['youtube', 'codepen'].includes(provider)) return;
17
+ const frame = document.createElement('iframe');
18
+ frame.src = source;
19
+ frame.title = provider === 'youtube' ? 'YouTube video' : 'CodePen example';
20
+ frame.loading = 'eager';
21
+ frame.referrerPolicy = 'strict-origin-when-cross-origin';
22
+ frame.setAttribute('sandbox', 'allow-forms allow-popups allow-presentation allow-same-origin allow-scripts');
23
+ frame.setAttribute('allow', provider === 'youtube'
24
+ ? 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; web-share'
25
+ : 'fullscreen');
26
+ frame.setAttribute('allowfullscreen', '');
27
+ container.replaceChildren(frame);
28
+ return;
29
+ }
30
+
11
31
  const dialogControl = event.target.closest('[data-open-dialog]');
12
32
  if (dialogControl) {
13
33
  const dialog = document.getElementById(dialogControl.dataset.openDialog);
@@ -374,8 +394,9 @@ function engagementErrorMessage(code) {
374
394
  if (code === 'AUTHENTICATION_REQUIRED' || code === 'INVALID_BEARER_TOKEN'
375
395
  || code === 'REAUTHENTICATION_REQUIRED') return 'Sign in again to continue.';
376
396
  if (code === 'ENGAGEMENT_RATE_LIMITED') return 'You are posting too quickly; try again shortly.';
397
+ if (code === 'CONTACT_RATE_LIMITED') return 'You are sending too quickly; try again later.';
377
398
  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.';
399
+ if (code === 'INVALID_ENGAGEMENT_WRITE' || code === 'INVALID_CONTACT_SUBMISSION' || code === 'INVALID_REQUEST') return 'Check your entry and try again.';
379
400
  if (code === 'ACCESS_DENIED') return 'That action is not available for this account.';
380
401
  if (code === 'IDEMPOTENCY_CONFLICT' || code === 'ENGAGEMENT_STATE_CONFLICT') return 'The item changed; reload and try again.';
381
402
  return 'The action could not be completed. Try again.';
@@ -392,6 +413,33 @@ function sendEngagementWrite(operation, payload) {
392
413
  });
393
414
  }
394
415
 
416
+ document.addEventListener('submit', async (event) => {
417
+ const form = event.target.closest('[data-contact-form] form');
418
+ if (!form) return;
419
+ event.preventDefault();
420
+ const region = form.closest('[data-contact-form]');
421
+ const status = region.querySelector('[data-contact-status]');
422
+ if (!sessionUser) {
423
+ status.textContent = 'Sign in with the account button before sending.';
424
+ return;
425
+ }
426
+ const data = new FormData(form);
427
+ try {
428
+ status.textContent = 'Sending…';
429
+ await sendEngagementWrite('contact.submit', {
430
+ siteId: region.dataset.siteId,
431
+ subject: data.get('subject'),
432
+ message: data.get('message'),
433
+ website: data.get('website') || null,
434
+ phone: data.get('phone') || null
435
+ });
436
+ form.reset();
437
+ status.textContent = 'Message sent.';
438
+ } catch (error) {
439
+ status.textContent = engagementErrorMessage(error.message);
440
+ }
441
+ });
442
+
395
443
  async function mutateEngagement(region, operation, payload) {
396
444
  const status = region.querySelector('[data-engagement-status]');
397
445
  try {
@@ -30,6 +30,32 @@
30
30
  --gala-color-border: #aaa;
31
31
  }
32
32
 
33
+ :root[data-palette='ocean'] {
34
+ --gala-color-background: light-dark(#f7fbff, #071b2d);
35
+ --gala-color-text: light-dark(#102a43, #e8f4ff);
36
+ --gala-color-surface: light-dark(#d9eaf7, #102f49);
37
+ --gala-color-accent: light-dark(#005ea8, #78c8ff);
38
+ --gala-color-border: light-dark(#526d82, #8aa8bf);
39
+ }
40
+
41
+ :root[data-palette='ocean'][data-mode='light'] {
42
+ --gala-color-background: #f7fbff;
43
+ --gala-color-text: #102a43;
44
+ --gala-color-surface: #d9eaf7;
45
+ --gala-color-accent: #005ea8;
46
+ --gala-color-border: #526d82;
47
+ }
48
+
49
+ :root[data-palette='ocean'][data-mode='dark'] {
50
+ --gala-color-background: #071b2d;
51
+ --gala-color-text: #e8f4ff;
52
+ --gala-color-surface: #102f49;
53
+ --gala-color-accent: #78c8ff;
54
+ --gala-color-border: #8aa8bf;
55
+ }
56
+
57
+ :root[data-layout='portfolio'] { --gala-content-width: 82rem; }
58
+
33
59
  body {
34
60
  margin: 0;
35
61
  padding: var(--gala-space-page);
@@ -126,6 +152,8 @@ button:focus-visible {
126
152
  .gala-page-footer ul,
127
153
  .gala-share ul { display: flex; flex-wrap: wrap; gap: 1rem; padding: 0; list-style: none; }
128
154
  .gala-card-index { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); gap: 1rem; padding: 0; list-style: none; }
155
+ [data-layout='portfolio'] .gala-card-index { grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); gap: 1.5rem; }
156
+ [data-layout='portfolio'] .gala-card { padding: clamp(1.25rem, 3vw, 2rem); }
129
157
  .gala-card { block-size: 100%; box-sizing: border-box; padding: 1.25rem; background: var(--gala-color-surface); border-radius: var(--gala-radius-control); }
130
158
  .gala-stats-graph { min-block-size: var(--gala-widget-min-block-size); margin-inline: 0; }
131
159
  .gala-stats-graph svg { inline-size: 100%; block-size: 8rem; }
@@ -164,6 +192,33 @@ button:focus-visible {
164
192
  .gala-code-block { position: relative; }
165
193
  .gala-code-block > button { position: absolute; inset-block-start: 0.5rem; inset-inline-end: 0.5rem; background: var(--gala-color-background); }
166
194
  .gala-code-block pre { overflow: auto; padding: 3.5rem 1rem 1rem; background: var(--gala-color-surface); border-radius: var(--gala-radius-control); }
195
+ .gala-embed {
196
+ display: grid;
197
+ place-items: center;
198
+ gap: 0.75rem;
199
+ box-sizing: border-box;
200
+ inline-size: 100%;
201
+ margin-inline: 0;
202
+ padding: 1rem;
203
+ overflow: hidden;
204
+ background: var(--gala-color-surface);
205
+ border: 0.125rem solid var(--gala-color-border);
206
+ border-radius: var(--gala-radius-control);
207
+ }
208
+ .gala-embed--youtube { aspect-ratio: 16 / 9; }
209
+ .gala-embed--codepen { min-block-size: 25rem; }
210
+ .gala-embed--gist,
211
+ .gala-embed--x { min-block-size: 8rem; }
212
+ .gala-embed iframe { inline-size: 100%; block-size: 100%; border: 0; }
213
+ .gala-embed__icon {
214
+ inline-size: 2rem;
215
+ block-size: 2rem;
216
+ background: center / contain no-repeat;
217
+ }
218
+ .gala-embed--youtube .gala-embed__icon { background-image: url('./embed-youtube.svg'); }
219
+ .gala-embed--codepen .gala-embed__icon { background-image: url('./embed-codepen.svg'); }
220
+ .gala-embed--gist .gala-embed__icon { background-image: url('./embed-gist.svg'); }
221
+ .gala-embed--x .gala-embed__icon { background-image: url('./embed-x.svg'); }
167
222
  .shiki,
168
223
  .shiki span { color: var(--shiki-light); background-color: var(--shiki-light-bg); }
169
224
  :root[data-mode='dark'] .shiki,
@@ -173,6 +228,11 @@ button:focus-visible {
173
228
  :root[data-mode='system'] .shiki span { color: var(--shiki-dark); background-color: var(--shiki-dark-bg); }
174
229
  }
175
230
  .gala-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
231
+ .gala-contact form { display: grid; gap: 1rem; max-inline-size: 42rem; }
232
+ .gala-contact label { display: grid; gap: .35rem; }
233
+ .gala-contact input,
234
+ .gala-contact textarea { font: inherit; padding: .65rem; }
235
+ .gala-contact textarea { min-block-size: 12rem; resize: vertical; }
176
236
 
177
237
  @keyframes gala-spin { to { transform: rotate(1turn); } }
178
238
 
@@ -0,0 +1,22 @@
1
+ ---
2
+ layout: layouts/base.njk
3
+ title: Contact
4
+ permalink: /contact/index.html
5
+ eleventyExcludeFromCollections: true
6
+ ---
7
+ {% if site.contact.enabled %}
8
+ <article class="gala-contact" data-contact-form data-site-id="{{ site.site.id | escape }}">
9
+ <h1>Contact</h1>
10
+ <p>Sign in with your Gala account to send a private message to the author.</p>
11
+ <form>
12
+ <label>Subject <input name="subject" maxlength="200" required></label>
13
+ <label>Message <textarea name="message" maxlength="5000" required></textarea></label>
14
+ {% if site.contact.websiteEnabled %}<label>Website (optional) <input name="website" type="url" maxlength="2048" autocomplete="url"></label>{% endif %}
15
+ {% if site.contact.phoneEnabled %}<label>Phone (optional) <input name="phone" type="tel" maxlength="32" autocomplete="tel"></label>{% endif %}
16
+ <button type="submit">Send message</button>
17
+ </form>
18
+ <p data-contact-status role="status" aria-live="polite"></p>
19
+ </article>
20
+ {% else %}
21
+ <h1>Contact</h1><p>This site is not accepting contact messages.</p>
22
+ {% endif %}
@@ -52,6 +52,9 @@ function renderedDocument(post) {
52
52
  let document = renderedDocuments.get(post);
53
53
  if (!document) {
54
54
  document = renderMarkdownDocument(post.body);
55
+ for (const warning of document.warnings) {
56
+ console.warn(`${post.source}: warning: ${warning}`);
57
+ }
55
58
  renderedDocuments.set(post, document);
56
59
  }
57
60
  return document;