@janga/norna 0.7.7 → 0.7.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.
Files changed (86) hide show
  1. package/README.md +6 -5
  2. package/docs/README.md +5 -1
  3. package/docs/commands.md +4 -3
  4. package/docs/configuration.md +1 -1
  5. package/docs/content.md +64 -14
  6. package/docs/design/norna-diagram-design.md +156 -0
  7. package/docs/getting-started.md +48 -7
  8. package/docs/images-and-metadata.md +11 -6
  9. package/docs/routes.md +14 -3
  10. package/docs/site-structure.md +6 -3
  11. package/docs/theme.md +7 -4
  12. package/docs/typography.md +4 -5
  13. package/examples/dog-gallery/site/content.md +1 -36
  14. package/examples/dog-gallery/site/routes/010-dog-care/route-content.md +0 -8
  15. package/examples/typography-presets/.astro/collections/site.schema.json +776 -0
  16. package/examples/typography-presets/.astro/collections/theme.schema.json +668 -0
  17. package/examples/typography-presets/.astro/content-assets.mjs +4 -0
  18. package/examples/typography-presets/.astro/content-modules.mjs +1 -0
  19. package/examples/typography-presets/.astro/content.d.ts +175 -0
  20. package/examples/typography-presets/.astro/data-store.json +1 -0
  21. package/examples/typography-presets/.astro/dev-local.json +8 -0
  22. package/examples/typography-presets/.astro/dev.json +15 -0
  23. package/examples/typography-presets/.astro/dev.log +24 -0
  24. package/examples/typography-presets/.astro/settings.json +5 -0
  25. package/examples/typography-presets/.astro/types.d.ts +2 -0
  26. package/examples/typography-presets/node_modules/.vite/deps/_metadata.json +80 -0
  27. package/examples/typography-presets/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
  28. package/examples/typography-presets/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
  29. package/examples/typography-presets/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  30. package/examples/typography-presets/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  31. package/examples/typography-presets/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
  32. package/examples/typography-presets/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  33. package/examples/typography-presets/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  34. package/examples/typography-presets/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  35. package/examples/typography-presets/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  36. package/examples/typography-presets/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  37. package/examples/typography-presets/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
  38. package/examples/typography-presets/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
  39. package/examples/typography-presets/node_modules/.vite/deps/embla-carousel.js +1389 -0
  40. package/examples/typography-presets/node_modules/.vite/deps/embla-carousel.js.map +1 -0
  41. package/examples/typography-presets/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
  42. package/examples/typography-presets/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
  43. package/examples/typography-presets/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
  44. package/examples/typography-presets/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
  45. package/examples/typography-presets/node_modules/.vite/deps/package.json +3 -0
  46. package/examples/typography-presets/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
  47. package/examples/typography-presets/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
  48. package/examples/typography-presets/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
  49. package/examples/typography-presets/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
  50. package/examples/typography-presets/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
  51. package/examples/typography-presets/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
  52. package/examples/typography-presets/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
  53. package/examples/typography-presets/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
  54. package/examples/typography-presets/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
  55. package/examples/typography-presets/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
  56. package/examples/typography-presets/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
  57. package/examples/typography-presets/site/.norna/generated-images.json +290 -0
  58. package/examples/typography-presets/site/config.mjs +42 -0
  59. package/examples/typography-presets/site/content.md +134 -0
  60. package/examples/typography-presets/site/images/compact-gallery/black-schnauzer.jpg +0 -0
  61. package/examples/typography-presets/site/images/compact-gallery/retriever-portrait.jpg +0 -0
  62. package/examples/typography-presets/site/images/compact-gallery/schnauzer-portrait.jpg +0 -0
  63. package/examples/typography-presets/site/images/quiet-gallery/puppy-portrait.jpg +0 -0
  64. package/examples/typography-presets/site/images/quiet-gallery/shepherd-portrait.jpg +0 -0
  65. package/examples/typography-presets/site/images/quiet-gallery/white-puppy.jpg +0 -0
  66. package/examples/typography-presets/site/images/statement/black-pedigree.jpg +0 -0
  67. package/examples/typography-presets/site/images/statement/sheepdog-grass.jpg +0 -0
  68. package/examples/typography-presets/site/images/statement/sheepdog-wide.jpg +0 -0
  69. package/examples/typography-presets/site/images/text-forward/boxer-portrait.jpg +0 -0
  70. package/examples/typography-presets/site/images/text-forward/grass-puppy.jpg +0 -0
  71. package/examples/typography-presets/site/images/text-forward/grey-street-dog.jpg +0 -0
  72. package/examples/typography-presets/site/public/robots.txt +2 -0
  73. package/examples/typography-presets/site/theme.md +43 -0
  74. package/package.json +1 -1
  75. package/scripts/lib/site-content.mjs +76 -0
  76. package/scripts/lib/theme-config.mjs +2 -0
  77. package/scripts/sync-content-sections.mjs +158 -22
  78. package/scripts/test-content-check.mjs +31 -1
  79. package/scripts/test-content-model-v2.mjs +238 -6
  80. package/scripts/test-package-check.mjs +17 -0
  81. package/src/components/SiteSection.astro +1 -1
  82. package/src/lib/sectionContent.ts +10 -2
  83. package/src/styles/global.css +1 -1
  84. package/starters/basic/README.md +4 -2
  85. package/starters/basic/site/content.md +2 -8
  86. package/starters/project/README.md +7 -3
@@ -0,0 +1,134 @@
1
+ ---
2
+ title: Typography Presets
3
+ description: A Norna example page showing every built-in typography preset.
4
+ navigation:
5
+ label: Presets
6
+ sections:
7
+ quiet-gallery:
8
+ presentation:
9
+ typography:
10
+ preset: quiet-gallery
11
+ compact-gallery:
12
+ presentation:
13
+ typography:
14
+ preset: compact-gallery
15
+ text-forward:
16
+ presentation:
17
+ typography:
18
+ preset: text-forward
19
+ statement:
20
+ presentation:
21
+ typography:
22
+ preset: statement
23
+ ---
24
+
25
+ ## Quiet Gallery {#quiet-gallery}
26
+
27
+ This section uses the `quiet-gallery` preset. Its purpose is to keep text
28
+ restrained so images can carry much of the page without the typography
29
+ competing for attention.
30
+
31
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a
32
+ ante venenatis dapibus posuere velit aliquet. Maecenas faucibus mollis
33
+ interdum. Donec ullamcorper nulla non metus auctor fringilla.
34
+
35
+ ### Reading rhythm
36
+
37
+ Curabitur blandit tempus porttitor. Praesent commodo cursus magna, vel
38
+ scelerisque nisl consectetur et. Vestibulum id ligula porta felis euismod
39
+ semper.
40
+
41
+ ```norna-image-stack
42
+ - image: puppy-portrait.jpg
43
+ alt: A puppy sitting outdoors and looking toward the camera.
44
+ caption: Puppy portrait. Photo by Bicanski, CC0, via Pixnio.
45
+ - image: shepherd-portrait.jpg
46
+ alt: A shepherd dog standing outdoors and looking toward the camera.
47
+ caption: Shepherd dog portrait. Photo by Bicanski, CC0, via Pixnio.
48
+ - image: white-puppy.jpg
49
+ alt: A white puppy outdoors in winter.
50
+ caption: White puppy outdoors. Photo by Bicanski, CC0, via Pixnio.
51
+ ```
52
+
53
+ ## Compact Gallery {#compact-gallery}
54
+
55
+ This section uses the `compact-gallery` preset. Its purpose is to make denser
56
+ image-led pages easier to scan when a site contains many sections, many images
57
+ or short information blocks.
58
+
59
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean lacinia
60
+ bibendum nulla sed consectetur. Etiam porta sem malesuada magna mollis euismod.
61
+
62
+ ### Dense sections
63
+
64
+ Nullam id dolor id nibh ultricies vehicula ut id elit. Fusce dapibus, tellus ac
65
+ cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit
66
+ amet risus.
67
+
68
+ ```norna-image-stack
69
+ - image: schnauzer-portrait.jpg
70
+ alt: A schnauzer-type dog sitting outdoors.
71
+ caption: Schnauzer portrait. Photo by Bicanski, CC0, via Pixnio.
72
+ - image: black-schnauzer.jpg
73
+ alt: A black schnauzer-type dog standing on grass.
74
+ caption: Black hunting dog portrait. Photo by Bicanski, CC0, via Pixnio.
75
+ - image: retriever-portrait.jpg
76
+ alt: A retriever-type dog sitting outdoors.
77
+ caption: Retriever portrait. Photo by Bicanski, CC0, via Pixnio.
78
+ ```
79
+
80
+ ## Text Forward {#text-forward}
81
+
82
+ This section uses the `text-forward` preset. Its purpose is to give longer
83
+ explanatory text more presence while still supporting image captions and
84
+ image-led sections.
85
+
86
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed posuere
87
+ consectetur est at lobortis. Cras mattis consectetur purus sit amet fermentum.
88
+ Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.
89
+
90
+ ### Longer explanations
91
+
92
+ Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Donec sed odio
93
+ dui. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget
94
+ lacinia odio sem nec elit.
95
+
96
+ ```norna-image-stack
97
+ - image: grass-puppy.jpg
98
+ alt: A puppy standing in green grass.
99
+ caption: Puppy in grass. Photo by Bicanski, CC0, via Pixnio.
100
+ - image: grey-street-dog.jpg
101
+ alt: A grey dog standing on a paved street.
102
+ caption: Grey dog on pavement. Photo by Bicanski, CC0, via Pixnio.
103
+ - image: boxer-portrait.jpg
104
+ alt: A brown boxer-type dog looking at the camera.
105
+ caption: Brown dog portrait. Photo by Bicanski, CC0, via Pixnio.
106
+ ```
107
+
108
+ ## Statement {#statement}
109
+
110
+ This section uses the `statement` preset. Its purpose is to make short,
111
+ declarative sections feel more direct and programmatic. It is intentionally
112
+ stronger than the other presets and should usually be used sparingly.
113
+
114
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec id elit non mi
115
+ porta gravida at eget metus. Integer posuere erat a ante venenatis dapibus
116
+ posuere velit aliquet.
117
+
118
+ ### Short emphasis
119
+
120
+ Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Aenean
121
+ lacinia bibendum nulla sed consectetur. Maecenas sed diam eget risus varius
122
+ blandit sit amet non magna.
123
+
124
+ ```norna-image-stack
125
+ - image: sheepdog-grass.jpg
126
+ alt: A sheepdog puppy sitting in green grass.
127
+ caption: Sheepdog puppy in grass. Photo by Bicanski, CC0, via Pixnio.
128
+ - image: sheepdog-wide.jpg
129
+ alt: A sheepdog puppy lying outdoors.
130
+ caption: Sheepdog puppy outdoors. Photo by Bicanski, CC0, via Pixnio.
131
+ - image: black-pedigree.jpg
132
+ alt: A black dog standing in grass.
133
+ caption: Black pedigree dog portrait. Photo by Bicanski, CC0, via Pixnio.
134
+ ```
@@ -0,0 +1,2 @@
1
+ User-agent: *
2
+ Disallow:
@@ -0,0 +1,43 @@
1
+ ---
2
+ navigation:
3
+ brand: Typography Presets
4
+ layout:
5
+ density: normal
6
+ pageWidth: 1320px
7
+ gutter:
8
+ desktop: clamp(1.25rem, 4vw, 3rem)
9
+ mobile: 1rem
10
+ spacing:
11
+ firstSectionTop:
12
+ desktop: clamp(1.5rem, 3vw, 2.5rem)
13
+ mobile: 1rem
14
+ sectionGap:
15
+ desktop: clamp(2rem, 4vw, 3.5rem)
16
+ mobile: 2rem
17
+ finalSectionBottom:
18
+ desktop: clamp(2rem, 4vw, 3.5rem)
19
+ mobile: 2rem
20
+ bodyToImages:
21
+ desktop: 1.25rem
22
+ mobile: 1rem
23
+ imageGap:
24
+ desktop: clamp(1.25rem, 3vw, 2.25rem)
25
+ mobile: 1.5rem
26
+ gallery:
27
+ width: 70vw
28
+ maxAvailableWidthPercent:
29
+ desktop: 100
30
+ mobile: 100
31
+ maxAvailableHeightPercent:
32
+ desktop: 72
33
+ mobile: 68
34
+ typography:
35
+ fontFamily: "Arial, 'Helvetica Neue', Helvetica, sans-serif"
36
+ preset: quiet-gallery
37
+ rhythm: normal
38
+ presentation:
39
+ backgroundColor: "#f8f5ee"
40
+ textColor: "#17201d"
41
+ frame:
42
+ colors: presentation
43
+ ---
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@janga/norna",
3
3
  "description": "A command-line publishing workflow for simple static sites.",
4
4
  "type": "module",
5
- "version": "0.7.7",
5
+ "version": "0.7.9",
6
6
  "license": "GPL-3.0-only",
7
7
  "repository": {
8
8
  "type": "git",
@@ -22,6 +22,9 @@ const inlineStyleReferenceRegex = /\[[^\]\n]+\]\{\.([a-z][a-z0-9-]*)\}/g;
22
22
  const frontmatterDelimiterRegex = /^---\s*$/;
23
23
  const knownContentTopLevelFrontmatterKeys = new Set(['title', 'description', 'navigation', 'presentation', 'frame', 'sections']);
24
24
  const knownThemeTopLevelFrontmatterKeys = new Set(['navigation', 'layout', 'gallery', 'typography', 'presentation', 'frame']);
25
+ const frontmatterColorKeys = new Set(['backgroundColor', 'textColor', 'color']);
26
+ const quotedHexColorRegex = /^["']#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})["']$/;
27
+ const unquotedHexColorRegex = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
25
28
  const knownNestedFrontmatterKeys = new Set([
26
29
  'align',
27
30
  'alt',
@@ -261,6 +264,79 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
261
264
  }
262
265
  };
263
266
 
267
+ const stripInlineYamlComment = (value) => {
268
+ let quote = null;
269
+
270
+ for (let index = 0; index < value.length; index += 1) {
271
+ const character = value[index];
272
+ const previous = value[index - 1];
273
+
274
+ if ((character === '"' || character === "'") && previous !== '\\') {
275
+ quote = quote === character ? null : quote ?? character;
276
+ continue;
277
+ }
278
+
279
+ if (character === '#' && quote === null && /\s/.test(previous ?? '')) {
280
+ return value.slice(0, index).trimEnd();
281
+ }
282
+ }
283
+
284
+ return value.trim();
285
+ };
286
+
287
+ export const validateFrontmatterColorValues = (frontmatter, addIssue, label = 'Frontmatter') => {
288
+ const lines = frontmatter.split(/\r?\n/);
289
+ const pathStack = [];
290
+
291
+ for (const [index, line] of lines.entries()) {
292
+ const lineNumber = index + 1;
293
+ const trimmed = line.trim();
294
+
295
+ if (!trimmed || trimmed.startsWith('#') || frontmatterDelimiterRegex.test(line)) {
296
+ continue;
297
+ }
298
+
299
+ const match = line.match(/^(\s*)([A-Za-z][A-Za-z0-9-]*):\s*(.*?)\s*$/);
300
+ if (!match) continue;
301
+
302
+ const indent = match[1].length;
303
+ const key = match[2];
304
+ const rawValue = match[3].trim();
305
+
306
+ while (pathStack.length > 0 && pathStack[pathStack.length - 1].indent >= indent) {
307
+ pathStack.pop();
308
+ }
309
+
310
+ const pathLabel = [...pathStack.map((entry) => entry.key), key].join('.');
311
+
312
+ if (!frontmatterColorKeys.has(key)) {
313
+ if (!rawValue) {
314
+ pathStack.push({ indent, key });
315
+ }
316
+ continue;
317
+ }
318
+
319
+ const value = stripInlineYamlComment(rawValue);
320
+
321
+ if (quotedHexColorRegex.test(value)) continue;
322
+
323
+ if (unquotedHexColorRegex.test(rawValue)) {
324
+ addIssue({
325
+ severity: 'error',
326
+ message: `${label} line ${lineNumber} defines ${pathLabel} as an unquoted hex color. YAML treats "#" as the start of a comment unless the value is quoted.`,
327
+ fix: `Write it as ${key}: "#ffd84d" or another quoted hex color.`,
328
+ });
329
+ continue;
330
+ }
331
+
332
+ addIssue({
333
+ severity: 'error',
334
+ message: `${label} line ${lineNumber} defines ${pathLabel} with invalid color value ${rawValue || '(empty)'}.`,
335
+ fix: `Use a quoted hex color in #rgb, #rrggbb, or #rrggbbaa form, for example ${key}: "#ffd84d".`,
336
+ });
337
+ }
338
+ };
339
+
264
340
  export const validateContentFrontmatterStructure = (frontmatter, addIssue) =>
265
341
  validateFrontmatterStructure(frontmatter, addIssue, {
266
342
  knownTopLevelFrontmatterKeys: knownContentTopLevelFrontmatterKeys,
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { parseYamlMapping } from './frontmatter-yaml.mjs';
3
3
  import {
4
4
  splitSiteFile,
5
+ validateFrontmatterColorValues,
5
6
  validateFrontmatterIndentation,
6
7
  validateThemeFrontmatterStructure,
7
8
  } from './site-content.mjs';
@@ -22,6 +23,7 @@ export const readThemeConfig = async () => {
22
23
 
23
24
  validateFrontmatterIndentation(frontmatter, (issue) => issues.push(issue));
24
25
  validateThemeFrontmatterStructure(frontmatter, (issue) => issues.push(issue));
26
+ validateFrontmatterColorValues(frontmatter, (issue) => issues.push(issue), siteThemeLabel);
25
27
 
26
28
  if (issues.length > 0) {
27
29
  throw new Error([
@@ -1,7 +1,9 @@
1
+ import { execFile } from 'node:child_process';
1
2
  import { mkdir, rename, stat } from 'node:fs/promises';
2
3
  import { createInterface } from 'node:readline/promises';
3
4
  import { stdin as input, stdout as output } from 'node:process';
4
5
  import path from 'node:path';
6
+ import { promisify } from 'node:util';
5
7
  import {
6
8
  extractMarkdownImageReferences,
7
9
  extractNornaMarkdownBlockDiagnostics,
@@ -18,6 +20,7 @@ import {
18
20
  readThemeFile,
19
21
  toPosixPath,
20
22
  validateContentFrontmatterStructure,
23
+ validateFrontmatterColorValues,
21
24
  validateFrontmatterIndentation,
22
25
  validateThemeFrontmatterStructure,
23
26
  } from './lib/site-content.mjs';
@@ -25,8 +28,11 @@ import { readImageDimensions } from './lib/image-dimensions.mjs';
25
28
  import {
26
29
  siteThemeLabel,
27
30
  siteThemePath,
31
+ siteProjectRoot,
28
32
  } from './lib/site-paths.mjs';
29
33
 
34
+ const execFileAsync = promisify(execFile);
35
+
30
36
  const args = new Set(process.argv.slice(2));
31
37
  const shouldWrite = args.has('--write');
32
38
  const shouldCheck = args.has('--check') || !shouldWrite;
@@ -60,7 +66,7 @@ const promptForWrite = async () => {
60
66
  if (!process.stdin.isTTY) return false;
61
67
 
62
68
  const rl = createInterface({ input, output });
63
- const answer = await rl.question('This will move image files inside their current page or route image root if needed. Continue? [y/N] ');
69
+ const answer = await rl.question('This will move image files into the page or route image roots where Norna references them. Continue? [y/N] ');
64
70
  rl.close();
65
71
 
66
72
  return answer.trim().toLowerCase() === 'y';
@@ -172,16 +178,110 @@ const getExpectedImagePath = (contentFile, sectionId, imageName) =>
172
178
  const getExpectedImageLabel = (contentFile, sectionId, imageName) =>
173
179
  `${contentFile.imagesLabel}/${sectionId}/${imageName}`;
174
180
 
175
- const getImageCandidates = (imageCandidatesByName, imageName, expectedPath) =>
176
- (imageCandidatesByName.get(imageName) ?? []).filter((candidate) => candidate !== expectedPath);
181
+ const getGlobalImageCandidates = (globalImageCandidatesByName, imageName, expectedPath) =>
182
+ (globalImageCandidatesByName.get(imageName) ?? []).filter(({ imagePath }) => imagePath !== expectedPath);
177
183
 
178
184
  const getReferenceLabel = (contentFile, section) => `${contentFile.contentLabel} [${section.id}]`;
179
185
 
186
+ const getRootLabel = (contentFile) => contentFile.isHome
187
+ ? contentFile.imagesLabel
188
+ : contentFile.imagesLabel.replace(/\/images$/, '');
189
+
190
+ const getCandidateLabel = ({ contentFile, imagePath }) =>
191
+ `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`;
192
+
193
+ const addExpectedReference = (expectedReferencesByPath, expectedPath, expectedReference) => {
194
+ if (!expectedReferencesByPath.has(expectedPath)) {
195
+ expectedReferencesByPath.set(expectedPath, []);
196
+ }
197
+
198
+ expectedReferencesByPath.get(expectedPath).push(expectedReference);
199
+ };
200
+
201
+ const addGlobalImageCandidate = (globalImageCandidatesByName, imageName, candidate) => {
202
+ if (!globalImageCandidatesByName.has(imageName)) {
203
+ globalImageCandidatesByName.set(imageName, []);
204
+ }
205
+
206
+ globalImageCandidatesByName.get(imageName).push(candidate);
207
+ };
208
+
209
+ const assertCleanGitForCrossRootSync = async (moves) => {
210
+ if (!moves.some((move) => move.crossRoot)) return;
211
+
212
+ let stdout = '';
213
+ try {
214
+ ({ stdout } = await execFileAsync('git', ['status', '--short'], {
215
+ cwd: siteProjectRoot,
216
+ maxBuffer: 1024 * 1024,
217
+ }));
218
+ } catch (error) {
219
+ addIssue({
220
+ severity: 'error',
221
+ message: 'Cross-route content sync requires a clean git working tree, but git status could not be checked.',
222
+ fix: 'Run content:sync inside a Git worktree, or move cross-route image files manually.',
223
+ });
224
+ return;
225
+ }
226
+
227
+ if (!stdout.trim()) return;
228
+
229
+ addIssue({
230
+ severity: 'error',
231
+ message: 'Cross-route content sync requires a clean git working tree before moving files between page or route image roots.',
232
+ fix: `Commit or stash your current changes, then run content:sync again. Current changes:\n${stdout.trim()}`,
233
+ });
234
+ };
235
+
236
+ const addConflictingMoveIssues = () => {
237
+ const movesBySource = new Map();
238
+ const movesByDestination = new Map();
239
+
240
+ for (const move of imageMoves) {
241
+ const sourceKey = move.from;
242
+ const destinationKey = move.to;
243
+
244
+ if (!movesBySource.has(sourceKey)) {
245
+ movesBySource.set(sourceKey, []);
246
+ }
247
+ movesBySource.get(sourceKey).push(move);
248
+
249
+ if (!movesByDestination.has(destinationKey)) {
250
+ movesByDestination.set(destinationKey, []);
251
+ }
252
+ movesByDestination.get(destinationKey).push(move);
253
+ }
254
+
255
+ for (const moves of movesBySource.values()) {
256
+ const destinations = new Set(moves.map((move) => move.to));
257
+ if (destinations.size <= 1) continue;
258
+
259
+ const firstMove = moves[0];
260
+ addSectionIssue(firstMove.contentFile, { id: firstMove.sectionId, heading: firstMove.sectionId }, {
261
+ severity: 'error',
262
+ message: `Cannot relocate "${firstMove.imageName}" because the same source file is referenced from multiple destinations: ${moves.map((move) => getExpectedImageLabel(move.contentFile, move.sectionId, move.imageName)).join(', ')}.`,
263
+ fix: 'Duplicate the image manually or rename one of the image files so each move has a single destination.',
264
+ });
265
+ }
266
+
267
+ for (const moves of movesByDestination.values()) {
268
+ const sources = new Set(moves.map((move) => move.from));
269
+ if (sources.size <= 1) continue;
270
+
271
+ const firstMove = moves[0];
272
+ addSectionIssue(firstMove.contentFile, { id: firstMove.sectionId, heading: firstMove.sectionId }, {
273
+ severity: 'error',
274
+ message: `Cannot relocate "${firstMove.imageName}" because multiple source files would move to ${getExpectedImageLabel(firstMove.contentFile, firstMove.sectionId, firstMove.imageName)}: ${moves.map((move) => getCandidateLabel(move.sourceContentFile ? { contentFile: move.sourceContentFile, imagePath: move.from } : move)).join(', ')}.`,
275
+ fix: 'Move the intended file manually or rename files so the destination is unambiguous.',
276
+ });
277
+ }
278
+ };
279
+
180
280
  const validateImageReference = async (
181
281
  contentFile,
182
282
  section,
183
283
  reference,
184
- imageCandidatesByName,
284
+ globalImageCandidatesByName,
185
285
  expectedReferencesByPath,
186
286
  ) => {
187
287
  const imageName = reference.image;
@@ -194,11 +294,11 @@ const validateImageReference = async (
194
294
  return expectedPath;
195
295
  }
196
296
 
197
- const candidates = getImageCandidates(imageCandidatesByName, imageName, expectedPath);
297
+ const candidates = getGlobalImageCandidates(globalImageCandidatesByName, imageName, expectedPath);
198
298
  if (candidates.length === 0) {
199
299
  addSectionIssue(contentFile, section, {
200
300
  severity: 'error',
201
- message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under ${contentFile.imagesLabel}/.`,
301
+ message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under any page or route image root.`,
202
302
  fix: `Add the source image to ${contentFile.imagesLabel}/${section.id}/ or remove the Norna-managed image reference.`,
203
303
  });
204
304
  return null;
@@ -207,20 +307,21 @@ const validateImageReference = async (
207
307
  if (candidates.length > 1) {
208
308
  addSectionIssue(contentFile, section, {
209
309
  severity: 'error',
210
- message: `Cannot relocate "${imageName}". Multiple files with this filename were found: ${candidates.map((candidate) => `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, candidate))}`).join(', ')}.`,
211
- fix: 'Move the intended file manually or rename files so the local move is unambiguous.',
310
+ message: `Cannot relocate "${imageName}". Multiple files with this filename were found: ${candidates.map(getCandidateLabel).join(', ')}.`,
311
+ fix: 'Move the intended file manually or rename files so the move is unambiguous.',
212
312
  });
213
313
  return null;
214
314
  }
215
315
 
216
- const sourcePath = candidates[0];
316
+ const sourceCandidate = candidates[0];
317
+ const sourcePath = sourceCandidate.imagePath;
217
318
  const referencesAtCurrentLocation = (expectedReferencesByPath.get(sourcePath) ?? [])
218
- .filter((expectedReference) => expectedReference.section.id !== section.id);
319
+ .filter((expectedReference) => expectedReference.contentFile !== contentFile || expectedReference.section.id !== section.id);
219
320
 
220
321
  if (referencesAtCurrentLocation.length > 0) {
221
322
  addSectionIssue(contentFile, section, {
222
323
  severity: 'error',
223
- message: `Cannot relocate "${imageName}" from ${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, sourcePath))} because it is still referenced from ${referencesAtCurrentLocation.map((expectedReference) => getReferenceLabel(contentFile, expectedReference.section)).join(', ')}.`,
324
+ message: `Cannot relocate "${imageName}" from ${getCandidateLabel(sourceCandidate)} because it is still referenced from ${referencesAtCurrentLocation.map((expectedReference) => getReferenceLabel(expectedReference.contentFile, expectedReference.section)).join(', ')}.`,
224
325
  fix: 'Remove the extra reference, duplicate the image file manually, or rename one of the image files so the intended move is unambiguous.',
225
326
  });
226
327
  return null;
@@ -231,15 +332,19 @@ const validateImageReference = async (
231
332
  from: sourcePath,
232
333
  to: expectedPath,
233
334
  contentFile,
335
+ sourceContentFile: sourceCandidate.contentFile,
234
336
  sectionId: section.id,
337
+ crossRoot: sourceCandidate.contentFile !== contentFile,
235
338
  });
236
339
  referencedImagePaths.add(sourcePath);
237
340
 
238
341
  if (!shouldWrite) {
239
342
  addSectionIssue(contentFile, section, {
240
343
  severity: 'error',
241
- message: `Image "${imageName}" is used here but is located in ${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, sourcePath))}.`,
242
- fix: 'Run norna content:sync to move it inside the current page or route image root.',
344
+ message: `Image "${imageName}" is used here but is located in ${getCandidateLabel(sourceCandidate)}.`,
345
+ fix: sourceCandidate.contentFile === contentFile
346
+ ? 'Run norna content:sync to move it inside the current page or route image root.'
347
+ : `Run norna content:sync to move it from ${getRootLabel(sourceCandidate.contentFile)} to ${getRootLabel(contentFile)}. Cross-route sync requires a clean git working tree when files are moved.`,
243
348
  });
244
349
  }
245
350
 
@@ -290,6 +395,7 @@ try {
290
395
  themeFrontmatter = (await readThemeFile(siteThemePath)).frontmatter;
291
396
  validateFrontmatterIndentation(themeFrontmatter, addIssue);
292
397
  validateThemeFrontmatterStructure(themeFrontmatter, addIssue);
398
+ validateFrontmatterColorValues(themeFrontmatter, addIssue, siteThemeLabel);
293
399
  } catch (error) {
294
400
  if (error?.code !== 'ENOENT') {
295
401
  throw error;
@@ -302,23 +408,30 @@ const themeInlineStyleNames = themeFrontmatter
302
408
 
303
409
  const contentFiles = await getContentFiles();
304
410
  const allImageFiles = [];
411
+ const contentFileContexts = [];
412
+ const globalImageCandidatesByName = new Map();
413
+ const globalExpectedReferencesByPath = new Map();
305
414
 
306
415
  for (const contentFile of contentFiles) {
307
416
  const { frontmatter, body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
308
417
  const bodyLineOffset = frontmatter.split(/\r?\n/).length - 1;
309
418
  validateFrontmatterIndentation(frontmatter, addIssue);
310
419
  validateContentFrontmatterStructure(frontmatter, addIssue);
420
+ validateFrontmatterColorValues(frontmatter, addIssue, contentFile.contentLabel);
311
421
 
312
422
  const frontmatterSections = getFrontmatterSections(frontmatter);
313
423
  const frontmatterIds = new Set(frontmatterSections.map((section) => section.id));
314
424
  const { sections } = getBodySections(body);
315
425
  const sectionsById = new Map();
316
426
  const blockResultsBySectionId = new Map();
317
- const expectedReferencesByPath = new Map();
318
427
  const imageCandidatesByName = await getImageCandidatesByName(contentFile.imagesDir);
319
428
 
320
- for (const candidates of imageCandidatesByName.values()) {
321
- allImageFiles.push(...candidates.map((imagePath) => ({ contentFile, imagePath })));
429
+ for (const [imageName, candidates] of imageCandidatesByName) {
430
+ for (const imagePath of candidates) {
431
+ const candidate = { contentFile, imagePath };
432
+ allImageFiles.push(candidate);
433
+ addGlobalImageCandidate(globalImageCandidatesByName, imageName, candidate);
434
+ }
322
435
  }
323
436
 
324
437
  for (const section of sections) {
@@ -354,13 +467,30 @@ for (const contentFile of contentFiles) {
354
467
 
355
468
  for (const reference of getNornaBlockImageReferences(blockResults.blocks)) {
356
469
  const expectedPath = getExpectedImagePath(contentFile, section.id, reference.image);
357
- if (!expectedReferencesByPath.has(expectedPath)) {
358
- expectedReferencesByPath.set(expectedPath, []);
359
- }
360
- expectedReferencesByPath.get(expectedPath).push({ section, reference });
470
+ addExpectedReference(globalExpectedReferencesByPath, expectedPath, { contentFile, section, reference });
361
471
  }
362
472
  }
363
473
 
474
+ contentFileContexts.push({
475
+ contentFile,
476
+ frontmatterIds,
477
+ sections,
478
+ sectionsById,
479
+ blockResultsBySectionId,
480
+ body,
481
+ });
482
+ }
483
+
484
+ for (const context of contentFileContexts) {
485
+ const {
486
+ contentFile,
487
+ frontmatterIds,
488
+ sections,
489
+ sectionsById,
490
+ blockResultsBySectionId,
491
+ body,
492
+ } = context;
493
+
364
494
  for (const id of frontmatterIds) {
365
495
  if (!sectionsById.has(id)) {
366
496
  addContentIssue(contentFile, {
@@ -417,8 +547,8 @@ for (const contentFile of contentFiles) {
417
547
  contentFile,
418
548
  section,
419
549
  reference,
420
- imageCandidatesByName,
421
- expectedReferencesByPath,
550
+ globalImageCandidatesByName,
551
+ globalExpectedReferencesByPath,
422
552
  );
423
553
  if (sourcePath) {
424
554
  sourcePathsByImage.set(reference.image, sourcePath);
@@ -436,6 +566,12 @@ const unreferencedImages = allImageFiles
436
566
  .map(({ contentFile, imagePath }) => `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`)
437
567
  .sort((left, right) => left.localeCompare(right, 'sv'));
438
568
 
569
+ addConflictingMoveIssues();
570
+
571
+ if (shouldWrite) {
572
+ await assertCleanGitForCrossRootSync(imageMoves);
573
+ }
574
+
439
575
  if (hasErrors()) {
440
576
  printReport(shouldWrite ? 'Content sync failed.' : 'Content check failed.', unreferencedImages);
441
577
  process.exit(1);