@janga/norna 0.7.0

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 (77) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +109 -0
  3. package/astro.config.mjs +17 -0
  4. package/bin/norna.mjs +170 -0
  5. package/docs/README.md +48 -0
  6. package/docs/command-organization.md +402 -0
  7. package/docs/commands.md +152 -0
  8. package/docs/configuration.md +376 -0
  9. package/docs/content.md +384 -0
  10. package/docs/engine-development.md +164 -0
  11. package/docs/getting-started.md +96 -0
  12. package/docs/images-and-metadata.md +88 -0
  13. package/docs/local-development.md +61 -0
  14. package/docs/publishing.md +81 -0
  15. package/docs/site-examples-structure-note.md +105 -0
  16. package/docs/site-structure.md +81 -0
  17. package/fixtures/basic/site/.norna/generated-images.json +1 -0
  18. package/fixtures/basic/site/config.mjs +59 -0
  19. package/fixtures/basic/site/content.md +17 -0
  20. package/fixtures/basic/site/images/work/.gitkeep +1 -0
  21. package/fixtures/basic/site/public/robots.txt +2 -0
  22. package/fixtures/basic/site/theme.md +7 -0
  23. package/package.json +90 -0
  24. package/scripts/build-site.mjs +16 -0
  25. package/scripts/check-config.mjs +37 -0
  26. package/scripts/deploy-site.mjs +389 -0
  27. package/scripts/dev-local.mjs +313 -0
  28. package/scripts/doctor.mjs +38 -0
  29. package/scripts/engine-version.mjs +137 -0
  30. package/scripts/generate-images.mjs +369 -0
  31. package/scripts/init-site.mjs +249 -0
  32. package/scripts/lib/astro-command.mjs +34 -0
  33. package/scripts/lib/ci-lockfile.mjs +34 -0
  34. package/scripts/lib/image-dimensions.mjs +78 -0
  35. package/scripts/lib/presentation.mjs +72 -0
  36. package/scripts/lib/project-config.mjs +322 -0
  37. package/scripts/lib/run-command.mjs +21 -0
  38. package/scripts/lib/site-content.mjs +392 -0
  39. package/scripts/lib/site-paths.mjs +127 -0
  40. package/scripts/lib/typography.mjs +166 -0
  41. package/scripts/release.mjs +77 -0
  42. package/scripts/show-typography.mjs +210 -0
  43. package/scripts/sync-content-sections.mjs +610 -0
  44. package/scripts/sync-site-public.mjs +42 -0
  45. package/scripts/test-ci-lockfile.mjs +65 -0
  46. package/scripts/test-content-check.mjs +364 -0
  47. package/scripts/test-engine-commands.mjs +128 -0
  48. package/scripts/test-navigation-preview.mjs +108 -0
  49. package/scripts/test-navigation.mjs +116 -0
  50. package/scripts/test-package-check.mjs +394 -0
  51. package/scripts/test-site-public.mjs +85 -0
  52. package/scripts/test-temporary-visibility.mjs +99 -0
  53. package/scripts/update-engine.mjs +127 -0
  54. package/scripts/watch-pages-deploy.mjs +430 -0
  55. package/src/components/GalleryGrid.astro +221 -0
  56. package/src/components/SiteNavigation.astro +410 -0
  57. package/src/components/SitePage.astro +69 -0
  58. package/src/components/SiteSection.astro +174 -0
  59. package/src/content.config.ts +171 -0
  60. package/src/layouts/BaseLayout.astro +90 -0
  61. package/src/lib/generatedImages.ts +63 -0
  62. package/src/lib/sectionContent.ts +125 -0
  63. package/src/lib/sitePages.ts +80 -0
  64. package/src/lib/sitePublicAssets.ts +39 -0
  65. package/src/lib/visibility.ts +35 -0
  66. package/src/pages/[slug].astro +31 -0
  67. package/src/pages/index.astro +16 -0
  68. package/src/styles/global.css +872 -0
  69. package/starters/basic/.github/workflows/deploy.yml +65 -0
  70. package/starters/basic/README.md +55 -0
  71. package/starters/basic/package.json +35 -0
  72. package/starters/basic/site/config.mjs +60 -0
  73. package/starters/basic/site/content.md +21 -0
  74. package/starters/basic/site/images/work/.gitkeep +1 -0
  75. package/starters/basic/site/public/robots.txt +2 -0
  76. package/starters/basic/site/theme.md +53 -0
  77. package/tsconfig.json +5 -0
@@ -0,0 +1,610 @@
1
+ import { mkdir, rename, stat, writeFile } from 'node:fs/promises';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { stdin as input, stdout as output } from 'node:process';
4
+ import path from 'node:path';
5
+ import {
6
+ getBodySections,
7
+ getContentFiles,
8
+ getFrontmatterSections,
9
+ getFrontmatterInlineStyleNames,
10
+ getImageIndex,
11
+ getInlineStyleReferences,
12
+ readSiteFile,
13
+ readThemeFile,
14
+ toPosixPath,
15
+ validateContentFrontmatterStructure,
16
+ validateFrontmatterIndentation,
17
+ validateThemeFrontmatterStructure,
18
+ } from './lib/site-content.mjs';
19
+ import { readImageDimensions } from './lib/image-dimensions.mjs';
20
+ import {
21
+ siteContentLabel,
22
+ siteContentPath,
23
+ siteImagesDir,
24
+ siteImagesLabel,
25
+ siteThemeLabel,
26
+ siteThemePath,
27
+ } from './lib/site-paths.mjs';
28
+
29
+ const args = new Set(process.argv.slice(2));
30
+ const shouldWrite = args.has('--write');
31
+ const shouldCheck = args.has('--check') || !shouldWrite;
32
+ const skipPrompt = args.has('--yes');
33
+ const issues = [];
34
+
35
+ const addIssue = ({ severity, message, fix, sectionId, sectionLabel }) => {
36
+ issues.push({ severity, message, fix, sectionId, sectionLabel });
37
+ };
38
+
39
+ const fail = (message) => {
40
+ addIssue({ severity: 'error', message });
41
+ };
42
+
43
+ const warn = (message) => {
44
+ addIssue({ severity: 'warning', message });
45
+ };
46
+
47
+ const addSectionIssue = (sectionId, issue) => {
48
+ addIssue({
49
+ ...issue,
50
+ sectionId,
51
+ sectionLabel: issue.sectionLabel ?? sectionId,
52
+ });
53
+ };
54
+
55
+ const hasErrors = () => issues.some((issue) => issue.severity === 'error');
56
+
57
+ const promptForWrite = async () => {
58
+ if (skipPrompt) return true;
59
+ if (!process.stdin.isTTY) return false;
60
+
61
+ const rl = createInterface({ input, output });
62
+ const answer = await rl.question(`This will rewrite Markdown sections in ${siteContentLabel} and move gallery image files if needed. Continue? [y/N] `);
63
+ rl.close();
64
+
65
+ return answer.trim().toLowerCase() === 'y';
66
+ };
67
+
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
+ const groupIssuesBySeverity = (groupedIssues) => [
112
+ ['error', 'Errors'],
113
+ ['warning', 'Warnings'],
114
+ ].map(([severity, label]) => ({
115
+ label,
116
+ issues: groupedIssues.filter((issue) => issue.severity === severity),
117
+ })).filter((group) => group.issues.length > 0);
118
+
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 getFrontmatterSlug = (frontmatter) =>
168
+ frontmatter.match(/^slug:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim() ?? null;
169
+
170
+ const validateRouteContentFiles = async () => {
171
+ const routeContentFiles = (await getContentFiles()).filter((contentFile) => !contentFile.isHome);
172
+
173
+ for (const contentFile of routeContentFiles) {
174
+ const { frontmatter: routeFrontmatter, body: routeBody } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
175
+ validateFrontmatterIndentation(routeFrontmatter, addIssue);
176
+ validateContentFrontmatterStructure(routeFrontmatter, addIssue);
177
+
178
+ const slug = getFrontmatterSlug(routeFrontmatter);
179
+ if (slug && slug !== contentFile.routeFolder) {
180
+ addIssue({
181
+ severity: 'warning',
182
+ message: `${contentFile.contentLabel} has slug "${slug}" but its route folder is "${contentFile.routeFolder}".`,
183
+ fix: `Keep slug and route folder aligned unless the page intentionally needs to build at /${slug}/.`,
184
+ });
185
+ }
186
+
187
+ const routeFrontmatterSections = getFrontmatterSections(routeFrontmatter);
188
+ const routeFrontmatterIds = routeFrontmatterSections.map((section) => section.id);
189
+ const routeBodySections = getBodySections(routeBody).sections;
190
+ const routeSectionsById = new Map();
191
+ const routeImageIndex = await getImageIndex(contentFile.imagesDir, fail);
192
+
193
+ for (const section of routeBodySections) {
194
+ const sectionLabel = `${contentFile.contentLabel} [${section.id ?? section.heading}]`;
195
+
196
+ if (!section.id) {
197
+ addIssue({
198
+ severity: 'warning',
199
+ sectionLabel,
200
+ message: 'Markdown section is missing an explicit heading id.',
201
+ fix: `Write the heading with an explicit id, for example "${section.heading} {#section-id}".`,
202
+ });
203
+ continue;
204
+ }
205
+
206
+ if (routeSectionsById.has(section.id)) {
207
+ addIssue({
208
+ severity: 'error',
209
+ sectionId: `${contentFile.contentLabel}:${section.id}`,
210
+ sectionLabel,
211
+ message: `Duplicate Markdown section heading id "${section.id}".`,
212
+ fix: 'Each Markdown level 2 section heading must use a unique explicit id.',
213
+ });
214
+ continue;
215
+ }
216
+
217
+ routeSectionsById.set(section.id, section);
218
+ }
219
+
220
+ for (const id of routeFrontmatterIds) {
221
+ if (!routeSectionsById.has(id)) {
222
+ addIssue({
223
+ severity: 'error',
224
+ sectionId: `${contentFile.contentLabel}:${id}`,
225
+ sectionLabel: `${contentFile.contentLabel} [${id}]`,
226
+ message: `Cannot find a Markdown heading for section "${id}".`,
227
+ fix: `Add a level 2 Markdown heading, for example "## Heading {#${id}}".`,
228
+ });
229
+ }
230
+ }
231
+
232
+ for (const section of routeBodySections) {
233
+ if (section.id && !routeFrontmatterIds.includes(section.id)) {
234
+ addIssue({
235
+ severity: 'warning',
236
+ sectionId: `${contentFile.contentLabel}:${section.id}`,
237
+ sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
238
+ message: `Markdown section "${section.id}" exists but is not used in frontmatter.`,
239
+ fix: 'Add it to frontmatter sections or remove the unused Markdown section.',
240
+ });
241
+ }
242
+ }
243
+
244
+ const currentOrder = routeBodySections.map((section) => section.id).filter(Boolean);
245
+ const expectedOrder = routeFrontmatterIds.filter((id) => routeSectionsById.has(id));
246
+ if (
247
+ currentOrder.length !== expectedOrder.length ||
248
+ currentOrder.some((id, index) => id !== expectedOrder[index])
249
+ ) {
250
+ addIssue({
251
+ severity: 'warning',
252
+ message: `${contentFile.contentLabel} Markdown section order differs from frontmatter.`,
253
+ });
254
+ }
255
+
256
+ for (const section of routeFrontmatterSections) {
257
+ for (const imageName of section.images) {
258
+ if (imageName.includes('/') || imageName.includes('\\')) {
259
+ addIssue({
260
+ severity: 'error',
261
+ sectionId: `${contentFile.contentLabel}:${section.id}`,
262
+ sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
263
+ message: `Image reference "${imageName}" must be a filename without a directory.`,
264
+ fix: `Use only the filename in ${contentFile.contentLabel} and keep the file in the matching ${contentFile.imagesLabel}/<section-id>/ directory.`,
265
+ });
266
+ continue;
267
+ }
268
+
269
+ if (referencedImages.has(imageName)) {
270
+ const previousSectionId = referencedImages.get(imageName);
271
+ const message = `Image "${imageName}" is referenced more than once, in "${previousSectionId}" and "${contentFile.contentLabel}:${section.id}".`;
272
+ const fix = 'Each image filename can be referenced by only one gallery row.';
273
+
274
+ addIssue({ severity: 'error', sectionId: previousSectionId, message, fix });
275
+ addIssue({
276
+ severity: 'error',
277
+ sectionId: `${contentFile.contentLabel}:${section.id}`,
278
+ sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
279
+ message,
280
+ fix,
281
+ });
282
+ continue;
283
+ }
284
+
285
+ referencedImages.set(imageName, `${contentFile.contentLabel}:${section.id}`);
286
+
287
+ const imagePath = routeImageIndex.get(imageName);
288
+ if (!imagePath) {
289
+ addIssue({
290
+ severity: 'error',
291
+ sectionId: `${contentFile.contentLabel}:${section.id}`,
292
+ sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
293
+ message: `Image "${imageName}" does not exist anywhere under ${contentFile.imagesLabel}/.`,
294
+ fix: `Add the source image to ${contentFile.imagesLabel}/${section.id}/ or remove the gallery row.`,
295
+ });
296
+ continue;
297
+ }
298
+
299
+ const currentDirectory = path.basename(path.dirname(imagePath));
300
+ if (currentDirectory !== section.id) {
301
+ addIssue({
302
+ severity: 'error',
303
+ sectionId: `${contentFile.contentLabel}:${section.id}`,
304
+ sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
305
+ message: `Image "${imageName}" is used here but is located in ${contentFile.imagesLabel}/${currentDirectory}/.`,
306
+ fix: 'Move it to the matching section image directory.',
307
+ });
308
+ }
309
+ }
310
+
311
+ for (const carousel of section.carousels ?? []) {
312
+ const imagesWithRatios = [];
313
+
314
+ for (const { image } of carousel.imageReferences ?? []) {
315
+ const imagePath = routeImageIndex.get(image);
316
+ if (!imagePath) continue;
317
+
318
+ const dimensions = await readImageDimensions(imagePath).catch(() => null);
319
+ if (!dimensions) continue;
320
+
321
+ imagesWithRatios.push({
322
+ image,
323
+ ratio: getAspectRatioLabel(dimensions),
324
+ });
325
+ }
326
+
327
+ const ratios = new Set(imagesWithRatios.map(({ ratio }) => ratio));
328
+ if (ratios.size <= 1) continue;
329
+
330
+ addIssue({
331
+ severity: 'warning',
332
+ sectionId: `${contentFile.contentLabel}:${section.id}`,
333
+ sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
334
+ message: `Carousel on line ${carousel.line} uses images with different aspect ratios: ${imagesWithRatios.map(({ image, ratio }) => `${image} (${ratio})`).join(', ')}.`,
335
+ fix: 'Use images with exactly matching proportions in the same carousel to avoid uneven layout and motion.',
336
+ });
337
+ }
338
+ }
339
+
340
+ for (const styleName of new Set(getInlineStyleReferences(routeBody))) {
341
+ if (!themeInlineStyleNames.has(styleName)) {
342
+ addIssue({
343
+ severity: 'error',
344
+ message: `Inline style ".${styleName}" is used in ${contentFile.contentLabel} but is not defined in ${siteThemeLabel} presentation.inlineStyles.`,
345
+ fix: `Add presentation.inlineStyles.${styleName} to ${siteThemeLabel} or remove "{.${styleName}}" from Markdown.`,
346
+ });
347
+ }
348
+ }
349
+ }
350
+ };
351
+
352
+ const formatIssue = (issue) => [
353
+ `- ${issue.message}`,
354
+ issue.fix ? ` Fix: ${issue.fix}` : null,
355
+ ].filter(Boolean);
356
+
357
+ const getReportLines = (title) => {
358
+ const lines = [title];
359
+ const sectionIssues = issues.filter((issue) => issue.sectionId || issue.sectionLabel);
360
+ const globalIssues = issues.filter((issue) => !issue.sectionId && !issue.sectionLabel);
361
+
362
+ if (sectionIssues.length > 0) {
363
+ const sectionOrder = getSectionReportOrder();
364
+ const sectionGroups = new Map();
365
+
366
+ for (const issue of sectionIssues) {
367
+ const key = issue.sectionId ?? issue.sectionLabel;
368
+ const label = issue.sectionLabel ?? issue.sectionId;
369
+
370
+ if (!sectionGroups.has(key)) {
371
+ sectionGroups.set(key, { label, issues: [] });
372
+ }
373
+
374
+ sectionGroups.get(key).issues.push(issue);
375
+ }
376
+
377
+ const orderedSectionGroups = Array.from(sectionGroups.entries()).sort(([leftKey], [rightKey]) => (
378
+ (sectionOrder.get(leftKey) ?? Number.MAX_SAFE_INTEGER) -
379
+ (sectionOrder.get(rightKey) ?? Number.MAX_SAFE_INTEGER) ||
380
+ leftKey.localeCompare(rightKey, 'sv')
381
+ ));
382
+
383
+ lines.push('', 'Section and Gallery Issues');
384
+
385
+ for (const [, group] of orderedSectionGroups) {
386
+ lines.push('', `[${group.label}]`);
387
+
388
+ for (const severityGroup of groupIssuesBySeverity(group.issues)) {
389
+ lines.push(` ${severityGroup.label}:`);
390
+
391
+ for (const issue of severityGroup.issues) {
392
+ for (const line of formatIssue(issue)) {
393
+ lines.push(` ${line}`);
394
+ }
395
+ }
396
+ }
397
+ }
398
+ }
399
+
400
+ if (globalIssues.length > 0) {
401
+ lines.push('', 'Global Content Issues');
402
+
403
+ for (const severityGroup of groupIssuesBySeverity(globalIssues)) {
404
+ lines.push('', `${severityGroup.label}:`);
405
+
406
+ for (const issue of severityGroup.issues) {
407
+ lines.push(...formatIssue(issue));
408
+ }
409
+ }
410
+ }
411
+
412
+ const unreferencedImages = getUnreferencedImages();
413
+
414
+ if (unreferencedImages.length > 0) {
415
+ lines.push(
416
+ '',
417
+ 'Unreferenced Images',
418
+ `These files are kept in ${siteImagesLabel}/ but are not mounted on the site:`,
419
+ );
420
+
421
+ for (const imagePath of unreferencedImages) {
422
+ lines.push(`- ${imagePath}`);
423
+ }
424
+ }
425
+
426
+ return lines;
427
+ };
428
+
429
+ const printReport = (title) => {
430
+ const lines = getReportLines(title);
431
+ const output = hasErrors() ? console.error : console.log;
432
+ output(lines.join('\n'));
433
+ };
434
+
435
+ for (const section of sections) {
436
+ if (!section.id) {
437
+ addSectionIssue(null, {
438
+ severity: 'warning',
439
+ sectionLabel: section.heading,
440
+ message: 'Markdown section is missing an explicit heading id.',
441
+ fix: `Write the heading with an explicit id, for example "${section.heading} {#section-id}".`,
442
+ });
443
+ continue;
444
+ }
445
+
446
+ if (sectionsById.has(section.id)) {
447
+ addSectionIssue(section.id, {
448
+ severity: 'error',
449
+ message: `Duplicate Markdown section heading id "${section.id}".`,
450
+ fix: 'Each Markdown level 2 section heading must use a unique explicit id.',
451
+ });
452
+ continue;
453
+ }
454
+
455
+ sectionsById.set(section.id, section);
456
+ }
457
+
458
+ for (const id of frontmatterIds) {
459
+ if (!sectionsById.has(id)) {
460
+ addSectionIssue(id, {
461
+ severity: 'error',
462
+ message: `Cannot find a Markdown heading for section "${id}".`,
463
+ fix: `Add a level 2 Markdown heading, for example "## Heading {#${id}}".`,
464
+ });
465
+ }
466
+ }
467
+
468
+ for (const section of sections) {
469
+ if (section.id && !frontmatterIds.includes(section.id)) {
470
+ addSectionIssue(section.id, {
471
+ severity: 'warning',
472
+ message: `Markdown section "${section.id}" exists but is not used in frontmatter.`,
473
+ fix: 'Add it to frontmatter sections or remove the unused Markdown section.',
474
+ });
475
+ extraSections.push(section);
476
+ }
477
+ }
478
+
479
+ const orderedSections = [
480
+ ...frontmatterIds.map((id) => sectionsById.get(id)).filter(Boolean),
481
+ ...extraSections,
482
+ ];
483
+ const currentOrder = sections.map((section) => section.id).filter(Boolean);
484
+ const expectedOrder = orderedSections.map((section) => section.id).filter(Boolean);
485
+ const hasOrderMismatch =
486
+ currentOrder.length !== expectedOrder.length ||
487
+ currentOrder.some((id, index) => id !== expectedOrder[index]);
488
+
489
+ if (hasOrderMismatch) {
490
+ warn('Markdown section order differs from frontmatter.');
491
+ }
492
+
493
+ for (const section of frontmatterSections) {
494
+ for (const imageName of section.images) {
495
+ if (imageName.includes('/') || imageName.includes('\\')) {
496
+ addSectionIssue(section.id, {
497
+ severity: 'error',
498
+ message: `Image reference "${imageName}" must be a filename without a directory.`,
499
+ fix: `Use only the filename in ${siteContentLabel} and keep the file in the matching ${siteImagesLabel}/<section-id>/ directory.`,
500
+ });
501
+ continue;
502
+ }
503
+
504
+ if (referencedImages.has(imageName)) {
505
+ const previousSectionId = referencedImages.get(imageName);
506
+ const message = previousSectionId === section.id
507
+ ? `Image "${imageName}" is referenced more than once in this section.`
508
+ : `Image "${imageName}" is referenced more than once, in sections "${previousSectionId}" and "${section.id}".`;
509
+ const fix = 'Each image filename can be referenced by only one gallery row.';
510
+
511
+ addSectionIssue(previousSectionId, { severity: 'error', message, fix });
512
+ if (previousSectionId !== section.id) {
513
+ addSectionIssue(section.id, { severity: 'error', message, fix });
514
+ }
515
+ continue;
516
+ }
517
+
518
+ referencedImages.set(imageName, section.id);
519
+
520
+ const imagePath = imageIndex.get(imageName);
521
+ if (!imagePath) {
522
+ addSectionIssue(section.id, {
523
+ severity: 'error',
524
+ message: `Image "${imageName}" does not exist anywhere under ${siteImagesLabel}/.`,
525
+ fix: `Add the source image to ${siteImagesLabel}/${section.id}/ or remove the gallery row.`,
526
+ });
527
+ continue;
528
+ }
529
+
530
+ const currentDirectory = path.basename(path.dirname(imagePath));
531
+ if (currentDirectory !== section.id) {
532
+ const targetPath = path.join(siteImagesDir, section.id, imageName);
533
+ const targetExists = await stat(targetPath).then((entry) => entry.isFile()).catch(() => false);
534
+
535
+ if (targetExists) {
536
+ addSectionIssue(section.id, {
537
+ severity: 'error',
538
+ message: `Cannot move image "${imageName}" to ${siteImagesLabel}/${section.id}/ because the target file already exists.`,
539
+ fix: 'Rename one of the files so image filenames remain globally unique.',
540
+ });
541
+ continue;
542
+ }
543
+
544
+ imageMoves.push({ imageName, from: imagePath, to: targetPath, sectionId: section.id });
545
+
546
+ if (!shouldWrite) {
547
+ addSectionIssue(section.id, {
548
+ severity: 'error',
549
+ message: `Image "${imageName}" is used here but is located in ${siteImagesLabel}/${currentDirectory}/.`,
550
+ fix: 'Run norna content:sync, or npm run norna:sync in starter-style repositories, to move it.',
551
+ });
552
+ }
553
+ }
554
+ }
555
+ }
556
+
557
+ await warnAboutCarouselAspectRatios();
558
+
559
+ for (const styleName of new Set(getInlineStyleReferences(body))) {
560
+ if (!themeInlineStyleNames.has(styleName)) {
561
+ addIssue({
562
+ severity: 'error',
563
+ message: `Inline style ".${styleName}" is used in Markdown but is not defined in ${siteThemeLabel} presentation.inlineStyles.`,
564
+ fix: `Add presentation.inlineStyles.${styleName} to ${siteThemeLabel} or remove "{.${styleName}}" from Markdown.`,
565
+ });
566
+ }
567
+ }
568
+
569
+ await validateRouteContentFiles();
570
+
571
+ if (hasErrors()) {
572
+ printReport(shouldWrite ? 'Content sync failed.' : 'Content check failed.');
573
+ process.exit(1);
574
+ }
575
+
576
+ if (shouldCheck) {
577
+ const title = issues.length > 0
578
+ ? 'Content check completed with warnings.'
579
+ : 'Content check passed.';
580
+ printReport(title);
581
+ process.exit(0);
582
+ }
583
+
584
+ if (!hasOrderMismatch && imageMoves.length === 0) {
585
+ console.log(`${siteContentLabel}: no sync needed.`);
586
+ process.exit(0);
587
+ }
588
+
589
+ if (issues.length > 0) {
590
+ printReport('Content sync completed with warnings.');
591
+ }
592
+
593
+ const canWrite = await promptForWrite();
594
+
595
+ if (!canWrite) {
596
+ console.log('Aborted. No file was changed.');
597
+ process.exit(1);
598
+ }
599
+
600
+ const nextBody = `${prelude}${orderedSections.map((section) => section.text).join('\n')}\n`;
601
+ if (hasOrderMismatch) {
602
+ await writeFile(siteContentPath, `${frontmatter}${nextBody}`);
603
+ console.log(`${siteContentLabel}: Markdown sections were sorted according to frontmatter.`);
604
+ }
605
+
606
+ for (const move of imageMoves) {
607
+ await mkdir(path.dirname(move.to), { recursive: true });
608
+ await rename(move.from, move.to);
609
+ console.log(`Moved image "${move.imageName}" to ${siteImagesLabel}/${move.sectionId}/.`);
610
+ }
@@ -0,0 +1,42 @@
1
+ import { cp, mkdir, readdir, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ astroPublicDir,
5
+ astroPublicLabel,
6
+ sitePublicDir,
7
+ sitePublicLabel,
8
+ } from './lib/site-paths.mjs';
9
+
10
+ const keepAstroPublicEntries = new Set(['images']);
11
+
12
+ const readDirectory = async (directory) => {
13
+ try {
14
+ return await readdir(directory, { withFileTypes: true });
15
+ } catch (error) {
16
+ if (error?.code === 'ENOENT') {
17
+ return [];
18
+ }
19
+
20
+ throw error;
21
+ }
22
+ };
23
+
24
+ await mkdir(astroPublicDir, { recursive: true });
25
+
26
+ for (const entry of await readDirectory(astroPublicDir)) {
27
+ if (keepAstroPublicEntries.has(entry.name)) {
28
+ continue;
29
+ }
30
+
31
+ await rm(path.join(astroPublicDir, entry.name), { force: true, recursive: true });
32
+ }
33
+
34
+ for (const entry of await readDirectory(sitePublicDir)) {
35
+ await cp(
36
+ path.join(sitePublicDir, entry.name),
37
+ path.join(astroPublicDir, entry.name),
38
+ { force: true, recursive: true },
39
+ );
40
+ }
41
+
42
+ console.log(`Synced ${sitePublicLabel}/ to ${astroPublicLabel}/.`);