@rathnasgala/theme 0.0.20 → 2.0.2

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.20",
3
+ "version": "2.0.2",
4
4
  "description": "Managed Gala static-site theme payload",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -2,9 +2,11 @@
2
2
  "schemaVersion": 1,
3
3
  "themePackage": {
4
4
  "name": "@rathnasgala/theme",
5
- "version": "0.0.20",
5
+ "version": "2.0.2",
6
6
  "availableDesignThemes": [
7
- "editorial"
7
+ "editorial",
8
+ "modern",
9
+ "technical"
8
10
  ],
9
11
  "securityAdvisories": []
10
12
  },
@@ -15,55 +17,58 @@
15
17
  "files": {
16
18
  ".gala/publish.yml.template": "73080241d89bd09964b797a8bcc8e18471412e11bc873efad76305b60f497d08",
17
19
  ".gitignore": "b5fa24e10155097a0d372926fd2e59e6072e7cc2cb6399bbfb4beb8b33ea315b",
18
- "eleventy.config.js": "efe587447e3dc374a220c7fa037fdf2fffc02d09ba43a4d2b96d6c6aae0e3325",
20
+ "eleventy.config.js": "0b72076f2f884a3a4a5b2ea4a124e0c1308c735526275bb847a3d6ebd082a186",
21
+ "lib/accent.js": "e6c921e0a3afe094b18709e3e40baa06692017fa6576d84b1ec6b7d788da8dd0",
19
22
  "lib/build-manifest.js": "88949f0112d683ca809bb67ca969e15634792423559a67bc79a534cd807e5535",
20
23
  "lib/engagement-snapshot.js": "298c11b1ab135a3a98a4e31b6320ab60dfd7fda6a9d9053df37d86282a5a6687",
21
24
  "lib/performance-budget.js": "c279622dfde237cd6e3c64775c91d770a0e1f54f5f94257ccc95d9a44de2c4c9",
22
25
  "lib/provider-fixtures/embeds.v1.json": "5f26909fb433135321ddf7fdd14e45aeb702805c88f5676941166c5735143ee2",
23
26
  "lib/provider-fixtures/share-intents.v1.json": "66a8eedfcff711253ee45ceb94b53f7d5a6be3ddbbb3d1f0d4e31a114326cc0a",
24
27
  "lib/publication-state.js": "1b59c29a18bbb1193bddea898dac3a4fb832c7bc4751263be086f3d64392dea0",
25
- "lib/render-markdown.js": "40e6d6743b76de6c16d138475f188a4263967530c370d8b26a363f4df58c2caf",
26
- "lib/seo.js": "107de8804931945b8e73894877fc394d6b535a9d0d969a4ba5b68f2e136e0a17",
28
+ "lib/render-markdown.js": "275b7da721aef1f68cda515673153471c0d597061e8fdf867c5f2b32c98b91db",
29
+ "lib/seo.js": "19cf1d67d5b711b8d3a533f207f267e770ee46fd4a4e6561c5f7fd4e9c1989ff",
27
30
  "lib/share-targets.js": "458aff86fab2b62a364442cd8c612565c6e0b53eef2f20cb6237cc562b1e0731",
28
- "lib/site-config.js": "3ed45daef0b7f79d94bf0c648c4fe7d8bf517534e4645a4b43d952dfc434ddc4",
29
- "package-lock.json": "c97a6a2f794aef083a39a8d2628eccc4a3c484e1c6225899b77ec1fb7c466d9e",
30
- "package.json": "4c60bea06f39aec755ac68caf8d8841c1d59d0f6121fad7f4396231ce14712dc",
31
- "src/404.njk": "be0d44370ac5402156aaf3d32ea581057bc61ec94c1a5f92bb809733af7a8a20",
31
+ "lib/site-config.js": "679c0eaea066bbe7569bf0211696868106dd1d3bf6118fcfb7aeb75962055384",
32
+ "package-lock.json": "dc11470b1ad897c58d91745f23ff25711c593e62972ec9fb83f4e10fd77e1ecd",
33
+ "package.json": "1b3b70f6e94391d309625cb8cef503ee92ecd282bec587643b535ae7a14c24c0",
34
+ "scripts/build-reader.js": "743194c8dc9cfb9b6631db1732b5f36a92e3a4f905a2965c71d56e1ef50c2e45",
35
+ "scripts/lint.js": "c4bb3c961686043fa8c79bed546758ef09d44441421d8209fec9d69f74171025",
32
36
  "src/_data/buildManifest.js": "628163305984e0d3537e27b0d4050f5ba5ead12a834878266d2ce8004ffe1b1e",
33
37
  "src/_data/engagementSnapshot.js": "9217dacebfaf008f7a0ad67ff4e8d1fa8fba609a9dc7e3430c78c5b32024da0e",
34
38
  "src/_data/feedLinks.js": "06c93352ebd888acb431a1c9a8be6ecff757895468e9f2d336761038751c2d5e",
35
39
  "src/_data/languages.js": "1358748f7fa2f74406d3295a030827d6229a53e94da72cf712e719d7a356aeac",
36
- "src/_data/site.js": "6f9b754876ab7cfa93f02a38be2962edce8245423c354426a1465513caec7911",
37
- "src/_includes/components/ui.njk": "23eb647d8658868449ec6bb809e965a85302124a97639f94d5d28af72df56e4c",
38
- "src/_includes/layouts/base.njk": "638457f6c10f3ba866e8c70715678298096a668568e99456bef4167ebf1111e7",
39
- "src/_includes/layouts/post.njk": "1accd2832064ad19ee7bf18daf5fa1e60569f32c9b7a547934a6361f747d7eb7",
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": "579421054d8b547cb974a87114a96bb90cc2792b1436eb90c1f620589b2bfbfa",
44
+ "src/404.njk": "be0d44370ac5402156aaf3d32ea581057bc61ec94c1a5f92bb809733af7a8a20",
45
+ "src/accent.11ty.js": "52e7d3eb731c5f4649c14c4745f65da026c25b58cf462d075b8ef363f0e487dc",
40
46
  "src/assets/embed-codepen.svg": "e9def697f38c466fb2b6652535c4c814e8cbbbefc6e052a69f335aeb27e8356b",
41
47
  "src/assets/embed-gist.svg": "fcd9bc2bf29a5c118492b853c0a01390b7539c38ddb985da3c2a6e2f246a39f7",
42
48
  "src/assets/embed-x.svg": "0ae74edc6790ec7e933067fe5392bce756e93fa7ad71caacf1c1ca99fa4ec9b6",
43
49
  "src/assets/embed-youtube.svg": "10d192f2b14b67664fea88c8cf246321d20190def51d59fad5762e552219b067",
44
- "src/assets/engagement-comments.js": "b20d9268c9d908b4453a42188eaca25349f25534fb04a9293c9e688a982cd8f8",
45
- "src/assets/engagement-transport.js": "4f9ca0cd669bf8abc25f2909104e3e4b231657ef97d36a17d06e53454d711786",
50
+ "src/assets/engagement-comments.js": "eda0fee471c7e42ee01a25b8aa14ed6ae9690d52b3fbb34027d97534ab62998b",
51
+ "src/assets/engagement-transport.js": "d4108cded823f859e7a8d6e36716162422ceaf5e4b94a57ed9bd7941d5096216",
46
52
  "src/assets/favicon.svg": "8615e3d96fe37ae9ed14afb1886e7c0e690eef5b10be66ca91cccfd085a74bb6",
47
- "src/assets/interactions.js": "4b65dcfcc23682616b15071d746da053bcc3a6bc5df36e11dc5698dd3348f314",
53
+ "src/assets/interactions.js": "d7ac42be1c3453fb5a7bc1b7de52bca2bc7322e60ef235ce4953f5382a3550c5",
48
54
  "src/assets/preferences.js": "789127bee013ed6a1bbf8cefd7b9109291c16220e7a6e09314e3c93c54731d1d",
55
+ "src/assets/reader.js": "96018c305177c5cbea77b4958ccba7800be82ace513f47fc811bdd41fb8eee63",
49
56
  "src/assets/search.js": "df425f0239277792ad39f2138bc89cb66cc4ecda5ed59042d46abc2f05372088",
50
57
  "src/assets/theme-mode.js": "944b29db11a577961a60ddcfa52f07ef2411f3f3631bea85bd79be16428c08be",
51
- "src/assets/theme.css": "300f6c2ffd7e63a948cfdb46b2d0f4abdbce5f5c4da7ee2cca5633d9795277c4",
52
- "src/assets/vendor/hooks.js": "ae6ec58592683ca64fa4d65b20d40b103df62a673bcc4712515cf165950b07f3",
53
- "src/assets/vendor/htm.js": "e4dac9bc40a6f2ec590413d787ae30bbcfdfcde4f28971ee65d9578271b7aad4",
54
- "src/assets/vendor/preact.js": "712517dd2a42c3c22b5ab0264ad7e9a2b1e8be6cf97fa00dbedd98203617e616",
55
- "src/assets/vendor/signals-core.js": "1e10560fec5118c9e35eb312c869fe51f4870424766ef2ec0f7cd069bd898c8b",
56
- "src/assets/vendor/signals.js": "717a88151bacf4f8b79b528a96d71be20a133fb3345230c1146929903cecfd19",
58
+ "src/assets/theme.css": "43adf073599bf660534b26c7096c0fe5c25b12589d6dcb760d2d06fe2a477be4",
59
+ "src/client/reader.js": "5a61d629517c5315b3cb14dbae6cee226ba828e95132100f2ad36e614c57470a",
57
60
  "src/cname.11ty.js": "206fc224df91c569e18312158e7f69803061f2952513f501ada54bd06c4f8006",
58
61
  "src/contact.njk": "f4d74d038781e15af0acef571901c91e7753a2e21b6bef617429585698839a34",
59
62
  "src/feed.11ty.js": "009bd2384e08e02a9405683bab1653bab3aeaec16accb0fd01ba69b24c777da9",
60
63
  "src/index.njk": "5c313f0fcdad3ddbfb79376bc9671b3f37539ef70c14bdbbd26d6e2e6de250e8",
61
64
  "src/languages.11ty.js": "399559199c65570d7d5efd88e20254838e316f81c06d7b28ec34990e963db915",
62
- "src/posts.11ty.js": "89c0e4742a67cd03f9fd13c8c8d4ca56375d97628b3cb439af7f9c92c240104a",
65
+ "src/posts.11ty.js": "c0bdc81183f5849670d100ceb97ecad0d1eb05f0de3eea34a0ce7427fd2e7e2a",
63
66
  "src/redirects.11ty.js": "3c163246a59f5364861db6a6f05ae88afc938a5379d86cf42776d8d49b0031f3",
64
67
  "src/search-index.11ty.js": "700dc2a2be0ef20de3a828a4612fcf7a082691eade29c6e1d75f09c24b6c6024",
65
68
  "src/search.njk": "63f2e8aeebb63c90da2e375eece639ce096cad1eff7f28628cafc46faf7ceee6",
66
69
  "src/sitemap.11ty.js": "4f233e94fb4a913ad5659116518432ed264984a7b622eba3558c77b4ec6435dc",
70
+ "src/styles/theme.css": "14496b8c692ea81427386125dc92822e1f06eadab30844380f77ca710c2ff7ef",
71
+ "static/favicon.ico": "ad3cb0b02f0164f3af6280d0bfe712879d55cec0e9a7a567f658aaddca5a55a2",
67
72
  "static/robots.txt": "16ceb5ee3e0dc13aa9adf31a3ebbe45a1d965b8c2b9f72eaf84e5911e140ed95"
68
73
  },
69
74
  "artifactSources": {
@@ -4,6 +4,9 @@ import { loadSiteConfiguration } from './lib/site-config.js';
4
4
  import { enforcePerformanceBudgets } from './lib/performance-budget.js';
5
5
  import { lstat, realpath } from 'node:fs/promises';
6
6
  import path from 'node:path';
7
+ import { createHash } from 'node:crypto';
8
+
9
+ const themeBootstrap = "(function(){try{var m=localStorage.getItem('gala-color-mode');if(m==='light'||m==='dark'||m==='system')document.documentElement.dataset.mode=m}catch(e){}})();";
7
10
 
8
11
  async function verifiedMediaSource(postSource, mediaSource) {
9
12
  const metadata = await lstat(mediaSource);
@@ -25,9 +28,20 @@ export default async function (eleventyConfig) {
25
28
  const [manifest, site] = await Promise.all([readBuildManifest(), loadSiteConfiguration()]);
26
29
  const attributionTier = process.env.GALA_ATTRIBUTION_TIER === 'PAID' ? 'PAID' : 'FREE';
27
30
  eleventyConfig.addGlobalData('attributionTier', attributionTier);
31
+ eleventyConfig.addGlobalData('themeBootstrap', themeBootstrap);
32
+ eleventyConfig.addFilter('sha256Csp', (value) =>
33
+ createHash('sha256').update(String(value)).digest('base64'));
28
34
  eleventyConfig.setLibrary('md', markdownLibrary);
29
35
  eleventyConfig.addPassthroughCopy({ static: '/' });
30
- eleventyConfig.addPassthroughCopy({ 'src/assets': 'assets' });
36
+ eleventyConfig.addPassthroughCopy({
37
+ 'src/assets/theme.css': 'assets/theme.css',
38
+ 'src/assets/reader.js': 'assets/reader.js',
39
+ 'src/assets/favicon.svg': 'assets/favicon.svg',
40
+ 'src/assets/embed-codepen.svg': 'assets/embed-codepen.svg',
41
+ 'src/assets/embed-gist.svg': 'assets/embed-gist.svg',
42
+ 'src/assets/embed-x.svg': 'assets/embed-x.svg',
43
+ 'src/assets/embed-youtube.svg': 'assets/embed-youtube.svg'
44
+ });
31
45
  eleventyConfig.addPassthroughCopy('custom.css');
32
46
  for (const post of manifest.posts) {
33
47
  for (const copy of post.media ?? []) {
@@ -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 = {
@@ -12,14 +12,20 @@ const BUDGET_KEYS = Object.freeze([
12
12
  * a publication looks; anything the CSS does not answer to has no business being offered.
13
13
  */
14
14
  const DESIGN_VALUES = Object.freeze({
15
- layout: Object.freeze(['article-first', 'portfolio']),
16
- palette: Object.freeze(['default', 'ocean', 'forest', 'plum']),
17
15
  theme: Object.freeze(['editorial', 'modern', 'technical']),
18
16
  colorMode: Object.freeze(['system', 'light', 'dark'])
19
17
  });
18
+ // Accepted only so repositories created before v2 keep building. New author surfaces never offer
19
+ // these independent knobs; v2's look owns its layout and palette as one reviewed composition.
20
+ const LEGACY_DESIGN_VALUES = Object.freeze({
21
+ layout: Object.freeze(['article-first', 'portfolio']),
22
+ palette: Object.freeze(['default', 'ocean', 'forest', 'plum'])
23
+ });
20
24
  const CONTACT_KEYS = Object.freeze([
21
25
  'enabled', 'websiteEnabled', 'phoneEnabled'
22
26
  ]);
27
+ const PROFILE_KEYS = Object.freeze(['displayName', 'bio', 'avatarUrl', 'profileUrl']);
28
+ const PUBLISHER_KEYS = Object.freeze(['name', 'url', 'logoUrl']);
23
29
 
24
30
  /*
25
31
  * `layout` and `palette` have always been required. The rest were added later and are optional:
@@ -27,8 +33,6 @@ const CONTACT_KEYS = Object.freeze([
27
33
  * back to its own defaults for anything absent. Present-but-unknown is still an error, because
28
34
  * that is a writer expecting something the CSS will not do.
29
35
  */
30
- const REQUIRED_DESIGN = Object.freeze(['layout', 'palette']);
31
-
32
36
  /** A free colour, not a menu — but still a colour. */
33
37
  const ACCENT = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
34
38
 
@@ -40,11 +44,17 @@ function validateImplementedDesign(config) {
40
44
  if (config?.design == null) return;
41
45
  for (const [field, allowed] of Object.entries(DESIGN_VALUES)) {
42
46
  const value = config.design[field];
43
- if (value === undefined && !REQUIRED_DESIGN.includes(field)) continue;
47
+ if (value === undefined) continue;
44
48
  if (!allowed.includes(value)) {
45
49
  throw new TypeError(`Unsupported design.${field}: ${value}`);
46
50
  }
47
51
  }
52
+ for (const [field, allowed] of Object.entries(LEGACY_DESIGN_VALUES)) {
53
+ const value = config.design[field];
54
+ if (value !== undefined && !allowed.includes(value)) {
55
+ throw new TypeError(`Unsupported legacy design.${field}: ${value}`);
56
+ }
57
+ }
48
58
  }
49
59
 
50
60
  export function validatePerformanceBudgets(value) {
@@ -96,6 +106,60 @@ export function validateContact(value) {
96
106
  });
97
107
  }
98
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
+
99
163
  export async function loadSiteConfiguration({
100
164
  root = process.cwd(),
101
165
  configPath = process.env.GALA_CONFIG_PATH ?? 'site.config.yml'
@@ -118,5 +182,11 @@ export async function loadSiteConfiguration({
118
182
  validatePerformanceBudgets(config?.performance?.budgets);
119
183
  config.statistics = validateStatistics(config.statistics);
120
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
+ };
121
191
  return config;
122
192
  }