@janga/norna 0.7.22 → 0.7.23

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.
Files changed (60) hide show
  1. package/astro.config.mjs +3 -0
  2. package/bin/norna-cli.mjs +2 -2
  3. package/package.json +17 -3
  4. package/schemas/category.schema.json +2 -2
  5. package/schemas/config.schema.json +22 -24
  6. package/schemas/content-frontmatter.schema.json +25 -5
  7. package/schemas/page-theme.schema.json +53 -25
  8. package/schemas/sitewide-content.schema.json +19 -18
  9. package/schemas/theme.schema.json +383 -250
  10. package/scripts/check-config.mjs +24 -4
  11. package/scripts/deploy-site.mjs +0 -1
  12. package/scripts/dev-local.mjs +175 -112
  13. package/scripts/lib/content-sync-apply.mjs +34 -0
  14. package/scripts/lib/content-sync-plan.mjs +179 -0
  15. package/scripts/lib/editor-language-service.mjs +128 -0
  16. package/scripts/lib/heading-ids.mjs +1 -1
  17. package/scripts/lib/image-presentation.mjs +2 -0
  18. package/scripts/lib/markdown-links.mjs +182 -0
  19. package/scripts/lib/navigation-model.mjs +25 -14
  20. package/scripts/lib/norna-markdown-blocks.mjs +1 -0
  21. package/scripts/lib/page-aliases.mjs +164 -0
  22. package/scripts/lib/page-markdown.mjs +56 -6
  23. package/scripts/lib/presentation.mjs +19 -37
  24. package/scripts/lib/project-config.mjs +50 -29
  25. package/scripts/lib/schema-definitions.mjs +24 -13
  26. package/scripts/lib/schema-editor-metadata.mjs +43 -30
  27. package/scripts/lib/schema-value-definitions.mjs +6 -2
  28. package/scripts/lib/site-content.mjs +26 -4
  29. package/scripts/lib/site-link-graph.mjs +326 -0
  30. package/scripts/lib/site-page-urls.mjs +10 -0
  31. package/scripts/lib/sitemap.mjs +34 -0
  32. package/scripts/lib/theme-presets.mjs +51 -23
  33. package/scripts/lib/theme-profiles.mjs +7 -5
  34. package/scripts/lib/yaml-config.mjs +6 -2
  35. package/scripts/sync-content-sections.mjs +116 -213
  36. package/scripts/sync-site-public.mjs +23 -2
  37. package/src/components/CodeBlockCopyScript.astro +92 -0
  38. package/src/components/DisplaySettings.astro +4 -4
  39. package/src/components/ImageCarousel.astro +7 -3
  40. package/src/components/NavigationPageTree.astro +44 -60
  41. package/src/components/PageAliasRedirect.astro +49 -0
  42. package/src/components/PageContentsNavigation.astro +21 -0
  43. package/src/components/SectionNavigationScript.astro +62 -6
  44. package/src/components/SiteNavigation.astro +15 -3
  45. package/src/components/SitePage.astro +42 -2
  46. package/src/components/SiteSection.astro +5 -0
  47. package/src/components/SiteTreeNavigation.astro +0 -1
  48. package/src/content.config.ts +5 -2
  49. package/src/layouts/BaseLayout.astro +14 -8
  50. package/src/lib/readerPreferencesScript.mjs +2 -2
  51. package/src/lib/sitePages.ts +3 -2
  52. package/src/pages/[...slug].astro +35 -8
  53. package/src/styles/content.css +494 -0
  54. package/src/styles/foundations.css +284 -0
  55. package/src/styles/global.css +6 -2510
  56. package/src/styles/media.css +523 -0
  57. package/src/styles/navigation.css +495 -0
  58. package/src/styles/page-layout.css +437 -0
  59. package/src/styles/responsive.css +525 -0
  60. package/starters/basic/README.md +1 -1
@@ -0,0 +1,164 @@
1
+ import { getSiteNodePathname } from './site-page-urls.mjs';
2
+ import { sitemapFilename } from './sitemap.mjs';
3
+
4
+ export const generatedSiteRoutes = Object.freeze([
5
+ Object.freeze({
6
+ kind: 'generated-route',
7
+ label: `Norna generated ${sitemapFilename}`,
8
+ pathname: `/${sitemapFilename}`,
9
+ }),
10
+ ]);
11
+
12
+ const getPagePathname = (page) => page.pathname ?? getSiteNodePathname(page.contentFile ?? page);
13
+ const getPageLabel = (page) => page.contentLabel ?? page.contentFile?.contentLabel ?? page.entry?.id ?? getPagePathname(page);
14
+ const getPageAliases = (page) => page.aliases ?? page.data?.page?.aliases ?? [];
15
+
16
+ const createIdentity = ({ kind, label, pathname, source }) => ({
17
+ kind,
18
+ label,
19
+ pathname,
20
+ source,
21
+ });
22
+
23
+ const getPublicFileIdentities = (file) => {
24
+ const identities = [createIdentity({
25
+ kind: 'public-file',
26
+ label: file.label,
27
+ pathname: file.pathname,
28
+ source: file,
29
+ })];
30
+
31
+ if (file.pathname === '/index.html') {
32
+ identities.push(createIdentity({
33
+ kind: 'public-file',
34
+ label: file.label,
35
+ pathname: '/',
36
+ source: file,
37
+ }));
38
+ } else if (file.pathname.endsWith('/index.html')) {
39
+ identities.push(createIdentity({
40
+ kind: 'public-file',
41
+ label: file.label,
42
+ pathname: file.pathname.slice(0, -'index.html'.length),
43
+ source: file,
44
+ }));
45
+ }
46
+
47
+ return identities;
48
+ };
49
+
50
+ const describeIdentity = (identity) => {
51
+ if (identity.kind === 'page') return `page URL from ${identity.label}`;
52
+ if (identity.kind === 'category') return `navigation category path from ${identity.label}`;
53
+ if (identity.kind === 'public-file') return `public file ${identity.label}`;
54
+ if (identity.kind === 'generated-route') return `generated route ${identity.label}`;
55
+ if (identity.kind === 'page-alias') return `page alias declared in ${identity.label}`;
56
+ return `${identity.kind} from ${identity.label}`;
57
+ };
58
+
59
+ const compareAliases = (left, right) => (
60
+ left.pathname.localeCompare(right.pathname, 'en')
61
+ || left.targetPathname.localeCompare(right.targetPathname, 'en')
62
+ || left.contentLabel.localeCompare(right.contentLabel, 'en')
63
+ );
64
+
65
+ export const createPageAliasModel = ({
66
+ pages,
67
+ categories = [],
68
+ publicFiles = [],
69
+ generatedRoutes = generatedSiteRoutes,
70
+ }) => {
71
+ const identitiesByPathname = new Map();
72
+ const aliases = [];
73
+ const aliasesByPathname = new Map();
74
+ const diagnostics = [];
75
+
76
+ const addIdentity = (identity) => {
77
+ if (!identitiesByPathname.has(identity.pathname)) {
78
+ identitiesByPathname.set(identity.pathname, identity);
79
+ }
80
+ };
81
+
82
+ for (const page of pages) {
83
+ addIdentity(createIdentity({
84
+ kind: 'page',
85
+ label: getPageLabel(page),
86
+ pathname: getPagePathname(page),
87
+ source: page,
88
+ }));
89
+ }
90
+ for (const category of categories) {
91
+ addIdentity(createIdentity({
92
+ kind: 'category',
93
+ label: category.categorySourceLabel ?? category.nodeLabel ?? getSiteNodePathname(category),
94
+ pathname: getSiteNodePathname(category),
95
+ source: category,
96
+ }));
97
+ }
98
+ for (const file of publicFiles) {
99
+ for (const identity of getPublicFileIdentities(file)) addIdentity(identity);
100
+ }
101
+ for (const route of generatedRoutes) {
102
+ addIdentity(createIdentity({ ...route, source: route }));
103
+ }
104
+
105
+ for (const page of pages) {
106
+ const contentFile = page.contentFile ?? page;
107
+ const contentLabel = getPageLabel(page);
108
+ const targetPathname = getPagePathname(page);
109
+
110
+ for (const pathname of getPageAliases(page)) {
111
+ const alias = {
112
+ contentFile,
113
+ contentLabel,
114
+ page,
115
+ pathname,
116
+ targetPathname,
117
+ };
118
+ const conflict = identitiesByPathname.get(pathname);
119
+ if (conflict) {
120
+ diagnostics.push({
121
+ alias,
122
+ code: 'page-alias-collision',
123
+ contentFile,
124
+ fix: conflict.kind === 'page-alias'
125
+ ? 'Keep the old URL on exactly one current page.'
126
+ : 'Remove or change the alias. A public URL can identify only one site resource.',
127
+ message: `Page alias "${pathname}" in ${contentLabel} conflicts with ${describeIdentity(conflict)}.`,
128
+ severity: 'error',
129
+ });
130
+ continue;
131
+ }
132
+
133
+ aliases.push(alias);
134
+ aliasesByPathname.set(pathname, alias);
135
+ addIdentity(createIdentity({
136
+ kind: 'page-alias',
137
+ label: contentLabel,
138
+ pathname,
139
+ source: alias,
140
+ }));
141
+ }
142
+ }
143
+
144
+ aliases.sort(compareAliases);
145
+
146
+ return {
147
+ aliases,
148
+ aliasesByPathname,
149
+ diagnostics,
150
+ identitiesByPathname,
151
+ };
152
+ };
153
+
154
+ export const assertPageAliasModel = (model) => {
155
+ if (model.diagnostics.length === 0) return model;
156
+
157
+ throw new Error([
158
+ 'Page alias validation failed.',
159
+ ...model.diagnostics.flatMap((diagnostic) => [
160
+ `- ${diagnostic.message}`,
161
+ ` Fix: ${diagnostic.fix}`,
162
+ ]),
163
+ ].join('\n'));
164
+ };
@@ -2,6 +2,10 @@ import {
2
2
  getHeadingIdentifierIssues,
3
3
  getMarkdownHeadings,
4
4
  } from './heading-ids.mjs';
5
+ import {
6
+ extractMarkdownLinks,
7
+ extractNornaBlockLinks,
8
+ } from './markdown-links.mjs';
5
9
  import {
6
10
  extractInlineNoteDiagnostics,
7
11
  extractMarkdownImageReferences,
@@ -9,7 +13,28 @@ import {
9
13
  getNornaBlockImageReferences,
10
14
  } from './norna-markdown-blocks.mjs';
11
15
 
12
- const normalizeMarkdown = (source) => source.replace(/\r\n?/g, '\n');
16
+ const normalizeMarkdownWithOffsets = (source) => {
17
+ const input = String(source);
18
+ let normalized = '';
19
+ const originalOffsets = [0];
20
+
21
+ for (let index = 0; index < input.length;) {
22
+ if (input[index] === '\r') {
23
+ index += input[index + 1] === '\n' ? 2 : 1;
24
+ normalized += '\n';
25
+ originalOffsets.push(index);
26
+ continue;
27
+ }
28
+
29
+ normalized += input[index];
30
+ index += 1;
31
+ originalOffsets.push(index);
32
+ }
33
+
34
+ return { normalized, originalOffsets };
35
+ };
36
+
37
+ const normalizeMarkdown = (source) => normalizeMarkdownWithOffsets(source).normalized;
13
38
 
14
39
  const hasContent = (source) => source.trim().length > 0;
15
40
 
@@ -194,7 +219,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
194
219
  const source = normalizeMarkdown(markdown);
195
220
  const label = options.label ?? 'Markdown';
196
221
  const lineOffset = options.lineOffset ?? 0;
197
- const { headings } = await getMarkdownHeadings(source);
222
+ const { headings, tree } = await getMarkdownHeadings(source);
198
223
  const structuralHeadings = headings.filter((heading) => heading.depth <= 2);
199
224
  const prelude = structuralHeadings.length > 0
200
225
  ? source.slice(0, structuralHeadings[0].index)
@@ -225,6 +250,14 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
225
250
  regionId: region.id,
226
251
  severity: 'error',
227
252
  })));
253
+ const blocks = regions.flatMap((region) => region.blocks);
254
+ const links = [
255
+ ...extractMarkdownLinks({ source, tree, lineOffset }),
256
+ ...extractNornaBlockLinks({ source, blocks, lineOffset }),
257
+ ].sort((left, right) => (
258
+ left.line - right.line
259
+ || (left.range?.start ?? 0) - (right.range?.start ?? 0)
260
+ ));
228
261
  const navigationHeadings = [];
229
262
  let parentId = null;
230
263
  for (const heading of headings) {
@@ -240,7 +273,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
240
273
  }
241
274
 
242
275
  return {
243
- blocks: regions.flatMap((region) => region.blocks),
276
+ blocks,
244
277
  diagnostics: [
245
278
  ...structureDiagnostics,
246
279
  ...headingDiagnostics,
@@ -252,6 +285,7 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
252
285
  intro: regions.find((region) => region.kind === 'page-intro') ?? null,
253
286
  label,
254
287
  lineOffset,
288
+ links,
255
289
  managedImages: regions.flatMap((region) => region.managedImages),
256
290
  markdownImages: regions.flatMap((region) => region.markdownImages),
257
291
  navigationHeadings,
@@ -266,17 +300,33 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
266
300
  };
267
301
 
268
302
  export const parsePageMarkdownSource = async (source, options = {}) => {
269
- const split = splitPageMarkdownSource(source);
303
+ const normalization = normalizeMarkdownWithOffsets(source);
304
+ const split = splitPageMarkdownSource(normalization.normalized);
270
305
  const model = await parsePageMarkdown(split.body, {
271
306
  ...options,
272
307
  lineOffset: (options.lineOffset ?? 0) + split.lineOffset,
273
308
  });
274
309
 
310
+ const toOriginalRange = (range) => {
311
+ if (!range) return null;
312
+ const normalizedStart = split.bodyOffset + range.start;
313
+ const normalizedEnd = split.bodyOffset + range.end;
314
+ return {
315
+ start: normalization.originalOffsets[normalizedStart] ?? normalizedStart,
316
+ end: normalization.originalOffsets[normalizedEnd] ?? normalizedEnd,
317
+ };
318
+ };
319
+
275
320
  return {
276
321
  ...model,
277
- bodyOffset: split.bodyOffset,
322
+ bodyOffset: normalization.originalOffsets[split.bodyOffset] ?? split.bodyOffset,
278
323
  frontmatter: split.frontmatter,
279
324
  frontmatterUnclosed: split.frontmatterUnclosed,
280
- fullSource: normalizeMarkdown(source),
325
+ fullSource: String(source),
326
+ links: model.links.map((link) => ({
327
+ ...link,
328
+ range: toOriginalRange(link.range),
329
+ targetRange: toOriginalRange(link.targetRange),
330
+ })),
281
331
  };
282
332
  };
@@ -12,7 +12,7 @@ import { presentationPaletteNames } from './presentation-palette-metadata.mjs';
12
12
  import { resolveThemeConfig } from './theme-presets.mjs';
13
13
 
14
14
  export { presentationPaletteNames };
15
- export const colorModeNames = ['system', 'light', 'dark'];
15
+ export const appearanceNames = ['system', 'light', 'dark'];
16
16
  const sectionBackgroundPatterns = Object.freeze({
17
17
  uniform: ['base'],
18
18
  alternating: ['base', 'soft'],
@@ -44,7 +44,7 @@ const createPaletteMode = ({ appearance, page, surfaces, frame, css }) => {
44
44
 
45
45
  const presentationPalettes = Object.freeze({
46
46
  'near-monochrome': Object.freeze({
47
- defaultMode: 'dark',
47
+ defaultAppearance: 'dark',
48
48
  modes: Object.freeze({
49
49
  light: createPaletteMode({
50
50
  appearance: 'light',
@@ -61,7 +61,6 @@ const presentationPalettes = Object.freeze({
61
61
  soft: '#d8d8d3',
62
62
  accent: '#4b4b46',
63
63
  border: 'rgb(23 23 23 / 16%)',
64
- navBackground: 'rgb(247 247 245 / 92%)',
65
64
  navSeparator: 'rgb(23 23 23 / 24%)',
66
65
  },
67
66
  }),
@@ -80,14 +79,13 @@ const presentationPalettes = Object.freeze({
80
79
  soft: '#302f2c',
81
80
  accent: '#d8d2c8',
82
81
  border: 'rgb(255 255 255 / 14%)',
83
- navBackground: 'rgb(0 0 0 / 90%)',
84
82
  navSeparator: 'rgb(255 255 255 / 28%)',
85
83
  },
86
84
  }),
87
85
  }),
88
86
  }),
89
87
  'arctic-blue': Object.freeze({
90
- defaultMode: 'light',
88
+ defaultAppearance: 'light',
91
89
  modes: Object.freeze({
92
90
  light: createPaletteMode({
93
91
  appearance: 'light',
@@ -104,7 +102,6 @@ const presentationPalettes = Object.freeze({
104
102
  soft: '#d5dfe7',
105
103
  accent: '#2a5877',
106
104
  border: 'rgb(32 41 50 / 16%)',
107
- navBackground: 'rgb(243 246 248 / 92%)',
108
105
  navSeparator: 'rgb(32 41 50 / 24%)',
109
106
  },
110
107
  }),
@@ -123,14 +120,13 @@ const presentationPalettes = Object.freeze({
123
120
  soft: '#354655',
124
121
  accent: '#9bc5de',
125
122
  border: 'rgb(237 243 247 / 16%)',
126
- navBackground: 'rgb(23 32 42 / 92%)',
127
123
  navSeparator: 'rgb(237 243 247 / 28%)',
128
124
  },
129
125
  }),
130
126
  }),
131
127
  }),
132
128
  'mineral-teal': Object.freeze({
133
- defaultMode: 'light',
129
+ defaultAppearance: 'light',
134
130
  modes: Object.freeze({
135
131
  light: createPaletteMode({
136
132
  appearance: 'light',
@@ -147,7 +143,6 @@ const presentationPalettes = Object.freeze({
147
143
  soft: '#cfdfd9',
148
144
  accent: '#155f5a',
149
145
  border: 'rgb(32 54 49 / 17%)',
150
- navBackground: 'rgb(238 244 241 / 92%)',
151
146
  navSeparator: 'rgb(32 54 49 / 24%)',
152
147
  },
153
148
  }),
@@ -166,14 +161,13 @@ const presentationPalettes = Object.freeze({
166
161
  soft: '#2c554f',
167
162
  accent: '#8bd5ca',
168
163
  border: 'rgb(237 245 241 / 16%)',
169
- navBackground: 'rgb(16 45 44 / 92%)',
170
164
  navSeparator: 'rgb(237 245 241 / 28%)',
171
165
  },
172
166
  }),
173
167
  }),
174
168
  }),
175
169
  'soft-lavender': Object.freeze({
176
- defaultMode: 'light',
170
+ defaultAppearance: 'light',
177
171
  modes: Object.freeze({
178
172
  light: createPaletteMode({
179
173
  appearance: 'light',
@@ -190,7 +184,6 @@ const presentationPalettes = Object.freeze({
190
184
  soft: '#ded5e7',
191
185
  accent: '#604678',
192
186
  border: 'rgb(46 39 54 / 16%)',
193
- navBackground: 'rgb(247 243 250 / 92%)',
194
187
  navSeparator: 'rgb(46 39 54 / 24%)',
195
188
  },
196
189
  }),
@@ -209,14 +202,13 @@ const presentationPalettes = Object.freeze({
209
202
  soft: '#4b3d57',
210
203
  accent: '#d4b7e3',
211
204
  border: 'rgb(244 237 248 / 16%)',
212
- navBackground: 'rgb(37 31 46 / 92%)',
213
205
  navSeparator: 'rgb(244 237 248 / 28%)',
214
206
  },
215
207
  }),
216
208
  }),
217
209
  }),
218
210
  'warm-paper': Object.freeze({
219
- defaultMode: 'light',
211
+ defaultAppearance: 'light',
220
212
  modes: Object.freeze({
221
213
  light: createPaletteMode({
222
214
  appearance: 'light',
@@ -233,7 +225,6 @@ const presentationPalettes = Object.freeze({
233
225
  soft: '#d8d0c4',
234
226
  accent: '#5f513c',
235
227
  border: 'rgb(39 37 34 / 18%)',
236
- navBackground: 'rgb(248 245 238 / 92%)',
237
228
  navSeparator: 'rgb(39 37 34 / 24%)',
238
229
  },
239
230
  }),
@@ -252,14 +243,13 @@ const presentationPalettes = Object.freeze({
252
243
  soft: '#3b342b',
253
244
  accent: '#d5bea0',
254
245
  border: 'rgb(243 237 226 / 16%)',
255
- navBackground: 'rgb(27 25 22 / 92%)',
256
246
  navSeparator: 'rgb(243 237 226 / 26%)',
257
247
  },
258
248
  }),
259
249
  }),
260
250
  }),
261
251
  'retro-earth': Object.freeze({
262
- defaultMode: 'light',
252
+ defaultAppearance: 'light',
263
253
  modes: Object.freeze({
264
254
  light: createPaletteMode({
265
255
  appearance: 'light',
@@ -276,7 +266,6 @@ const presentationPalettes = Object.freeze({
276
266
  soft: '#deca9c',
277
267
  accent: '#5e4514',
278
268
  border: 'rgb(52 41 28 / 18%)',
279
- navBackground: 'rgb(247 239 217 / 92%)',
280
269
  navSeparator: 'rgb(52 41 28 / 25%)',
281
270
  },
282
271
  }),
@@ -295,14 +284,13 @@ const presentationPalettes = Object.freeze({
295
284
  soft: '#54422a',
296
285
  accent: '#e5c36c',
297
286
  border: 'rgb(245 232 201 / 16%)',
298
- navBackground: 'rgb(40 33 23 / 92%)',
299
287
  navSeparator: 'rgb(245 232 201 / 28%)',
300
288
  },
301
289
  }),
302
290
  }),
303
291
  }),
304
292
  'clay-rose': Object.freeze({
305
- defaultMode: 'light',
293
+ defaultAppearance: 'light',
306
294
  modes: Object.freeze({
307
295
  light: createPaletteMode({
308
296
  appearance: 'light',
@@ -319,7 +307,6 @@ const presentationPalettes = Object.freeze({
319
307
  soft: '#e2cfca',
320
308
  accent: '#71313d',
321
309
  border: 'rgb(60 41 41 / 17%)',
322
- navBackground: 'rgb(247 239 237 / 92%)',
323
310
  navSeparator: 'rgb(60 41 41 / 25%)',
324
311
  },
325
312
  }),
@@ -338,14 +325,13 @@ const presentationPalettes = Object.freeze({
338
325
  soft: '#58404a',
339
326
  accent: '#e6a7b0',
340
327
  border: 'rgb(247 235 235 / 16%)',
341
- navBackground: 'rgb(45 30 34 / 92%)',
342
328
  navSeparator: 'rgb(247 235 235 / 28%)',
343
329
  },
344
330
  }),
345
331
  }),
346
332
  }),
347
333
  'forest-moss': Object.freeze({
348
- defaultMode: 'light',
334
+ defaultAppearance: 'light',
349
335
  modes: Object.freeze({
350
336
  light: createPaletteMode({
351
337
  appearance: 'light',
@@ -362,7 +348,6 @@ const presentationPalettes = Object.freeze({
362
348
  soft: '#d5ddc5',
363
349
  accent: '#324e25',
364
350
  border: 'rgb(38 50 37 / 17%)',
365
- navBackground: 'rgb(241 243 232 / 92%)',
366
351
  navSeparator: 'rgb(38 50 37 / 24%)',
367
352
  },
368
353
  }),
@@ -381,14 +366,13 @@ const presentationPalettes = Object.freeze({
381
366
  soft: '#394c37',
382
367
  accent: '#add493',
383
368
  border: 'rgb(238 242 230 / 16%)',
384
- navBackground: 'rgb(23 34 24 / 92%)',
385
369
  navSeparator: 'rgb(238 242 230 / 28%)',
386
370
  },
387
371
  }),
388
372
  }),
389
373
  }),
390
374
  'vivid-night': Object.freeze({
391
- defaultMode: 'dark',
375
+ defaultAppearance: 'dark',
392
376
  modes: Object.freeze({
393
377
  light: createPaletteMode({
394
378
  appearance: 'light',
@@ -405,7 +389,6 @@ const presentationPalettes = Object.freeze({
405
389
  soft: '#d5daec',
406
390
  accent: '#3b568f',
407
391
  border: 'rgb(37 36 58 / 16%)',
408
- navBackground: 'rgb(242 244 251 / 92%)',
409
392
  navSeparator: 'rgb(37 36 58 / 24%)',
410
393
  },
411
394
  }),
@@ -424,7 +407,6 @@ const presentationPalettes = Object.freeze({
424
407
  soft: '#403a58',
425
408
  accent: '#9fe4e8',
426
409
  border: 'rgb(244 241 255 / 16%)',
427
- navBackground: 'rgb(29 27 45 / 92%)',
428
410
  navSeparator: 'rgb(244 241 255 / 28%)',
429
411
  },
430
412
  }),
@@ -493,11 +475,11 @@ export const resolveThemePresentation = (theme, sourceLabel = 'theme.yaml') => {
493
475
  const normalizedTheme = resolveThemeConfig(theme, sourceLabel);
494
476
  const paletteName = normalizedTheme.palette ?? 'near-monochrome';
495
477
  const palette = getPresentationPalette(paletteName);
496
- const colorMode = normalizedTheme.colorMode ?? {};
478
+ const appearance = normalizedTheme.appearance ?? {};
497
479
  const readerControls = normalizedTheme.readerControls ?? {};
498
- const defaultColorMode = colorMode.default ?? palette.defaultMode;
499
- if (!colorModeNames.includes(defaultColorMode)) {
500
- throw new Error(`colorMode.default must be one of ${colorModeNames.join(', ')} in ${sourceLabel}.`);
480
+ const defaultAppearance = appearance.default ?? palette.defaultAppearance;
481
+ if (!appearanceNames.includes(defaultAppearance)) {
482
+ throw new Error(`appearance.default must be one of ${appearanceNames.join(', ')} in ${sourceLabel}.`);
501
483
  }
502
484
  const typography = resolveTypographyConfig(normalizedTheme.typography ?? defaultTypography, sourceLabel);
503
485
  const textWidth = normalizedTheme.layout?.textWidth;
@@ -508,19 +490,19 @@ export const resolveThemePresentation = (theme, sourceLabel = 'theme.yaml') => {
508
490
 
509
491
  return {
510
492
  paletteName,
511
- palette: palette.modes[defaultColorMode === 'dark' ? 'dark' : 'light'],
493
+ palette: palette.modes[defaultAppearance === 'dark' ? 'dark' : 'light'],
512
494
  paletteModes: palette.modes,
513
- colorMode: {
514
- default: defaultColorMode,
495
+ appearance: {
496
+ default: defaultAppearance,
515
497
  },
516
498
  readerPreferences: {
517
499
  controls: {
518
- appearance: readerControls.colorMode === true,
500
+ appearance: readerControls.appearance === true,
519
501
  readingWidth: true,
520
502
  focusReading: readerControls.focusReading === true,
521
503
  },
522
504
  defaults: {
523
- appearance: defaultColorMode,
505
+ appearance: defaultAppearance,
524
506
  readingWidth: textWidth === 'narrow' ? 'narrow' : textWidth === 'wide' ? 'wide' : 'standard',
525
507
  focusReading: 'off',
526
508
  },
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import {
3
3
  validateConfigYamlStructure,
4
4
  } from './site-content.mjs';
5
+ import { defaultImagePresentation, imagePresentationNames } from './image-presentation.mjs';
5
6
  import { navigationModeNames } from './navigation-model.mjs';
6
7
  import {
7
8
  siteConfigLabel,
@@ -51,16 +52,6 @@ const readEnum = (object, key, path, allowedValues, fallback, sourceLabel = site
51
52
  return value;
52
53
  };
53
54
 
54
- const readBoolean = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
55
- const value = object[key] ?? fallback;
56
-
57
- if (typeof value !== 'boolean') {
58
- throw new Error(`${path}.${key} must be true or false in ${sourceLabel}.`);
59
- }
60
-
61
- return value;
62
- };
63
-
64
55
  const readFontFamily = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
65
56
  const value = object[key] ?? fallback;
66
57
 
@@ -194,10 +185,13 @@ const readSiteUrl = (config) => {
194
185
  const localeLabels = Object.freeze({
195
186
  en: Object.freeze({
196
187
  breadcrumb: 'Breadcrumb',
197
- colorMode: 'Color mode',
198
- colorModeDark: 'Dark',
199
- colorModeLight: 'Light',
200
- colorModeSystem: 'System',
188
+ appearance: 'Appearance',
189
+ appearanceDark: 'Dark',
190
+ appearanceLight: 'Light',
191
+ appearanceSystem: 'System',
192
+ codeCopied: 'Copied',
193
+ codeCopyFailed: 'Could not copy code',
194
+ copyCode: 'Copy code',
201
195
  displaySettings: 'Display',
202
196
  focusReading: 'Focus reading',
203
197
  readingWidth: 'Reading width',
@@ -213,6 +207,8 @@ const localeLabels = Object.freeze({
213
207
  navigationMenu: 'Menu',
214
208
  nextImage: 'Next image',
215
209
  note: 'Note',
210
+ pageMoved: 'Page moved',
211
+ pageMovedText: 'This address now identifies',
216
212
  pageNavigation: 'Page contents',
217
213
  previousImage: 'Previous image',
218
214
  siteBanners: 'Site notices',
@@ -221,10 +217,13 @@ const localeLabels = Object.freeze({
221
217
  }),
222
218
  sv: Object.freeze({
223
219
  breadcrumb: 'Brödsmulor',
224
- colorMode: 'Färgläge',
225
- colorModeDark: 'Mörkt',
226
- colorModeLight: 'Ljust',
227
- colorModeSystem: 'System',
220
+ appearance: 'Utseende',
221
+ appearanceDark: 'Mörkt',
222
+ appearanceLight: 'Ljust',
223
+ appearanceSystem: 'System',
224
+ codeCopied: 'Kopierat',
225
+ codeCopyFailed: 'Kunde inte kopiera koden',
226
+ copyCode: 'Kopiera kod',
228
227
  displaySettings: 'Visning',
229
228
  focusReading: 'Fokuserad läsning',
230
229
  readingWidth: 'Textbredd',
@@ -240,6 +239,8 @@ const localeLabels = Object.freeze({
240
239
  navigationMenu: 'Meny',
241
240
  nextImage: 'Nästa bild',
242
241
  note: 'Not',
242
+ pageMoved: 'Sidan har flyttats',
243
+ pageMovedText: 'Den här adressen identifierar nu',
243
244
  pageNavigation: 'Sidinnehåll',
244
245
  previousImage: 'Föregående bild',
245
246
  siteBanners: 'Meddelanden',
@@ -365,6 +366,21 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
365
366
  const resolvedContentSpacing = readEnum(rawLayoutConfig, 'contentSpacing', 'layout', contentSpacingNames, 'normal', sourceLabel);
366
367
  const resolvedLayoutSpacingDefaults = contentSpacingProfiles[resolvedContentSpacing];
367
368
  const corners = readEnum(rawThemeConfig, 'corners', 'theme', ['square', 'rounded'], 'rounded', sourceLabel);
369
+ const imagePresentation = readEnum(
370
+ rawImagesConfig,
371
+ 'presentation',
372
+ 'images',
373
+ imagePresentationNames,
374
+ defaultImagePresentation,
375
+ sourceLabel,
376
+ );
377
+ if (imagePresentation === 'prose-aligned' && rawImagesConfig.maxAvailableHeightPercent !== undefined) {
378
+ throw new Error([
379
+ `images.maxAvailableHeightPercent cannot be used with images.presentation "prose-aligned" in ${sourceLabel}.`,
380
+ 'Prose-aligned images follow available horizontal space instead of a viewport-height limit.',
381
+ 'Remove images.maxAvailableHeightPercent or set images.presentation to "centered-fit".',
382
+ ].join('\n'));
383
+ }
368
384
 
369
385
  return Object.freeze({
370
386
  layout: Object.freeze({
@@ -392,10 +408,15 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
392
408
  }),
393
409
  }),
394
410
  images: Object.freeze({
395
- maxAvailableHeightPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableHeightPercent', 'images', Object.freeze({
396
- desktop: 74,
397
- mobile: 68,
398
- }), sourceLabel),
411
+ presentation: imagePresentation,
412
+ ...(imagePresentation === 'centered-fit'
413
+ ? {
414
+ maxAvailableHeightPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableHeightPercent', 'images', Object.freeze({
415
+ desktop: 74,
416
+ mobile: 68,
417
+ }), sourceLabel),
418
+ }
419
+ : {}),
399
420
  maxAvailableWidthPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableWidthPercent', 'images', Object.freeze({
400
421
  desktop: 100,
401
422
  mobile: 100,
@@ -416,6 +437,13 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
416
437
 
417
438
  export const resolveNavigationConfig = (config, sourceLabel = siteConfigLabel) => {
418
439
  const rawNavigation = assertObject(config.navigation ?? {}, 'navigation', sourceLabel);
440
+ if (Object.hasOwn(rawNavigation, 'sectionTracking')) {
441
+ throw new Error([
442
+ `navigation.sectionTracking is no longer supported in ${sourceLabel}.`,
443
+ 'Section tracking is automatic on pages with a contents rail.',
444
+ 'Remove navigation.sectionTracking.',
445
+ ].join('\n'));
446
+ }
419
447
 
420
448
  return Object.freeze({
421
449
  mode: readEnum(
@@ -426,13 +454,6 @@ export const resolveNavigationConfig = (config, sourceLabel = siteConfigLabel) =
426
454
  'automatic',
427
455
  sourceLabel,
428
456
  ),
429
- sectionTracking: readBoolean(
430
- rawNavigation,
431
- 'sectionTracking',
432
- 'navigation',
433
- false,
434
- sourceLabel,
435
- ),
436
457
  });
437
458
  };
438
459