@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala/theme",
3
- "version": "0.0.1",
3
+ "version": "0.0.6",
4
4
  "description": "Managed Gala static-site theme payload",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,6 +15,6 @@
15
15
  "provenance": true
16
16
  },
17
17
  "engines": {
18
- "node": ">=18"
18
+ "node": ">=20"
19
19
  }
20
20
  }
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "themePackage": {
4
4
  "name": "@rathnasgala/theme",
5
- "version": "0.0.1",
5
+ "version": "0.0.6",
6
6
  "availableDesignThemes": [
7
7
  "editorial"
8
8
  ],
@@ -11,33 +11,36 @@
11
11
  "files": {
12
12
  ".gitignore": "b5fa24e10155097a0d372926fd2e59e6072e7cc2cb6399bbfb4beb8b33ea315b",
13
13
  ".gala/publish.yml.template": "73080241d89bd09964b797a8bcc8e18471412e11bc873efad76305b60f497d08",
14
- "eleventy.config.js": "0b8c753a28afecdf733ea650cab5fd0691a87f996bc186820c5f949003891cc9",
15
- "package.json": "86e1e9922fda15122fe4eb5325d30d145cbdb2877e24307914702d0b64cf1ce9",
16
- "package-lock.json": "92372b1bfbdeedf53a8942b3a4e774da3f708ebbf177d92f2b6280e83981df67",
14
+ "eleventy.config.js": "24f25bd69108f17c80d85807f1bbf6851a44b77dfdddc8d8e234b85462cb2e04",
15
+ "package.json": "cccea6fc4edf4b4b653bd5cfc0901ab20dd92ac8975408fb01ae3670005da641",
16
+ "package-lock.json": "c97a6a2f794aef083a39a8d2628eccc4a3c484e1c6225899b77ec1fb7c466d9e",
17
17
  "lib/build-manifest.js": "88949f0112d683ca809bb67ca969e15634792423559a67bc79a534cd807e5535",
18
- "lib/site-config.js": "9eebf302e1ee5e7fa7b41e50cd0540cf71f917fda25c511fe1db3b035359b091",
18
+ "lib/site-config.js": "59579cc3253529c7201002ab40435eeaeb4359ca82ddbb473a026bc317f62fcb",
19
+ "lib/performance-budget.js": "124c43494c092a956accec46d1463ea063ed73363d0db01f24aaea987459d683",
19
20
  "lib/engagement-snapshot.js": "298c11b1ab135a3a98a4e31b6320ab60dfd7fda6a9d9053df37d86282a5a6687",
20
21
  "lib/publication-state.js": "1b59c29a18bbb1193bddea898dac3a4fb832c7bc4751263be086f3d64392dea0",
21
- "lib/render-markdown.js": "3c493e50264711ee537bf34db826ee39780a6caad5c82174919f48e18fc76ae8",
22
- "lib/seo.js": "609ae1c3a1b484dd7c37592d672d13c9aad8005471e2223caaabd2a8e800d194",
22
+ "lib/render-markdown.js": "c7dac7e3599af445830825e76e285166e85835163caa8bef2c3a970a307ac750",
23
+ "lib/seo.js": "107de8804931945b8e73894877fc394d6b535a9d0d969a4ba5b68f2e136e0a17",
24
+ "lib/share-targets.js": "5820466526ab2dcd2cd1689c163117c34df0a675dfa92e28e853f690423299b3",
25
+ "lib/provider-fixtures/share-intents.v1.json": "2535cee63962f38691cb7b8c47d2414bf9cacbcee5cadba0bec0172e7b3961e4",
23
26
  "src/404.njk": "be0d44370ac5402156aaf3d32ea581057bc61ec94c1a5f92bb809733af7a8a20",
24
- "src/_includes/layouts/base.njk": "4def165838c1314c5d7b3759830d268ad48ee6a966dafe492ae7b0bb5018a7c7",
25
- "src/_includes/layouts/post.njk": "ced9783eae1b4ca51b950edf19224cd4022294775c9dcc78964b65e504e9d497",
26
- "src/_includes/components/ui.njk": "774a82eade4fa21b9203f6455995de4a274eb0b2a452a54288f5fc4b5c16f233",
27
+ "src/_includes/layouts/base.njk": "47ad79e1ce9520caacb4e564f3257335efc8889c242b2064ce2ed27c98d2cfcc",
28
+ "src/_includes/layouts/post.njk": "1accd2832064ad19ee7bf18daf5fa1e60569f32c9b7a547934a6361f747d7eb7",
29
+ "src/_includes/components/ui.njk": "3a0eeb0f7b8f0ea46ed39d4c53b810a33b997766cbae540ef063c8ecdf641809",
27
30
  "src/_data/buildManifest.js": "628163305984e0d3537e27b0d4050f5ba5ead12a834878266d2ce8004ffe1b1e",
28
31
  "src/_data/engagementSnapshot.js": "9217dacebfaf008f7a0ad67ff4e8d1fa8fba609a9dc7e3430c78c5b32024da0e",
29
32
  "src/_data/feedLinks.js": "06c93352ebd888acb431a1c9a8be6ecff757895468e9f2d336761038751c2d5e",
30
33
  "src/_data/languages.js": "1358748f7fa2f74406d3295a030827d6229a53e94da72cf712e719d7a356aeac",
31
34
  "src/_data/site.js": "d9c001f4cc12a34027dd92303530b11b5402d47613459bb997e22c4bcbea326d",
32
- "src/assets/theme-mode.js": "67cfd8f950ee002d9b6364b7c90db0ad30341d78ac331b649d884ac00a3d6089",
33
- "src/assets/interactions.js": "1f68c7e80208c43a40390356009d6101f76f56200f23c654e19ddd253b10b677",
35
+ "src/assets/theme-mode.js": "3797c84dbaa090b5b21427a31928b5dd97f13d04d99833f5e1e74c6b50e01dcc",
36
+ "src/assets/interactions.js": "611e54376f03d9cd8abd55ef4fcbebe917a174c673dd15fcb3c4073cc6e79836",
34
37
  "src/assets/preferences.js": "789127bee013ed6a1bbf8cefd7b9109291c16220e7a6e09314e3c93c54731d1d",
35
- "src/assets/search.js": "5571697e2a5377c465e5446f77f2225948a5c0bdc58d0be44b579341e756909f",
36
- "src/assets/theme.css": "c1ea914cd4a4a23afce8265548feafcb730254c993fe55853779915491356a5c",
38
+ "src/assets/search.js": "df425f0239277792ad39f2138bc89cb66cc4ecda5ed59042d46abc2f05372088",
39
+ "src/assets/theme.css": "8cad6e82e0017e7b62a5355f0386d854fb62e774c520b3ce25aaea5b19b4fa61",
37
40
  "src/index.njk": "5c313f0fcdad3ddbfb79376bc9671b3f37539ef70c14bdbbd26d6e2e6de250e8",
38
41
  "src/feed.11ty.js": "009bd2384e08e02a9405683bab1653bab3aeaec16accb0fd01ba69b24c777da9",
39
42
  "src/languages.11ty.js": "399559199c65570d7d5efd88e20254838e316f81c06d7b28ec34990e963db915",
40
- "src/posts.11ty.js": "2967f9e17fa7fcec5bf44387ab5bdee7bf8b8c85cfdc589f2105829995fc1d0e",
43
+ "src/posts.11ty.js": "2f9d738075eab68961d7c8fe9286b61f12c2c677f7dad9d4858927ef51b66ff8",
41
44
  "src/redirects.11ty.js": "3c163246a59f5364861db6a6f05ae88afc938a5379d86cf42776d8d49b0031f3",
42
45
  "src/search-index.11ty.js": "700dc2a2be0ef20de3a828a4612fcf7a082691eade29c6e1d75f09c24b6c6024",
43
46
  "src/search.njk": "63f2e8aeebb63c90da2e375eece639ce096cad1eff7f28628cafc46faf7ceee6",
@@ -1,5 +1,7 @@
1
1
  import { markdownLibrary } from './lib/render-markdown.js';
2
2
  import { readBuildManifest } from './lib/build-manifest.js';
3
+ import { loadSiteConfiguration } from './lib/site-config.js';
4
+ import { enforcePerformanceBudgets } from './lib/performance-budget.js';
3
5
  import { lstat, realpath } from 'node:fs/promises';
4
6
  import path from 'node:path';
5
7
 
@@ -20,7 +22,7 @@ async function verifiedMediaSource(postSource, mediaSource) {
20
22
  }
21
23
 
22
24
  export default async function (eleventyConfig) {
23
- const manifest = await readBuildManifest();
25
+ const [manifest, site] = await Promise.all([readBuildManifest(), loadSiteConfiguration()]);
24
26
  eleventyConfig.setLibrary('md', markdownLibrary);
25
27
  eleventyConfig.addPassthroughCopy({ static: '/' });
26
28
  eleventyConfig.addPassthroughCopy({ 'src/assets': 'assets' });
@@ -34,6 +36,12 @@ export default async function (eleventyConfig) {
34
36
  eleventyConfig.addCollection('posts', (collectionApi) =>
35
37
  collectionApi.getAll().filter((item) => item.data.post?.publicationState === 'published')
36
38
  );
39
+ eleventyConfig.on('eleventy.after', async () => {
40
+ await enforcePerformanceBudgets({
41
+ outputDirectory: path.resolve('_site'),
42
+ budgets: site.performance.budgets
43
+ });
44
+ });
37
45
 
38
46
  return {
39
47
  dir: {
@@ -42,6 +50,6 @@ export default async function (eleventyConfig) {
42
50
  includes: '_includes',
43
51
  data: '_data'
44
52
  },
45
- pathPrefix: process.env.GALA_PATH_PREFIX ?? '/'
53
+ pathPrefix: site.hosting.pathPrefix === '' ? '/' : site.hosting.pathPrefix
46
54
  };
47
55
  }
@@ -0,0 +1,39 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ async function filesBelow(directory) {
5
+ const entries = await readdir(directory, { withFileTypes: true });
6
+ const nested = await Promise.all(entries.map(async (entry) => {
7
+ const file = path.join(directory, entry.name);
8
+ return entry.isDirectory() ? filesBelow(file) : entry.isFile() ? [file] : [];
9
+ }));
10
+ return nested.flat();
11
+ }
12
+
13
+ async function totalBytes(files) {
14
+ return (await Promise.all(files.map(async (file) => (await stat(file)).size)))
15
+ .reduce((total, size) => total + size, 0);
16
+ }
17
+
18
+ function assertWithin(actual, maximum, label) {
19
+ if (actual > maximum) {
20
+ throw new Error(`${label} performance budget exceeded: ${actual} bytes > ${maximum} bytes`);
21
+ }
22
+ }
23
+
24
+ export async function enforcePerformanceBudgets({ outputDirectory, budgets }) {
25
+ const output = path.resolve(outputDirectory);
26
+ const files = await filesBelow(output);
27
+ const managedAssets = files.filter((file) => path.dirname(file) === path.join(output, 'assets'));
28
+ const javascriptBytes = await totalBytes(managedAssets.filter((file) => file.endsWith('.js')));
29
+ const cssBytes = await totalBytes(managedAssets.filter((file) => file.endsWith('.css')));
30
+ assertWithin(javascriptBytes, budgets.managedJavaScriptBytes, 'Managed JavaScript');
31
+ assertWithin(cssBytes, budgets.managedCssBytes, 'Managed CSS');
32
+
33
+ for (const file of files.filter((candidate) => candidate.endsWith('.html'))) {
34
+ const bytes = (await stat(file)).size;
35
+ assertWithin(bytes, budgets.ordinaryHtmlBytes, path.relative(output, file));
36
+ }
37
+
38
+ return Object.freeze({ javascriptBytes, cssBytes });
39
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "providers": {
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
+ "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" },
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" },
10
+ "email": { "source": "https://www.rfc-editor.org/rfc/rfc6068", "verifiedOn": "2026-08-13", "status": "verified", "template": "mailto:?subject={title}&body={text}" },
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
+ }
13
+ }
@@ -1,6 +1,19 @@
1
1
  import MarkdownIt from 'markdown-it';
2
2
  import footnote from 'markdown-it-footnote';
3
3
  import sanitizeHtml from 'sanitize-html';
4
+ import { createHighlighter } from 'shiki';
5
+ import GithubSlugger from 'github-slugger';
6
+ import { randomBytes } from 'node:crypto';
7
+
8
+ const SHIKI_THEMES = Object.freeze({ light: 'github-light', dark: 'github-dark' });
9
+ const SHIKI_LANGUAGES = Object.freeze([
10
+ 'bash', 'css', 'html', 'java', 'javascript', 'json', 'jsx', 'markdown',
11
+ 'shellscript', 'sql', 'typescript', 'tsx', 'yaml'
12
+ ]);
13
+ const highlighter = await createHighlighter({
14
+ themes: Object.values(SHIKI_THEMES),
15
+ langs: SHIKI_LANGUAGES
16
+ });
4
17
 
5
18
  const ADMONITION_TYPES = new Set(['NOTE', 'TIP', 'IMPORTANT', 'WARNING', 'CAUTION']);
6
19
 
@@ -38,9 +51,44 @@ function admonitions(markdown) {
38
51
  });
39
52
  }
40
53
 
54
+ function headingAnchors(markdown) {
55
+ markdown.core.ruler.push('gala_heading_anchors', (state) => {
56
+ const slugger = new GithubSlugger();
57
+ const toc = [];
58
+ for (let index = 0; index < state.tokens.length - 1; index += 1) {
59
+ const open = state.tokens[index];
60
+ if (open.type !== 'heading_open') continue;
61
+ const inline = state.tokens[index + 1];
62
+ const id = slugger.slug(inline.content);
63
+ open.attrSet('id', id);
64
+ if (open.tag === 'h2' || open.tag === 'h3') toc.push({ id, text: inline.content });
65
+ }
66
+ state.env.galaToc = toc.length >= 3 ? toc : [];
67
+ });
68
+ }
69
+
41
70
  const markdown = new MarkdownIt({ html: true, linkify: true, typographer: false })
42
71
  .use(footnote)
43
- .use(admonitions);
72
+ .use(admonitions)
73
+ .use(headingAnchors);
74
+
75
+ const defaultFence = markdown.renderer.rules.fence.bind(markdown.renderer.rules);
76
+ markdown.renderer.rules.fence = (tokens, index, options, environment, self) => {
77
+ const language = tokens[index].info.trim().split(/\s+/, 1)[0].toLowerCase();
78
+ if (!SHIKI_LANGUAGES.includes(language)) {
79
+ return defaultFence(tokens, index, options, environment, self);
80
+ }
81
+ const highlights = environment.galaHighlights ??= [];
82
+ const token = randomBytes(18).toString('hex');
83
+ highlights.push(highlighter.codeToHtml(tokens[index].content, {
84
+ lang: language,
85
+ themes: SHIKI_THEMES,
86
+ defaultColor: false
87
+ }));
88
+ const tokensByIndex = environment.galaHighlightTokens ??= [];
89
+ tokensByIndex.push(token);
90
+ return `<div class="gala-highlight-placeholder" data-highlight-token="${token}"></div>`;
91
+ };
44
92
 
45
93
  const sanitizeOptions = {
46
94
  allowedTags: [
@@ -53,7 +101,13 @@ const sanitizeOptions = {
53
101
  a: ['href', 'title', 'rel'],
54
102
  aside: ['class', 'role'],
55
103
  code: ['class'],
56
- div: ['class', 'id'],
104
+ div: ['class', 'id', 'data-highlight-token'],
105
+ h1: ['id'],
106
+ h2: ['id'],
107
+ h3: ['id'],
108
+ h4: ['id'],
109
+ h5: ['id'],
110
+ h6: ['id'],
57
111
  img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'decoding'],
58
112
  li: ['id'],
59
113
  ol: ['class'],
@@ -67,6 +121,7 @@ const sanitizeOptions = {
67
121
  'admonition', 'admonition-note', 'admonition-tip', 'admonition-important',
68
122
  'admonition-warning', 'admonition-caution'
69
123
  ],
124
+ div: ['gala-highlight-placeholder'],
70
125
  code: [/^language-[a-z0-9_-]+$/],
71
126
  ol: ['footnotes-list'],
72
127
  span: ['footnote-backref'],
@@ -81,16 +136,40 @@ const sanitizeOptions = {
81
136
  enforceHtmlBoundary: true
82
137
  };
83
138
 
139
+ function render(source, inline, suppliedEnvironment) {
140
+ const environment = suppliedEnvironment ?? {};
141
+ environment.galaHighlights = [];
142
+ environment.galaHighlightTokens = [];
143
+ const unsafe = inline
144
+ ? markdown.renderInline(source, environment)
145
+ : markdown.render(source, environment);
146
+ let safe = sanitizeHtml(unsafe, sanitizeOptions);
147
+ for (let index = 0; index < environment.galaHighlights.length; index += 1) {
148
+ safe = safe.replace(
149
+ `<div class="gala-highlight-placeholder" data-highlight-token="${environment.galaHighlightTokens[index]}"></div>`,
150
+ environment.galaHighlights[index]
151
+ );
152
+ }
153
+ return safe;
154
+ }
155
+
84
156
  export function renderMarkdown(source) {
85
157
  if (typeof source !== 'string') throw new TypeError('Markdown source must be a string');
86
- return sanitizeHtml(markdown.render(source), sanitizeOptions);
158
+ return render(source, false);
159
+ }
160
+
161
+ export function renderMarkdownDocument(source) {
162
+ if (typeof source !== 'string') throw new TypeError('Markdown source must be a string');
163
+ const environment = {};
164
+ const html = render(source, false, environment);
165
+ return Object.freeze({ html, tableOfContents: Object.freeze(environment.galaToc ?? []) });
87
166
  }
88
167
 
89
168
  export const markdownLibrary = Object.assign(Object.create(markdown), {
90
169
  render(source, environment) {
91
- return sanitizeHtml(markdown.render(source, environment), sanitizeOptions);
170
+ return render(source, false, environment);
92
171
  },
93
172
  renderInline(source, environment) {
94
- return sanitizeHtml(markdown.renderInline(source, environment), sanitizeOptions);
173
+ return render(source, true, environment);
95
174
  }
96
175
  });
@@ -84,9 +84,19 @@ function urlPath(value, field) {
84
84
  }
85
85
 
86
86
  export function siteUrl({ canonicalBaseUrl, pathPrefix = '/', relativePath = '/' }) {
87
+ let suppliedBase;
88
+ try {
89
+ suppliedBase = new URL(canonicalBaseUrl);
90
+ } catch {
91
+ throw new TypeError('canonicalBaseUrl must be an absolute URL');
92
+ }
87
93
  const base = absoluteHttpsUrl(canonicalBaseUrl, 'canonicalBaseUrl');
94
+ if (base.pathname !== '/' || suppliedBase.search !== '' || suppliedBase.hash !== '') {
95
+ throw new TypeError('canonicalBaseUrl must be an origin only; put the URL path in pathPrefix');
96
+ }
97
+ const normalizedPrefix = pathPrefix === '' ? '/' : pathPrefix;
88
98
  const parts = [
89
- ...urlPath(pathPrefix, 'pathPrefix'),
99
+ ...urlPath(normalizedPrefix, 'pathPrefix'),
90
100
  ...urlPath(relativePath, 'relativePath')
91
101
  ].map(encodeURIComponent);
92
102
  base.pathname = parts.length === 0
@@ -0,0 +1,63 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ const fixture = JSON.parse(readFileSync(
4
+ new URL('./provider-fixtures/share-intents.v1.json', import.meta.url),
5
+ 'utf8'
6
+ ));
7
+
8
+ const LABELS = Object.freeze({
9
+ bluesky: 'Bluesky',
10
+ x: 'X',
11
+ 'hacker-news': 'Hacker News',
12
+ email: 'Email',
13
+ mastodon: 'Mastodon'
14
+ });
15
+
16
+ export function resolveShareTargets({ configured, title, canonicalUrl, socialProfiles = {} }) {
17
+ if (!Array.isArray(configured)) throw new TypeError('sharing.targets must be an array');
18
+ if (typeof title !== 'string' || title.trim() === '') throw new TypeError('share title is required');
19
+ const canonical = requireHttpsUrl(canonicalUrl, 'canonical URL');
20
+ return configured.map((provider) => resolveProvider(provider, title, canonical, socialProfiles));
21
+ }
22
+
23
+ function resolveProvider(provider, title, canonical, socialProfiles) {
24
+ const contract = fixture.providers?.[provider];
25
+ if (contract?.status !== 'verified' || typeof contract.template !== 'string') {
26
+ throw new TypeError(`Share target ${provider} has no verified public intent contract`);
27
+ }
28
+ let instance = '';
29
+ if (contract.requiresInstance) {
30
+ const profile = requireHttpsUrl(socialProfiles.mastodon, 'sharing.socialProfiles.mastodon');
31
+ instance = profile.host;
32
+ }
33
+ const text = provider === 'bluesky' || provider === 'mastodon'
34
+ ? `${title}\n${canonical.href}`
35
+ : provider === 'email' ? canonical.href : title;
36
+ const replacements = {
37
+ text: encodeURIComponent(text),
38
+ title: encodeURIComponent(title),
39
+ url: encodeURIComponent(canonical.href),
40
+ instance
41
+ };
42
+ const rendered = contract.template.replace(/\{(text|title|url|instance)\}/g,
43
+ (placeholder, name) => replacements[name]);
44
+ if (/\{[^}]+\}/.test(rendered)) throw new TypeError(`Share target ${provider} fixture is incomplete`);
45
+ const destination = new URL(rendered);
46
+ if (!['https:', 'mailto:'].includes(destination.protocol)) {
47
+ throw new TypeError(`Share target ${provider} produced an unsafe URL`);
48
+ }
49
+ return Object.freeze({ provider, label: LABELS[provider], url: destination.href });
50
+ }
51
+
52
+ function requireHttpsUrl(value, field) {
53
+ let parsed;
54
+ try {
55
+ parsed = new URL(value);
56
+ } catch {
57
+ throw new TypeError(`${field} must be an absolute HTTPS URL`);
58
+ }
59
+ if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') {
60
+ throw new TypeError(`${field} must be an absolute credential-free HTTPS URL`);
61
+ }
62
+ return parsed;
63
+ }
@@ -2,6 +2,44 @@ import { lstat, readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { parse } from 'yaml';
4
4
 
5
+ const BUDGET_KEYS = Object.freeze([
6
+ 'managedJavaScriptBytes',
7
+ 'managedCssBytes',
8
+ 'ordinaryHtmlBytes'
9
+ ]);
10
+
11
+ export function validatePerformanceBudgets(value) {
12
+ if (value == null || Array.isArray(value) || typeof value !== 'object') {
13
+ throw new TypeError('performance.budgets must be a mapping');
14
+ }
15
+ const keys = Object.keys(value);
16
+ const unknown = keys.filter((key) => !BUDGET_KEYS.includes(key));
17
+ if (unknown.length > 0) {
18
+ throw new TypeError(`Unsupported performance budget: ${unknown.join(', ')}`);
19
+ }
20
+ for (const key of BUDGET_KEYS) {
21
+ if (!Number.isSafeInteger(value[key]) || value[key] <= 0) {
22
+ throw new TypeError(`performance.budgets.${key} must be a positive integer byte count`);
23
+ }
24
+ }
25
+ return Object.freeze(Object.fromEntries(BUDGET_KEYS.map((key) => [key, value[key]])));
26
+ }
27
+
28
+ export function validateStatistics(value) {
29
+ if (value == null) return Object.freeze({ publicViewCounts: false });
30
+ if (Array.isArray(value) || typeof value !== 'object') {
31
+ throw new TypeError('statistics must be a mapping');
32
+ }
33
+ const unknown = Object.keys(value).filter((key) => key !== 'publicViewCounts');
34
+ if (unknown.length > 0) {
35
+ throw new TypeError(`Unsupported statistics option: ${unknown.join(', ')}`);
36
+ }
37
+ if (value.publicViewCounts != null && typeof value.publicViewCounts !== 'boolean') {
38
+ throw new TypeError('statistics.publicViewCounts must be a boolean');
39
+ }
40
+ return Object.freeze({ publicViewCounts: value.publicViewCounts === true });
41
+ }
42
+
5
43
  export async function loadSiteConfiguration({
6
44
  root = process.cwd(),
7
45
  configPath = process.env.GALA_CONFIG_PATH ?? 'site.config.yml'
@@ -19,5 +57,8 @@ export async function loadSiteConfiguration({
19
57
  if (!metadata.isFile() || metadata.isSymbolicLink()) {
20
58
  throw new TypeError('site configuration must be a regular file');
21
59
  }
22
- return parse(await readFile(file, 'utf8'));
60
+ const config = parse(await readFile(file, 'utf8'));
61
+ validatePerformanceBudgets(config?.performance?.budgets);
62
+ config.statistics = validateStatistics(config.statistics);
63
+ return config;
23
64
  }