@janga/norna 0.7.6 → 0.7.7

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 (137) hide show
  1. package/README.md +5 -5
  2. package/astro.config.mjs +76 -0
  3. package/bin/norna-cli.mjs +2 -2
  4. package/docs/README.md +3 -3
  5. package/docs/commands.md +6 -6
  6. package/docs/configuration.md +3 -2
  7. package/docs/content.md +152 -91
  8. package/docs/design/site-examples-structure.md +1 -1
  9. package/docs/engine-development.md +3 -3
  10. package/docs/getting-started.md +4 -3
  11. package/docs/images-and-metadata.md +112 -20
  12. package/docs/routes.md +9 -12
  13. package/docs/site-structure.md +4 -3
  14. package/docs/theme.md +8 -7
  15. package/docs/typography.md +5 -5
  16. package/examples/dog-gallery/site/content.md +41 -45
  17. package/examples/dog-gallery/site/routes/010-dog-care/route-content.md +1 -3
  18. package/fixtures/basic/site/content.md +2 -4
  19. package/fixtures/content-model-v2/.astro/collections/site.schema.json +776 -0
  20. package/fixtures/content-model-v2/.astro/collections/theme.schema.json +668 -0
  21. package/fixtures/content-model-v2/.astro/content-assets.mjs +4 -0
  22. package/fixtures/content-model-v2/.astro/content-modules.mjs +1 -0
  23. package/fixtures/content-model-v2/.astro/content.d.ts +175 -0
  24. package/fixtures/content-model-v2/.astro/data-store.json +1 -0
  25. package/fixtures/content-model-v2/.astro/dev-local.json +8 -0
  26. package/fixtures/content-model-v2/.astro/dev.json +13 -0
  27. package/fixtures/content-model-v2/.astro/dev.log +231 -0
  28. package/fixtures/content-model-v2/.astro/settings.json +5 -0
  29. package/fixtures/content-model-v2/.astro/types.d.ts +2 -0
  30. package/fixtures/content-model-v2/dist/_astro/GalleryGrid.astro_astro_type_script_index_0_lang.B0kxDyGz.js +1 -0
  31. package/fixtures/content-model-v2/dist/_astro/SitePage.BbfZub4g.css +1 -0
  32. package/fixtures/content-model-v2/dist/guide/index.html +3 -0
  33. package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-480.webp +0 -0
  34. package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-768.webp +0 -0
  35. package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-971.webp +0 -0
  36. package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-480.webp +0 -0
  37. package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-768.webp +0 -0
  38. package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-971.webp +0 -0
  39. package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-480.webp +0 -0
  40. package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-768.webp +0 -0
  41. package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-971.webp +0 -0
  42. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1080.webp +0 -0
  43. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1440.webp +0 -0
  44. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1600.webp +0 -0
  45. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-480.webp +0 -0
  46. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-768.webp +0 -0
  47. package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-480.webp +0 -0
  48. package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-768.webp +0 -0
  49. package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-960.webp +0 -0
  50. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1080.webp +0 -0
  51. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1440.webp +0 -0
  52. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1600.webp +0 -0
  53. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-480.webp +0 -0
  54. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-768.webp +0 -0
  55. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1080.webp +0 -0
  56. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1440.webp +0 -0
  57. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1600.webp +0 -0
  58. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-480.webp +0 -0
  59. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-768.webp +0 -0
  60. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1080.webp +0 -0
  61. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1440.webp +0 -0
  62. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1600.webp +0 -0
  63. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-480.webp +0 -0
  64. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-768.webp +0 -0
  65. package/fixtures/content-model-v2/dist/index.html +8 -0
  66. package/fixtures/content-model-v2/node_modules/.vite/deps/_metadata.json +80 -0
  67. package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
  68. package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
  69. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  70. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  71. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
  72. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  73. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  74. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  75. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  76. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  77. package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
  78. package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
  79. package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js +1389 -0
  80. package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js.map +1 -0
  81. package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
  82. package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
  83. package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
  84. package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
  85. package/fixtures/content-model-v2/node_modules/.vite/deps/package.json +3 -0
  86. package/fixtures/content-model-v2/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
  87. package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
  88. package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
  89. package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
  90. package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
  91. package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
  92. package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
  93. package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
  94. package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
  95. package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
  96. package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
  97. package/fixtures/content-model-v2/site/.norna/generated-images.json +210 -0
  98. package/fixtures/content-model-v2/site/config.mjs +11 -0
  99. package/fixtures/content-model-v2/site/content.md +84 -0
  100. package/fixtures/content-model-v2/site/images/card-section/adopt.svg +9 -0
  101. package/fixtures/content-model-v2/site/images/card-section/foster.svg +12 -0
  102. package/fixtures/content-model-v2/site/images/intro/detail.jpg +0 -0
  103. package/fixtures/content-model-v2/site/images/intro/duplicate.jpg +0 -0
  104. package/fixtures/content-model-v2/site/images/intro/hero.jpg +0 -0
  105. package/fixtures/content-model-v2/site/images/plain/image.jpg +0 -0
  106. package/fixtures/content-model-v2/site/images/timed/slide-one.jpg +0 -0
  107. package/fixtures/content-model-v2/site/images/timed/slide-three.jpg +0 -0
  108. package/fixtures/content-model-v2/site/images/timed/slide-two.jpg +0 -0
  109. package/fixtures/content-model-v2/site/routes/010-guide/images/intro/duplicate.jpg +0 -0
  110. package/fixtures/content-model-v2/site/routes/010-guide/route-content.md +17 -0
  111. package/fixtures/content-model-v2/site/theme.md +9 -0
  112. package/package.json +3 -2
  113. package/scripts/deploy-site.mjs +11 -4
  114. package/scripts/dev-local.mjs +6 -0
  115. package/scripts/generate-images.mjs +132 -56
  116. package/scripts/lib/image-dimensions.mjs +26 -0
  117. package/scripts/lib/norna-markdown-blocks.mjs +643 -0
  118. package/scripts/lib/site-content.mjs +31 -29
  119. package/scripts/show-typography.mjs +17 -9
  120. package/scripts/sync-content-sections.mjs +280 -411
  121. package/scripts/test-content-check.mjs +59 -45
  122. package/scripts/test-content-model-v2.mjs +998 -0
  123. package/scripts/test-engine-commands.mjs +1 -1
  124. package/scripts/test-package-check.mjs +36 -8
  125. package/scripts/test-temporary-visibility.mjs +3 -6
  126. package/src/components/CardList.astro +77 -0
  127. package/src/components/GalleryGrid.astro +22 -12
  128. package/src/components/SiteNavigation.astro +5 -4
  129. package/src/components/SitePage.astro +9 -2
  130. package/src/components/SiteSection.astro +35 -13
  131. package/src/content.config.ts +4 -17
  132. package/src/lib/generatedImages.ts +50 -10
  133. package/src/lib/sectionContent.ts +130 -35
  134. package/src/styles/global.css +187 -0
  135. package/starters/basic/site/content.md +10 -6
  136. package/starters/project/site/content.md +0 -9
  137. package/starters/project/site/routes/010-guide/route-content.md +0 -5
@@ -1,13 +1,18 @@
1
- import { mkdir, rename, stat, writeFile } from 'node:fs/promises';
1
+ import { mkdir, rename, stat } from 'node:fs/promises';
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
4
  import path from 'node:path';
5
+ import {
6
+ extractMarkdownImageReferences,
7
+ extractNornaMarkdownBlockDiagnostics,
8
+ getNornaBlockImageReferences,
9
+ } from './lib/norna-markdown-blocks.mjs';
5
10
  import {
6
11
  getBodySections,
7
12
  getContentFiles,
8
- getFrontmatterSections,
9
13
  getFrontmatterInlineStyleNames,
10
- getImageIndex,
14
+ getFrontmatterSections,
15
+ getImageCandidatesByName,
11
16
  getInlineStyleReferences,
12
17
  readSiteFile,
13
18
  readThemeFile,
@@ -18,10 +23,6 @@ import {
18
23
  } from './lib/site-content.mjs';
19
24
  import { readImageDimensions } from './lib/image-dimensions.mjs';
20
25
  import {
21
- siteContentLabel,
22
- siteContentPath,
23
- siteImagesDir,
24
- siteImagesLabel,
25
26
  siteThemeLabel,
26
27
  siteThemePath,
27
28
  } from './lib/site-paths.mjs';
@@ -31,83 +32,40 @@ const shouldWrite = args.has('--write');
31
32
  const shouldCheck = args.has('--check') || !shouldWrite;
32
33
  const skipPrompt = args.has('--yes');
33
34
  const issues = [];
35
+ const imageMoves = [];
36
+ const referencedImagePaths = new Set();
34
37
 
35
38
  const addIssue = ({ severity, message, fix, sectionId, sectionLabel }) => {
36
39
  issues.push({ severity, message, fix, sectionId, sectionLabel });
37
40
  };
38
41
 
39
- const fail = (message) => {
40
- addIssue({ severity: 'error', message });
41
- };
42
+ const hasErrors = () => issues.some((issue) => issue.severity === 'error');
42
43
 
43
- const warn = (message) => {
44
- addIssue({ severity: 'warning', message });
45
- };
44
+ const formatSectionLabel = (contentFile, section) => `${contentFile.contentLabel} [${section.id ?? section.heading}]`;
46
45
 
47
- const addSectionIssue = (sectionId, issue) => {
48
- addIssue({
49
- ...issue,
50
- sectionId,
51
- sectionLabel: issue.sectionLabel ?? sectionId,
52
- });
53
- };
46
+ const addContentIssue = (contentFile, issue) => addIssue({
47
+ ...issue,
48
+ sectionId: issue.sectionId ?? contentFile.contentLabel,
49
+ sectionLabel: issue.sectionLabel ?? contentFile.contentLabel,
50
+ });
54
51
 
55
- const hasErrors = () => issues.some((issue) => issue.severity === 'error');
52
+ const addSectionIssue = (contentFile, section, issue) => addIssue({
53
+ ...issue,
54
+ sectionId: issue.sectionId ?? `${contentFile.contentLabel}:${section.id ?? section.heading}`,
55
+ sectionLabel: issue.sectionLabel ?? formatSectionLabel(contentFile, section),
56
+ });
56
57
 
57
58
  const promptForWrite = async () => {
58
59
  if (skipPrompt) return true;
59
60
  if (!process.stdin.isTTY) return false;
60
61
 
61
62
  const rl = createInterface({ input, output });
62
- const answer = await rl.question(`This will rewrite Markdown sections in ${siteContentLabel} and move image files if needed. Continue? [y/N] `);
63
+ const answer = await rl.question('This will move image files inside their current page or route image root if needed. Continue? [y/N] ');
63
64
  rl.close();
64
65
 
65
66
  return answer.trim().toLowerCase() === 'y';
66
67
  };
67
68
 
68
- const { frontmatter, body } = await readSiteFile(siteContentPath);
69
- validateFrontmatterIndentation(frontmatter, addIssue);
70
- validateContentFrontmatterStructure(frontmatter, addIssue);
71
- let themeFrontmatter = null;
72
- try {
73
- themeFrontmatter = (await readThemeFile(siteThemePath)).frontmatter;
74
- validateFrontmatterIndentation(themeFrontmatter, addIssue);
75
- validateThemeFrontmatterStructure(themeFrontmatter, addIssue);
76
- } catch (error) {
77
- if (error?.code === 'ENOENT') {
78
- themeFrontmatter = null;
79
- } else {
80
- throw error;
81
- }
82
- }
83
- const frontmatterSections = getFrontmatterSections(frontmatter);
84
- const themeInlineStyleNames = themeFrontmatter
85
- ? getFrontmatterInlineStyleNames(themeFrontmatter)
86
- : new Set();
87
- const frontmatterIds = frontmatterSections.map((section) => section.id);
88
- const { prelude, sections } = getBodySections(body);
89
- const sectionsById = new Map();
90
- const extraSections = [];
91
- const imageIndex = await getImageIndex(siteImagesDir, fail);
92
- const imageMoves = [];
93
- const referencedImages = new Map();
94
-
95
- const getUnreferencedImages = () => Array.from(imageIndex.entries())
96
- .filter(([imageName]) => !referencedImages.has(imageName))
97
- .map(([, imagePath]) => `${siteImagesLabel}/${toPosixPath(path.relative(siteImagesDir, imagePath))}`)
98
- .sort((left, right) => left.localeCompare(right, 'sv'));
99
-
100
- const getSectionReportOrder = () => {
101
- const order = new Map();
102
- frontmatterIds.forEach((id, index) => order.set(id, index));
103
- extraSections.forEach((section, index) => {
104
- if (!order.has(section.id)) {
105
- order.set(section.id, frontmatterIds.length + index);
106
- }
107
- });
108
- return order;
109
- };
110
-
111
69
  const groupIssuesBySeverity = (groupedIssues) => [
112
70
  ['error', 'Errors'],
113
71
  ['warning', 'Warnings'],
@@ -116,239 +74,17 @@ const groupIssuesBySeverity = (groupedIssues) => [
116
74
  issues: groupedIssues.filter((issue) => issue.severity === severity),
117
75
  })).filter((group) => group.issues.length > 0);
118
76
 
119
- const getGreatestCommonDivisor = (left, right) => {
120
- let a = Math.abs(left);
121
- let b = Math.abs(right);
122
-
123
- while (b !== 0) {
124
- const next = a % b;
125
- a = b;
126
- b = next;
127
- }
128
-
129
- return a || 1;
130
- };
131
-
132
- const getAspectRatioLabel = ({ width, height }) => {
133
- const divisor = getGreatestCommonDivisor(width, height);
134
- return `${width / divisor}:${height / divisor}`;
135
- };
136
-
137
- const warnAboutCarouselAspectRatios = async () => {
138
- for (const section of frontmatterSections) {
139
- for (const carousel of section.carousels ?? []) {
140
- const imagesWithRatios = [];
141
-
142
- for (const { image } of carousel.imageReferences ?? []) {
143
- const imagePath = imageIndex.get(image);
144
- if (!imagePath) continue;
145
-
146
- const dimensions = await readImageDimensions(imagePath).catch(() => null);
147
- if (!dimensions) continue;
148
-
149
- imagesWithRatios.push({
150
- image,
151
- ratio: getAspectRatioLabel(dimensions),
152
- });
153
- }
154
-
155
- const ratios = new Set(imagesWithRatios.map(({ ratio }) => ratio));
156
- if (ratios.size <= 1) continue;
157
-
158
- addSectionIssue(section.id, {
159
- severity: 'warning',
160
- message: `Carousel on line ${carousel.line} uses images with different aspect ratios: ${imagesWithRatios.map(({ image, ratio }) => `${image} (${ratio})`).join(', ')}.`,
161
- fix: 'Use images with exactly matching proportions in the same carousel to avoid uneven layout and motion.',
162
- });
163
- }
164
- }
165
- };
166
-
167
- const validateRouteContentFiles = async () => {
168
- const routeContentFiles = (await getContentFiles()).filter((contentFile) => !contentFile.isHome);
169
-
170
- for (const contentFile of routeContentFiles) {
171
- const { frontmatter: routeFrontmatter, body: routeBody } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
172
- validateFrontmatterIndentation(routeFrontmatter, addIssue);
173
- validateContentFrontmatterStructure(routeFrontmatter, addIssue);
174
-
175
- const routeFrontmatterSections = getFrontmatterSections(routeFrontmatter);
176
- const routeFrontmatterIds = routeFrontmatterSections.map((section) => section.id);
177
- const routeBodySections = getBodySections(routeBody).sections;
178
- const routeSectionsById = new Map();
179
- const routeImageIndex = await getImageIndex(contentFile.imagesDir, fail);
180
-
181
- for (const section of routeBodySections) {
182
- const sectionLabel = `${contentFile.contentLabel} [${section.id ?? section.heading}]`;
183
-
184
- if (!section.id) {
185
- addIssue({
186
- severity: 'warning',
187
- sectionLabel,
188
- message: 'Markdown section is missing an explicit heading id.',
189
- fix: `Write the heading with an explicit id, for example "${section.heading} {#section-id}".`,
190
- });
191
- continue;
192
- }
193
-
194
- if (routeSectionsById.has(section.id)) {
195
- addIssue({
196
- severity: 'error',
197
- sectionId: `${contentFile.contentLabel}:${section.id}`,
198
- sectionLabel,
199
- message: `Duplicate Markdown section heading id "${section.id}".`,
200
- fix: 'Each Markdown level 2 section heading must use a unique explicit id.',
201
- });
202
- continue;
203
- }
204
-
205
- routeSectionsById.set(section.id, section);
206
- }
207
-
208
- for (const id of routeFrontmatterIds) {
209
- if (!routeSectionsById.has(id)) {
210
- addIssue({
211
- severity: 'error',
212
- sectionId: `${contentFile.contentLabel}:${id}`,
213
- sectionLabel: `${contentFile.contentLabel} [${id}]`,
214
- message: `Cannot find a Markdown heading for section "${id}".`,
215
- fix: `Add a level 2 Markdown heading, for example "## Heading {#${id}}".`,
216
- });
217
- }
218
- }
219
-
220
- for (const section of routeBodySections) {
221
- if (section.id && !routeFrontmatterIds.includes(section.id)) {
222
- addIssue({
223
- severity: 'warning',
224
- sectionId: `${contentFile.contentLabel}:${section.id}`,
225
- sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
226
- message: `Markdown section "${section.id}" exists but is not used in frontmatter.`,
227
- fix: 'Add it to frontmatter sections or remove the unused Markdown section.',
228
- });
229
- }
230
- }
231
-
232
- const currentOrder = routeBodySections.map((section) => section.id).filter(Boolean);
233
- const expectedOrder = routeFrontmatterIds.filter((id) => routeSectionsById.has(id));
234
- if (
235
- currentOrder.length !== expectedOrder.length ||
236
- currentOrder.some((id, index) => id !== expectedOrder[index])
237
- ) {
238
- addIssue({
239
- severity: 'warning',
240
- message: `${contentFile.contentLabel} Markdown section order differs from frontmatter.`,
241
- });
242
- }
243
-
244
- for (const section of routeFrontmatterSections) {
245
- for (const imageName of section.images) {
246
- if (imageName.includes('/') || imageName.includes('\\')) {
247
- addIssue({
248
- severity: 'error',
249
- sectionId: `${contentFile.contentLabel}:${section.id}`,
250
- sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
251
- message: `Image reference "${imageName}" must be a filename without a directory.`,
252
- fix: `Use only the filename in ${contentFile.contentLabel} and keep the file in the matching ${contentFile.imagesLabel}/<section-id>/ directory.`,
253
- });
254
- continue;
255
- }
256
-
257
- if (referencedImages.has(imageName)) {
258
- const previousSectionId = referencedImages.get(imageName);
259
- const message = `Image "${imageName}" is referenced more than once, in "${previousSectionId}" and "${contentFile.contentLabel}:${section.id}".`;
260
- const fix = 'Each image filename can be referenced by only one image row.';
261
-
262
- addIssue({ severity: 'error', sectionId: previousSectionId, message, fix });
263
- addIssue({
264
- severity: 'error',
265
- sectionId: `${contentFile.contentLabel}:${section.id}`,
266
- sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
267
- message,
268
- fix,
269
- });
270
- continue;
271
- }
272
-
273
- referencedImages.set(imageName, `${contentFile.contentLabel}:${section.id}`);
274
-
275
- const imagePath = routeImageIndex.get(imageName);
276
- if (!imagePath) {
277
- addIssue({
278
- severity: 'error',
279
- sectionId: `${contentFile.contentLabel}:${section.id}`,
280
- sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
281
- message: `Image "${imageName}" does not exist anywhere under ${contentFile.imagesLabel}/.`,
282
- fix: `Add the source image to ${contentFile.imagesLabel}/${section.id}/ or remove the image row.`,
283
- });
284
- continue;
285
- }
286
-
287
- const currentDirectory = path.basename(path.dirname(imagePath));
288
- if (currentDirectory !== section.id) {
289
- addIssue({
290
- severity: 'error',
291
- sectionId: `${contentFile.contentLabel}:${section.id}`,
292
- sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
293
- message: `Image "${imageName}" is used here but is located in ${contentFile.imagesLabel}/${currentDirectory}/.`,
294
- fix: 'Move it to the matching section image directory.',
295
- });
296
- }
297
- }
298
-
299
- for (const carousel of section.carousels ?? []) {
300
- const imagesWithRatios = [];
301
-
302
- for (const { image } of carousel.imageReferences ?? []) {
303
- const imagePath = routeImageIndex.get(image);
304
- if (!imagePath) continue;
305
-
306
- const dimensions = await readImageDimensions(imagePath).catch(() => null);
307
- if (!dimensions) continue;
308
-
309
- imagesWithRatios.push({
310
- image,
311
- ratio: getAspectRatioLabel(dimensions),
312
- });
313
- }
314
-
315
- const ratios = new Set(imagesWithRatios.map(({ ratio }) => ratio));
316
- if (ratios.size <= 1) continue;
317
-
318
- addIssue({
319
- severity: 'warning',
320
- sectionId: `${contentFile.contentLabel}:${section.id}`,
321
- sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
322
- message: `Carousel on line ${carousel.line} uses images with different aspect ratios: ${imagesWithRatios.map(({ image, ratio }) => `${image} (${ratio})`).join(', ')}.`,
323
- fix: 'Use images with exactly matching proportions in the same carousel to avoid uneven layout and motion.',
324
- });
325
- }
326
- }
327
-
328
- for (const styleName of new Set(getInlineStyleReferences(routeBody))) {
329
- if (!themeInlineStyleNames.has(styleName)) {
330
- addIssue({
331
- severity: 'error',
332
- message: `Inline style ".${styleName}" is used in ${contentFile.contentLabel} but is not defined in ${siteThemeLabel} presentation.inlineStyles.`,
333
- fix: `Add presentation.inlineStyles.${styleName} to ${siteThemeLabel} or remove "{.${styleName}}" from Markdown.`,
334
- });
335
- }
336
- }
337
- }
338
- };
339
-
340
77
  const formatIssue = (issue) => [
341
78
  `- ${issue.message}`,
342
79
  issue.fix ? ` Fix: ${issue.fix}` : null,
343
80
  ].filter(Boolean);
344
81
 
345
- const getReportLines = (title) => {
82
+ const getReportLines = (title, unreferencedImages) => {
346
83
  const lines = [title];
347
84
  const sectionIssues = issues.filter((issue) => issue.sectionId || issue.sectionLabel);
348
85
  const globalIssues = issues.filter((issue) => !issue.sectionId && !issue.sectionLabel);
349
86
 
350
87
  if (sectionIssues.length > 0) {
351
- const sectionOrder = getSectionReportOrder();
352
88
  const sectionGroups = new Map();
353
89
 
354
90
  for (const issue of sectionIssues) {
@@ -362,15 +98,9 @@ const getReportLines = (title) => {
362
98
  sectionGroups.get(key).issues.push(issue);
363
99
  }
364
100
 
365
- const orderedSectionGroups = Array.from(sectionGroups.entries()).sort(([leftKey], [rightKey]) => (
366
- (sectionOrder.get(leftKey) ?? Number.MAX_SAFE_INTEGER) -
367
- (sectionOrder.get(rightKey) ?? Number.MAX_SAFE_INTEGER) ||
368
- leftKey.localeCompare(rightKey, 'sv')
369
- ));
101
+ lines.push('', 'Content Issues');
370
102
 
371
- lines.push('', 'Section And Image Issues');
372
-
373
- for (const [, group] of orderedSectionGroups) {
103
+ for (const [, group] of Array.from(sectionGroups.entries()).sort(([left], [right]) => left.localeCompare(right, 'sv'))) {
374
104
  lines.push('', `[${group.label}]`);
375
105
 
376
106
  for (const severityGroup of groupIssuesBySeverity(group.issues)) {
@@ -397,13 +127,11 @@ const getReportLines = (title) => {
397
127
  }
398
128
  }
399
129
 
400
- const unreferencedImages = getUnreferencedImages();
401
-
402
130
  if (unreferencedImages.length > 0) {
403
131
  lines.push(
404
132
  '',
405
133
  'Unreferenced Images',
406
- `These files are kept in ${siteImagesLabel}/ but are not mounted on the site:`,
134
+ 'These files are kept under page or route image roots but are not referenced by Norna-managed image references:',
407
135
  );
408
136
 
409
137
  for (const imagePath of unreferencedImages) {
@@ -414,185 +142,326 @@ const getReportLines = (title) => {
414
142
  return lines;
415
143
  };
416
144
 
417
- const printReport = (title) => {
418
- const lines = getReportLines(title);
419
- const output = hasErrors() ? console.error : console.log;
420
- output(lines.join('\n'));
145
+ const printReport = (title, unreferencedImages) => {
146
+ const lines = getReportLines(title, unreferencedImages);
147
+ const target = hasErrors() ? console.error : console.log;
148
+ target(lines.join('\n'));
421
149
  };
422
150
 
423
- for (const section of sections) {
424
- if (!section.id) {
425
- addSectionIssue(null, {
426
- severity: 'warning',
427
- sectionLabel: section.heading,
428
- message: 'Markdown section is missing an explicit heading id.',
429
- fix: `Write the heading with an explicit id, for example "${section.heading} {#section-id}".`,
151
+ const getGreatestCommonDivisor = (left, right) => {
152
+ let a = Math.abs(left);
153
+ let b = Math.abs(right);
154
+
155
+ while (b !== 0) {
156
+ const next = a % b;
157
+ a = b;
158
+ b = next;
159
+ }
160
+
161
+ return a || 1;
162
+ };
163
+
164
+ const getAspectRatioLabel = ({ width, height }) => {
165
+ const divisor = getGreatestCommonDivisor(width, height);
166
+ return `${width / divisor}:${height / divisor}`;
167
+ };
168
+
169
+ const getExpectedImagePath = (contentFile, sectionId, imageName) =>
170
+ path.join(contentFile.imagesDir, sectionId, imageName);
171
+
172
+ const getExpectedImageLabel = (contentFile, sectionId, imageName) =>
173
+ `${contentFile.imagesLabel}/${sectionId}/${imageName}`;
174
+
175
+ const getImageCandidates = (imageCandidatesByName, imageName, expectedPath) =>
176
+ (imageCandidatesByName.get(imageName) ?? []).filter((candidate) => candidate !== expectedPath);
177
+
178
+ const getReferenceLabel = (contentFile, section) => `${contentFile.contentLabel} [${section.id}]`;
179
+
180
+ const validateImageReference = async (
181
+ contentFile,
182
+ section,
183
+ reference,
184
+ imageCandidatesByName,
185
+ expectedReferencesByPath,
186
+ ) => {
187
+ const imageName = reference.image;
188
+ const expectedPath = getExpectedImagePath(contentFile, section.id, imageName);
189
+ const expectedLabel = getExpectedImageLabel(contentFile, section.id, imageName);
190
+ const expectedExists = await stat(expectedPath).then((entry) => entry.isFile()).catch(() => false);
191
+
192
+ if (expectedExists) {
193
+ referencedImagePaths.add(expectedPath);
194
+ return expectedPath;
195
+ }
196
+
197
+ const candidates = getImageCandidates(imageCandidatesByName, imageName, expectedPath);
198
+ if (candidates.length === 0) {
199
+ addSectionIssue(contentFile, section, {
200
+ severity: 'error',
201
+ message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under ${contentFile.imagesLabel}/.`,
202
+ fix: `Add the source image to ${contentFile.imagesLabel}/${section.id}/ or remove the Norna-managed image reference.`,
430
203
  });
431
- continue;
204
+ return null;
432
205
  }
433
206
 
434
- if (sectionsById.has(section.id)) {
435
- addSectionIssue(section.id, {
207
+ if (candidates.length > 1) {
208
+ addSectionIssue(contentFile, section, {
436
209
  severity: 'error',
437
- message: `Duplicate Markdown section heading id "${section.id}".`,
438
- fix: 'Each Markdown level 2 section heading must use a unique explicit id.',
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.',
439
212
  });
440
- continue;
213
+ return null;
441
214
  }
442
215
 
443
- sectionsById.set(section.id, section);
444
- }
216
+ const sourcePath = candidates[0];
217
+ const referencesAtCurrentLocation = (expectedReferencesByPath.get(sourcePath) ?? [])
218
+ .filter((expectedReference) => expectedReference.section.id !== section.id);
445
219
 
446
- for (const id of frontmatterIds) {
447
- if (!sectionsById.has(id)) {
448
- addSectionIssue(id, {
220
+ if (referencesAtCurrentLocation.length > 0) {
221
+ addSectionIssue(contentFile, section, {
449
222
  severity: 'error',
450
- message: `Cannot find a Markdown heading for section "${id}".`,
451
- fix: `Add a level 2 Markdown heading, for example "## Heading {#${id}}".`,
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(', ')}.`,
224
+ fix: 'Remove the extra reference, duplicate the image file manually, or rename one of the image files so the intended move is unambiguous.',
452
225
  });
226
+ return null;
453
227
  }
454
- }
455
228
 
456
- for (const section of sections) {
457
- if (section.id && !frontmatterIds.includes(section.id)) {
458
- addSectionIssue(section.id, {
229
+ imageMoves.push({
230
+ imageName,
231
+ from: sourcePath,
232
+ to: expectedPath,
233
+ contentFile,
234
+ sectionId: section.id,
235
+ });
236
+ referencedImagePaths.add(sourcePath);
237
+
238
+ if (!shouldWrite) {
239
+ addSectionIssue(contentFile, section, {
240
+ 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.',
243
+ });
244
+ }
245
+
246
+ return sourcePath;
247
+ };
248
+
249
+ const warnAboutCarouselAspectRatios = async (contentFile, section, block, sourcePathsByImage) => {
250
+ if (block.type !== 'image-carousel') return;
251
+
252
+ const imagesWithRatios = [];
253
+ const imagesWithoutRatios = [];
254
+ for (const image of block.images) {
255
+ const imagePath = sourcePathsByImage.get(image.image);
256
+ if (!imagePath) continue;
257
+
258
+ const dimensions = await readImageDimensions(imagePath).catch(() => null);
259
+ if (!dimensions) {
260
+ imagesWithoutRatios.push(image.image);
261
+ continue;
262
+ }
263
+
264
+ imagesWithRatios.push({
265
+ image: image.image,
266
+ ratio: getAspectRatioLabel(dimensions),
267
+ });
268
+ }
269
+
270
+ if (imagesWithoutRatios.length > 0) {
271
+ addSectionIssue(contentFile, section, {
459
272
  severity: 'warning',
460
- message: `Markdown section "${section.id}" exists but is not used in frontmatter.`,
461
- fix: 'Add it to frontmatter sections or remove the unused Markdown section.',
273
+ message: `Carousel on line ${block.line} uses images without an intrinsic aspect ratio: ${imagesWithoutRatios.join(', ')}.`,
274
+ fix: 'For SVG images, add a viewBox or numeric width and height when stable carousel sizing matters.',
462
275
  });
463
- extraSections.push(section);
464
276
  }
465
- }
466
277
 
467
- const orderedSections = [
468
- ...frontmatterIds.map((id) => sectionsById.get(id)).filter(Boolean),
469
- ...extraSections,
470
- ];
471
- const currentOrder = sections.map((section) => section.id).filter(Boolean);
472
- const expectedOrder = orderedSections.map((section) => section.id).filter(Boolean);
473
- const hasOrderMismatch =
474
- currentOrder.length !== expectedOrder.length ||
475
- currentOrder.some((id, index) => id !== expectedOrder[index]);
476
-
477
- if (hasOrderMismatch) {
478
- warn('Markdown section order differs from frontmatter.');
278
+ const ratios = new Set(imagesWithRatios.map(({ ratio }) => ratio));
279
+ if (ratios.size <= 1) return;
280
+
281
+ addSectionIssue(contentFile, section, {
282
+ severity: 'warning',
283
+ message: `Carousel on line ${block.line} uses images with different aspect ratios: ${imagesWithRatios.map(({ image, ratio }) => `${image} (${ratio})`).join(', ')}.`,
284
+ fix: 'Use images with exactly matching proportions in the same carousel to avoid uneven layout and motion.',
285
+ });
286
+ };
287
+
288
+ let themeFrontmatter = null;
289
+ try {
290
+ themeFrontmatter = (await readThemeFile(siteThemePath)).frontmatter;
291
+ validateFrontmatterIndentation(themeFrontmatter, addIssue);
292
+ validateThemeFrontmatterStructure(themeFrontmatter, addIssue);
293
+ } catch (error) {
294
+ if (error?.code !== 'ENOENT') {
295
+ throw error;
296
+ }
479
297
  }
480
298
 
481
- for (const section of frontmatterSections) {
482
- for (const imageName of section.images) {
483
- if (imageName.includes('/') || imageName.includes('\\')) {
484
- addSectionIssue(section.id, {
299
+ const themeInlineStyleNames = themeFrontmatter
300
+ ? getFrontmatterInlineStyleNames(themeFrontmatter)
301
+ : new Set();
302
+
303
+ const contentFiles = await getContentFiles();
304
+ const allImageFiles = [];
305
+
306
+ for (const contentFile of contentFiles) {
307
+ const { frontmatter, body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
308
+ const bodyLineOffset = frontmatter.split(/\r?\n/).length - 1;
309
+ validateFrontmatterIndentation(frontmatter, addIssue);
310
+ validateContentFrontmatterStructure(frontmatter, addIssue);
311
+
312
+ const frontmatterSections = getFrontmatterSections(frontmatter);
313
+ const frontmatterIds = new Set(frontmatterSections.map((section) => section.id));
314
+ const { sections } = getBodySections(body);
315
+ const sectionsById = new Map();
316
+ const blockResultsBySectionId = new Map();
317
+ const expectedReferencesByPath = new Map();
318
+ const imageCandidatesByName = await getImageCandidatesByName(contentFile.imagesDir);
319
+
320
+ for (const candidates of imageCandidatesByName.values()) {
321
+ allImageFiles.push(...candidates.map((imagePath) => ({ contentFile, imagePath })));
322
+ }
323
+
324
+ for (const section of sections) {
325
+ if (!section.id) {
326
+ addSectionIssue(contentFile, section, {
485
327
  severity: 'error',
486
- message: `Image reference "${imageName}" must be a filename without a directory.`,
487
- fix: `Use only the filename in ${siteContentLabel} and keep the file in the matching ${siteImagesLabel}/<section-id>/ directory.`,
328
+ message: `Section heading "${section.heading.replace(/^##\s+/, '')}" is missing an explicit id.`,
329
+ fix: `Write it as: ${section.heading} {#section-id}`,
488
330
  });
489
331
  continue;
490
332
  }
491
333
 
492
- if (referencedImages.has(imageName)) {
493
- const previousSectionId = referencedImages.get(imageName);
494
- const message = previousSectionId === section.id
495
- ? `Image "${imageName}" is referenced more than once in this section.`
496
- : `Image "${imageName}" is referenced more than once, in sections "${previousSectionId}" and "${section.id}".`;
497
- const fix = 'Each image filename can be referenced by only one image row.';
334
+ if (sectionsById.has(section.id)) {
335
+ addSectionIssue(contentFile, section, {
336
+ severity: 'error',
337
+ message: `Duplicate Markdown section heading id "${section.id}".`,
338
+ fix: 'Each Markdown level 2 section heading must use a unique explicit id within the page.',
339
+ });
340
+ continue;
341
+ }
342
+
343
+ sectionsById.set(section.id, section);
344
+ }
345
+
346
+ for (const section of sections) {
347
+ if (!section.id || !sectionsById.has(section.id)) continue;
498
348
 
499
- addSectionIssue(previousSectionId, { severity: 'error', message, fix });
500
- if (previousSectionId !== section.id) {
501
- addSectionIssue(section.id, { severity: 'error', message, fix });
349
+ const blockResults = extractNornaMarkdownBlockDiagnostics(section.text, {
350
+ label: contentFile.contentLabel,
351
+ lineOffset: bodyLineOffset + section.line - 1,
352
+ });
353
+ blockResultsBySectionId.set(section.id, blockResults);
354
+
355
+ for (const reference of getNornaBlockImageReferences(blockResults.blocks)) {
356
+ const expectedPath = getExpectedImagePath(contentFile, section.id, reference.image);
357
+ if (!expectedReferencesByPath.has(expectedPath)) {
358
+ expectedReferencesByPath.set(expectedPath, []);
502
359
  }
503
- continue;
360
+ expectedReferencesByPath.get(expectedPath).push({ section, reference });
504
361
  }
362
+ }
505
363
 
506
- referencedImages.set(imageName, section.id);
364
+ for (const id of frontmatterIds) {
365
+ if (!sectionsById.has(id)) {
366
+ addContentIssue(contentFile, {
367
+ severity: 'error',
368
+ message: `Section metadata "${id}" does not match any Markdown section.`,
369
+ fix: `Add a level 2 Markdown heading, for example "## Heading {#${id}}", or remove sections.${id}.`,
370
+ });
371
+ }
372
+ }
507
373
 
508
- const imagePath = imageIndex.get(imageName);
509
- if (!imagePath) {
510
- addSectionIssue(section.id, {
374
+ for (const styleName of new Set(getInlineStyleReferences(body))) {
375
+ if (!themeInlineStyleNames.has(styleName)) {
376
+ addContentIssue(contentFile, {
511
377
  severity: 'error',
512
- message: `Image "${imageName}" does not exist anywhere under ${siteImagesLabel}/.`,
513
- fix: `Add the source image to ${siteImagesLabel}/${section.id}/ or remove the image row.`,
378
+ message: `Inline style ".${styleName}" is used in ${contentFile.contentLabel} but is not defined in ${siteThemeLabel} presentation.inlineStyles.`,
379
+ fix: `Add presentation.inlineStyles.${styleName} to ${siteThemeLabel} or remove "{.${styleName}}" from Markdown.`,
514
380
  });
515
- continue;
516
381
  }
382
+ }
517
383
 
518
- const currentDirectory = path.basename(path.dirname(imagePath));
519
- if (currentDirectory !== section.id) {
520
- const targetPath = path.join(siteImagesDir, section.id, imageName);
521
- const targetExists = await stat(targetPath).then((entry) => entry.isFile()).catch(() => false);
384
+ for (const section of sections) {
385
+ if (!section.id || !sectionsById.has(section.id)) continue;
522
386
 
523
- if (targetExists) {
524
- addSectionIssue(section.id, {
525
- severity: 'error',
526
- message: `Cannot move image "${imageName}" to ${siteImagesLabel}/${section.id}/ because the target file already exists.`,
527
- fix: 'Rename one of the files so image filenames remain globally unique.',
528
- });
529
- continue;
530
- }
387
+ const markdownImages = extractMarkdownImageReferences(section.text);
388
+ for (const reference of markdownImages) {
389
+ addSectionIssue(contentFile, section, {
390
+ severity: 'warning',
391
+ message: `Markdown image "${reference.target}" references a local image that is not managed by Norna.`,
392
+ fix: 'Use a norna-image-stack, norna-image-carousel, or norna-card-list block for site images that should be validated, processed and synced.',
393
+ });
394
+ }
531
395
 
532
- imageMoves.push({ imageName, from: imagePath, to: targetPath, sectionId: section.id });
396
+ const { blocks, errors: blockErrors } = blockResultsBySectionId.get(section.id) ?? { blocks: [], errors: [] };
397
+ for (const error of blockErrors) {
398
+ addSectionIssue(contentFile, section, {
399
+ severity: 'error',
400
+ message: error.message,
401
+ });
402
+ }
533
403
 
534
- if (!shouldWrite) {
535
- addSectionIssue(section.id, {
404
+ for (const block of blocks) {
405
+ if (block.type === 'image-carousel' && block.images.length < 2) {
406
+ addSectionIssue(contentFile, section, {
536
407
  severity: 'error',
537
- message: `Image "${imageName}" is used here but is located in ${siteImagesLabel}/${currentDirectory}/.`,
538
- fix: 'Run norna content:sync, or npm run norna:sync in starter-style repositories, to move it.',
408
+ message: `norna-image-carousel on line ${block.line} contains ${block.images.length} image. A carousel needs at least two images.`,
409
+ fix: 'Add another image entry, or use norna-image-stack for a single image.',
539
410
  });
540
411
  }
541
412
  }
542
- }
543
- }
544
413
 
545
- await warnAboutCarouselAspectRatios();
414
+ const sourcePathsByImage = new Map();
415
+ for (const reference of getNornaBlockImageReferences(blocks)) {
416
+ const sourcePath = await validateImageReference(
417
+ contentFile,
418
+ section,
419
+ reference,
420
+ imageCandidatesByName,
421
+ expectedReferencesByPath,
422
+ );
423
+ if (sourcePath) {
424
+ sourcePathsByImage.set(reference.image, sourcePath);
425
+ }
426
+ }
546
427
 
547
- for (const styleName of new Set(getInlineStyleReferences(body))) {
548
- if (!themeInlineStyleNames.has(styleName)) {
549
- addIssue({
550
- severity: 'error',
551
- message: `Inline style ".${styleName}" is used in Markdown but is not defined in ${siteThemeLabel} presentation.inlineStyles.`,
552
- fix: `Add presentation.inlineStyles.${styleName} to ${siteThemeLabel} or remove "{.${styleName}}" from Markdown.`,
553
- });
428
+ for (const block of blocks) {
429
+ await warnAboutCarouselAspectRatios(contentFile, section, block, sourcePathsByImage);
430
+ }
554
431
  }
555
432
  }
556
433
 
557
- await validateRouteContentFiles();
434
+ const unreferencedImages = allImageFiles
435
+ .filter(({ imagePath }) => !referencedImagePaths.has(imagePath))
436
+ .map(({ contentFile, imagePath }) => `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`)
437
+ .sort((left, right) => left.localeCompare(right, 'sv'));
558
438
 
559
439
  if (hasErrors()) {
560
- printReport(shouldWrite ? 'Content sync failed.' : 'Content check failed.');
440
+ printReport(shouldWrite ? 'Content sync failed.' : 'Content check failed.', unreferencedImages);
561
441
  process.exit(1);
562
442
  }
563
443
 
564
444
  if (shouldCheck) {
565
- const title = issues.length > 0
445
+ const title = issues.length > 0 || unreferencedImages.length > 0
566
446
  ? 'Content check completed with warnings.'
567
447
  : 'Content check passed.';
568
- printReport(title);
448
+ printReport(title, unreferencedImages);
569
449
  process.exit(0);
570
450
  }
571
451
 
572
- if (!hasOrderMismatch && imageMoves.length === 0) {
573
- console.log(`${siteContentLabel}: no sync needed.`);
452
+ if (imageMoves.length === 0) {
453
+ console.log('No content sync needed.');
574
454
  process.exit(0);
575
455
  }
576
456
 
577
- if (issues.length > 0) {
578
- printReport('Content sync completed with warnings.');
579
- }
580
-
581
457
  const canWrite = await promptForWrite();
582
-
583
458
  if (!canWrite) {
584
459
  console.log('Aborted. No file was changed.');
585
460
  process.exit(1);
586
461
  }
587
462
 
588
- const nextBody = `${prelude}${orderedSections.map((section) => section.text).join('\n')}\n`;
589
- if (hasOrderMismatch) {
590
- await writeFile(siteContentPath, `${frontmatter}${nextBody}`);
591
- console.log(`${siteContentLabel}: Markdown sections were sorted according to frontmatter.`);
592
- }
593
-
594
463
  for (const move of imageMoves) {
595
464
  await mkdir(path.dirname(move.to), { recursive: true });
596
465
  await rename(move.from, move.to);
597
- console.log(`Moved image "${move.imageName}" to ${siteImagesLabel}/${move.sectionId}/.`);
466
+ console.log(`Moved image "${move.imageName}" to ${move.contentFile.imagesLabel}/${move.sectionId}/.`);
598
467
  }