@janga/norna 0.7.22 → 0.7.24

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 (88) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +15 -0
  3. package/bin/norna-cli.mjs +8 -2
  4. package/package.json +28 -3
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +52 -24
  7. package/schemas/content-frontmatter.schema.json +25 -5
  8. package/schemas/page-theme.schema.json +53 -25
  9. package/schemas/sitewide-content.schema.json +19 -18
  10. package/schemas/theme.schema.json +385 -252
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +36 -5
  13. package/scripts/deploy-site.mjs +0 -1
  14. package/scripts/dev-local.mjs +175 -112
  15. package/scripts/generate-search-index.mjs +57 -0
  16. package/scripts/init-site.mjs +1 -0
  17. package/scripts/lib/code-fence-metadata.mjs +223 -0
  18. package/scripts/lib/content-sync-apply.mjs +34 -0
  19. package/scripts/lib/content-sync-plan.mjs +179 -0
  20. package/scripts/lib/edit-source-link.mjs +49 -0
  21. package/scripts/lib/editor-language-service.mjs +160 -12
  22. package/scripts/lib/heading-ids.mjs +1 -1
  23. package/scripts/lib/image-presentation.mjs +6 -0
  24. package/scripts/lib/markdown-links.mjs +182 -0
  25. package/scripts/lib/navigation-model.mjs +44 -12
  26. package/scripts/lib/navigation-review.mjs +396 -0
  27. package/scripts/lib/norna-markdown-blocks.mjs +74 -26
  28. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  29. package/scripts/lib/page-aliases.mjs +184 -0
  30. package/scripts/lib/page-markdown.mjs +88 -7
  31. package/scripts/lib/page-move-plan.mjs +659 -0
  32. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  33. package/scripts/lib/presentation.mjs +10 -37
  34. package/scripts/lib/project-config.mjs +146 -19
  35. package/scripts/lib/public-asset-conventions.mjs +36 -2
  36. package/scripts/lib/schema-definitions.mjs +43 -15
  37. package/scripts/lib/schema-editor-metadata.mjs +60 -32
  38. package/scripts/lib/schema-value-definitions.mjs +6 -2
  39. package/scripts/lib/semantic-callouts.mjs +128 -0
  40. package/scripts/lib/site-content.mjs +28 -5
  41. package/scripts/lib/site-link-graph.mjs +357 -0
  42. package/scripts/lib/site-navigation-tree.mjs +85 -0
  43. package/scripts/lib/site-page-urls.mjs +10 -0
  44. package/scripts/lib/sitemap.mjs +34 -0
  45. package/scripts/lib/social-image-assets.mjs +30 -0
  46. package/scripts/lib/theme-presets.mjs +53 -25
  47. package/scripts/lib/theme-profiles.mjs +6 -8
  48. package/scripts/lib/yaml-config.mjs +6 -2
  49. package/scripts/move-site-page.mjs +256 -0
  50. package/scripts/review-navigation.mjs +32 -0
  51. package/scripts/sync-content-sections.mjs +182 -216
  52. package/scripts/sync-site-public.mjs +43 -3
  53. package/src/components/CodeBlockCopyScript.astro +97 -0
  54. package/src/components/DisplaySettings.astro +4 -4
  55. package/src/components/EditSourceLink.astro +17 -0
  56. package/src/components/ImageCarousel.astro +6 -3
  57. package/src/components/NavigationPageTree.astro +81 -86
  58. package/src/components/NavigationTreeControls.astro +77 -0
  59. package/src/components/PageAliasRedirect.astro +49 -0
  60. package/src/components/PageContentsNavigation.astro +21 -0
  61. package/src/components/PageList.astro +33 -0
  62. package/src/components/PageSequenceNavigation.astro +37 -0
  63. package/src/components/SearchPage.astro +151 -0
  64. package/src/components/SectionNavigationScript.astro +62 -6
  65. package/src/components/SiteNavigation.astro +50 -11
  66. package/src/components/SitePage.astro +113 -20
  67. package/src/components/SiteSection.astro +16 -3
  68. package/src/components/SiteTreeNavigation.astro +8 -2
  69. package/src/components/TreeNavigationScript.astro +207 -26
  70. package/src/content.config.ts +5 -2
  71. package/src/layouts/BaseLayout.astro +71 -10
  72. package/src/lib/readerPreferencesScript.mjs +2 -2
  73. package/src/lib/sectionContent.ts +32 -3
  74. package/src/lib/siteNavigation.ts +31 -52
  75. package/src/lib/sitePages.ts +3 -2
  76. package/src/lib/sitePublicAssets.ts +1 -0
  77. package/src/pages/404.astro +110 -0
  78. package/src/pages/[...slug].astro +45 -8
  79. package/src/styles/content.css +718 -0
  80. package/src/styles/foundations.css +284 -0
  81. package/src/styles/global.css +6 -2510
  82. package/src/styles/media.css +518 -0
  83. package/src/styles/navigation.css +526 -0
  84. package/src/styles/page-layout.css +786 -0
  85. package/src/styles/responsive.css +594 -0
  86. package/starters/basic/README.md +6 -19
  87. package/starters/basic/package.json +1 -0
  88. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -1,12 +1,18 @@
1
- import { mkdir, rename, stat } from 'node:fs/promises';
2
1
  import { createInterface } from 'node:readline/promises';
3
2
  import { stdin as input, stdout as output } from 'node:process';
4
3
  import path from 'node:path';
4
+ import { applyImageSyncPlan } from './lib/content-sync-apply.mjs';
5
+ import {
6
+ createImageSyncPlan,
7
+ getExpectedImageLabel,
8
+ getImageCandidateLabel,
9
+ } from './lib/content-sync-plan.mjs';
5
10
  import {
6
11
  getImageCandidatesByName,
7
12
  getDeprecatedInlineStyleReferences,
8
13
  readSiteFile,
9
14
  readThemeFile,
15
+ parseContentFrontmatter,
10
16
  toPosixPath,
11
17
  validateContentFrontmatterStructure,
12
18
  validateFrontmatterIndentation,
@@ -14,7 +20,17 @@ import {
14
20
  } from './lib/site-content.mjs';
15
21
  import { getSiteStructure } from './lib/site-structure.mjs';
16
22
  import { readImageDimensions } from './lib/image-dimensions.mjs';
17
- import { parsePageMarkdown } from './lib/page-markdown.mjs';
23
+ import { parsePageMarkdownSource } from './lib/page-markdown.mjs';
24
+ import {
25
+ getDirectChildPages,
26
+ getListedSiteNavigationTree,
27
+ } from './lib/site-navigation-tree.mjs';
28
+ import {
29
+ createSiteLinkGraph,
30
+ getSitePublicFiles,
31
+ } from './lib/site-link-graph.mjs';
32
+ import { getGeneratedSiteRoutes } from './lib/page-aliases.mjs';
33
+ import projectConfig from './lib/project-config.mjs';
18
34
  import {
19
35
  siteThemeLabel,
20
36
  siteThemePath,
@@ -25,8 +41,6 @@ const shouldWrite = args.has('--write');
25
41
  const shouldCheck = args.has('--check') || !shouldWrite;
26
42
  const skipPrompt = args.has('--yes');
27
43
  const issues = [];
28
- const imageMoves = [];
29
- const referencedImagePaths = new Set();
30
44
 
31
45
  const addIssue = ({ severity, message, fix, sectionId, sectionLabel }) => {
32
46
  issues.push({ severity, message, fix, sectionId, sectionLabel });
@@ -50,7 +64,7 @@ const addSectionIssue = (contentFile, section, issue) => addIssue({
50
64
 
51
65
  const getMovePlanLines = (moves) => [
52
66
  'Planned image moves:',
53
- ...moves.map((move) => `- ${getCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from })} -> ${getExpectedImageLabel(move.contentFile, move.imageName)}`),
67
+ ...moves.map((move) => `- ${getImageCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from })} -> ${getExpectedImageLabel(move.contentFile, move.imageName)}`),
54
68
  ];
55
69
 
56
70
  const promptForWrite = async (moves) => {
@@ -166,157 +180,6 @@ const getAspectRatioLabel = ({ width, height }) => {
166
180
  return `${width / divisor}:${height / divisor}`;
167
181
  };
168
182
 
169
- const getExpectedImagePath = (contentFile, imageName) =>
170
- path.join(contentFile.imagesDir, imageName);
171
-
172
- const getExpectedImageLabel = (contentFile, imageName) =>
173
- `${contentFile.imagesLabel}/${imageName}`;
174
-
175
- const getGlobalImageCandidates = (globalImageCandidatesByName, imageName, expectedPath) =>
176
- (globalImageCandidatesByName.get(imageName) ?? []).filter(({ imagePath }) => imagePath !== expectedPath);
177
-
178
- const getReferenceLabel = (contentFile, section) => `${contentFile.contentLabel} [${section.id ?? 'page title'}]`;
179
-
180
- const getRootLabel = (contentFile) => contentFile.imagesLabel.replace(/\/images$/, '');
181
-
182
- const getCandidateLabel = ({ contentFile, imagePath }) =>
183
- `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`;
184
-
185
- const addExpectedReference = (expectedReferencesByPath, expectedPath, expectedReference) => {
186
- if (!expectedReferencesByPath.has(expectedPath)) {
187
- expectedReferencesByPath.set(expectedPath, []);
188
- }
189
-
190
- expectedReferencesByPath.get(expectedPath).push(expectedReference);
191
- };
192
-
193
- const addGlobalImageCandidate = (globalImageCandidatesByName, imageName, candidate) => {
194
- if (!globalImageCandidatesByName.has(imageName)) {
195
- globalImageCandidatesByName.set(imageName, []);
196
- }
197
-
198
- globalImageCandidatesByName.get(imageName).push(candidate);
199
- };
200
-
201
- const addConflictingMoveIssues = () => {
202
- const movesBySource = new Map();
203
- const movesByDestination = new Map();
204
-
205
- for (const move of imageMoves) {
206
- const sourceKey = move.from;
207
- const destinationKey = move.to;
208
-
209
- if (!movesBySource.has(sourceKey)) {
210
- movesBySource.set(sourceKey, []);
211
- }
212
- movesBySource.get(sourceKey).push(move);
213
-
214
- if (!movesByDestination.has(destinationKey)) {
215
- movesByDestination.set(destinationKey, []);
216
- }
217
- movesByDestination.get(destinationKey).push(move);
218
- }
219
-
220
- for (const moves of movesBySource.values()) {
221
- const destinations = new Set(moves.map((move) => move.to));
222
- if (destinations.size <= 1) continue;
223
-
224
- const firstMove = moves[0];
225
- addSectionIssue(firstMove.contentFile, firstMove.section, {
226
- severity: 'error',
227
- message: `Cannot relocate "${firstMove.imageName}" because the same source file is referenced from multiple destinations: ${moves.map((move) => getExpectedImageLabel(move.contentFile, move.imageName)).join(', ')}.`,
228
- fix: 'Duplicate the image manually or rename one of the image files so each move has a single destination.',
229
- });
230
- }
231
-
232
- for (const moves of movesByDestination.values()) {
233
- const sources = new Set(moves.map((move) => move.from));
234
- if (sources.size <= 1) continue;
235
-
236
- const firstMove = moves[0];
237
- addSectionIssue(firstMove.contentFile, firstMove.section, {
238
- severity: 'error',
239
- message: `Cannot relocate "${firstMove.imageName}" because multiple source files would move to ${getExpectedImageLabel(firstMove.contentFile, firstMove.imageName)}: ${moves.map((move) => getCandidateLabel(move.sourceContentFile ? { contentFile: move.sourceContentFile, imagePath: move.from } : move)).join(', ')}.`,
240
- fix: 'Move the intended file manually or rename files so the destination is unambiguous.',
241
- });
242
- }
243
- };
244
-
245
- const validateImageReference = async (
246
- contentFile,
247
- section,
248
- reference,
249
- globalImageCandidatesByName,
250
- expectedReferencesByPath,
251
- ) => {
252
- const imageName = reference.image;
253
- const expectedPath = getExpectedImagePath(contentFile, imageName);
254
- const expectedLabel = getExpectedImageLabel(contentFile, imageName);
255
- const expectedExists = await stat(expectedPath).then((entry) => entry.isFile()).catch(() => false);
256
-
257
- if (expectedExists) {
258
- referencedImagePaths.add(expectedPath);
259
- return expectedPath;
260
- }
261
-
262
- const candidates = getGlobalImageCandidates(globalImageCandidatesByName, imageName, expectedPath);
263
- if (candidates.length === 0) {
264
- addSectionIssue(contentFile, section, {
265
- severity: 'error',
266
- message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under any page image root.`,
267
- fix: `Add the source image directly to ${contentFile.imagesLabel}/ or remove the Norna-managed image reference.`,
268
- });
269
- return null;
270
- }
271
-
272
- if (candidates.length > 1) {
273
- addSectionIssue(contentFile, section, {
274
- severity: 'error',
275
- message: `Cannot relocate "${imageName}". Multiple files with this filename were found: ${candidates.map(getCandidateLabel).join(', ')}.`,
276
- fix: 'Move the intended file manually or rename files so the move is unambiguous.',
277
- });
278
- return null;
279
- }
280
-
281
- const sourceCandidate = candidates[0];
282
- const sourcePath = sourceCandidate.imagePath;
283
- const referencesAtCurrentLocation = (expectedReferencesByPath.get(sourcePath) ?? [])
284
- .filter((expectedReference) => expectedReference.contentFile !== contentFile);
285
-
286
- if (referencesAtCurrentLocation.length > 0) {
287
- addSectionIssue(contentFile, section, {
288
- severity: 'error',
289
- message: `Cannot relocate "${imageName}" from ${getCandidateLabel(sourceCandidate)} because it is still referenced from ${referencesAtCurrentLocation.map((expectedReference) => getReferenceLabel(expectedReference.contentFile, expectedReference.section)).join(', ')}.`,
290
- fix: 'Remove the extra reference, duplicate the image file manually, or rename one of the image files so the intended move is unambiguous.',
291
- });
292
- return null;
293
- }
294
-
295
- if (!imageMoves.some((move) => move.from === sourcePath && move.to === expectedPath)) {
296
- imageMoves.push({
297
- imageName,
298
- from: sourcePath,
299
- to: expectedPath,
300
- contentFile,
301
- sourceContentFile: sourceCandidate.contentFile,
302
- section,
303
- });
304
- }
305
- referencedImagePaths.add(sourcePath);
306
-
307
- if (!shouldWrite) {
308
- addSectionIssue(contentFile, section, {
309
- severity: 'error',
310
- message: `Image "${imageName}" is used here but is located in ${getCandidateLabel(sourceCandidate)}.`,
311
- fix: sourceCandidate.contentFile === contentFile
312
- ? 'Run norna content:sync to move it directly into the current page image root.'
313
- : `Run norna content:sync to move it from ${getRootLabel(sourceCandidate.contentFile)} to ${getRootLabel(contentFile)}.`,
314
- });
315
- }
316
-
317
- return sourcePath;
318
- };
319
-
320
183
  const warnAboutCarouselAspectRatios = async (contentFile, section, block, sourcePathsByImage) => {
321
184
  if (block.type !== 'image-carousel') return;
322
185
 
@@ -378,19 +241,30 @@ for (const warning of siteStructure.warnings) {
378
241
  });
379
242
  }
380
243
  const allImageFiles = [];
244
+ const managedImageReferences = [];
381
245
  const contentFileContexts = [];
382
- const globalImageCandidatesByName = new Map();
383
- const globalExpectedReferencesByPath = new Map();
384
246
 
385
247
  for (const contentFile of contentFiles) {
386
- const { frontmatter, body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
248
+ const { frontmatter, frontmatterBody, body, source } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
387
249
  const bodyLineOffset = frontmatter.split(/\r?\n/).length - 1;
250
+ const issueCountBeforeFrontmatter = issues.length;
388
251
  validateFrontmatterIndentation(frontmatter, addIssue);
389
252
  validateContentFrontmatterStructure(frontmatter, addIssue);
253
+ let frontmatterData = {};
254
+ if (!issues.slice(issueCountBeforeFrontmatter).some(({ severity }) => severity === 'error')) {
255
+ try {
256
+ frontmatterData = parseContentFrontmatter(frontmatterBody, contentFile.contentLabel);
257
+ } catch (error) {
258
+ addContentIssue(contentFile, {
259
+ severity: 'error',
260
+ message: error instanceof Error ? error.message : String(error),
261
+ fix: 'Correct the page frontmatter using the content reference and project-local schema help.',
262
+ });
263
+ }
264
+ }
390
265
 
391
- const page = await parsePageMarkdown(body, {
266
+ const page = await parsePageMarkdownSource(source, {
392
267
  label: contentFile.contentLabel,
393
- lineOffset: bodyLineOffset,
394
268
  });
395
269
  const {
396
270
  headingIssues: headingIdentifierIssues,
@@ -421,9 +295,7 @@ for (const contentFile of contentFiles) {
421
295
 
422
296
  for (const [imageName, candidates] of imageCandidatesByName) {
423
297
  for (const imagePath of candidates) {
424
- const candidate = { contentFile, imagePath };
425
- allImageFiles.push(candidate);
426
- addGlobalImageCandidate(globalImageCandidatesByName, imageName, candidate);
298
+ allImageFiles.push({ contentFile, imageName, imagePath });
427
299
  }
428
300
  }
429
301
 
@@ -497,14 +369,31 @@ for (const contentFile of contentFiles) {
497
369
  });
498
370
  }
499
371
 
372
+ for (const error of section.calloutErrors) {
373
+ addSectionIssue(contentFile, section, {
374
+ severity: 'error',
375
+ message: error.message,
376
+ fix: error.fix,
377
+ });
378
+ }
379
+
380
+ for (const error of section.codeFenceErrors) {
381
+ addSectionIssue(contentFile, section, {
382
+ severity: 'error',
383
+ message: error.message,
384
+ fix: error.fix,
385
+ });
386
+ }
387
+
500
388
  for (const reference of section.managedImages) {
501
- const expectedPath = getExpectedImagePath(contentFile, reference.image);
502
- addExpectedReference(globalExpectedReferencesByPath, expectedPath, { contentFile, section, reference });
389
+ managedImageReferences.push({ contentFile, section, reference });
503
390
  }
504
391
  }
505
392
 
506
393
  contentFileContexts.push({
507
394
  contentFile,
395
+ frontmatterData,
396
+ page,
508
397
  sections,
509
398
  validSections,
510
399
  blockResultsBySection,
@@ -512,6 +401,85 @@ for (const contentFile of contentFiles) {
512
401
  });
513
402
  }
514
403
 
404
+ const contentContextByPageDirectory = new Map(contentFileContexts.map((context) => [
405
+ context.contentFile.pageDirectory,
406
+ context,
407
+ ]));
408
+ const listedNavigationTree = getListedSiteNavigationTree(siteStructure.nodes.map((node) => {
409
+ const context = contentContextByPageDirectory.get(node.pageDirectory);
410
+ return {
411
+ node: {
412
+ ...node,
413
+ navigation: {
414
+ listed: node.kind === 'category'
415
+ ? true
416
+ : context?.frontmatterData.navigation?.listed ?? true,
417
+ },
418
+ },
419
+ };
420
+ }));
421
+
422
+ for (const context of contentFileContexts) {
423
+ const childPages = getDirectChildPages(listedNavigationTree, context.contentFile.pagePath);
424
+ if (childPages.length > 0) continue;
425
+
426
+ for (const section of context.sections) {
427
+ if (!context.validSections.has(section)) continue;
428
+ for (const block of section.blocks) {
429
+ if (block.type !== 'page-list') continue;
430
+ addSectionIssue(context.contentFile, section, {
431
+ severity: 'error',
432
+ message: `page-list on line ${block.line} has no listed direct child pages to display. Navigation categories are not pages.`,
433
+ fix: 'Add a listed direct child page or remove the block.',
434
+ });
435
+ }
436
+ }
437
+ }
438
+
439
+ const siteLinkGraph = createSiteLinkGraph({
440
+ pageDocuments: contentFileContexts.map(({ contentFile, frontmatterData, page }) => ({
441
+ contentFile,
442
+ data: frontmatterData,
443
+ document: page,
444
+ })),
445
+ publicFiles: await getSitePublicFiles(),
446
+ siteStructure,
447
+ generatedRoutes: getGeneratedSiteRoutes({ searchEnabled: projectConfig.search.enabled }),
448
+ });
449
+ for (const issue of siteLinkGraph.diagnostics) {
450
+ const issueContentFile = issue.reference?.sourceContentFile ?? issue.contentFile;
451
+ if (issueContentFile) {
452
+ addContentIssue(issueContentFile, issue);
453
+ } else {
454
+ addIssue(issue);
455
+ }
456
+ }
457
+
458
+ const imageSyncPlan = createImageSyncPlan({
459
+ imageCandidates: allImageFiles,
460
+ references: managedImageReferences,
461
+ reportMisplaced: !shouldWrite,
462
+ });
463
+ const {
464
+ moves: imageMoves,
465
+ referencedImagePaths,
466
+ resolvedPathByReference,
467
+ } = imageSyncPlan;
468
+ const plannerIssuesByReference = new Map();
469
+ const plannerConflictIssues = [];
470
+
471
+ for (const plannerIssue of imageSyncPlan.issues) {
472
+ if (plannerIssue.phase === 'conflict') {
473
+ plannerConflictIssues.push(plannerIssue);
474
+ continue;
475
+ }
476
+
477
+ if (!plannerIssuesByReference.has(plannerIssue.reference)) {
478
+ plannerIssuesByReference.set(plannerIssue.reference, []);
479
+ }
480
+ plannerIssuesByReference.get(plannerIssue.reference).push(plannerIssue);
481
+ }
482
+
515
483
  for (const context of contentFileContexts) {
516
484
  const {
517
485
  contentFile,
@@ -536,7 +504,7 @@ for (const context of contentFileContexts) {
536
504
  addSectionIssue(contentFile, section, {
537
505
  severity: 'warning',
538
506
  message: `Markdown image "${reference.target}" references a local image that is not managed by Norna.`,
539
- fix: 'Use a norna-image-stack, norna-image-carousel, or norna-card-list block for site images that should be validated, processed and synced.',
507
+ fix: 'Use an image-stack, image-carousel, or card-list block for site images that should be validated, processed and synced.',
540
508
  });
541
509
  }
542
510
 
@@ -552,21 +520,19 @@ for (const context of contentFileContexts) {
552
520
  if (block.type === 'image-carousel' && block.images.length < 2) {
553
521
  addSectionIssue(contentFile, section, {
554
522
  severity: 'error',
555
- message: `norna-image-carousel on line ${block.line} contains ${block.images.length} image. A carousel needs at least two images.`,
556
- fix: 'Add another image entry, or use norna-image-stack for a single image.',
523
+ message: `image-carousel on line ${block.line} contains ${block.images.length} image. An image carousel needs at least two images.`,
524
+ fix: 'Add another image entry, or use image-stack for a single image.',
557
525
  });
558
526
  }
559
527
  }
560
528
 
561
529
  const sourcePathsByImage = new Map();
562
530
  for (const reference of section.managedImages) {
563
- const sourcePath = await validateImageReference(
564
- contentFile,
565
- section,
566
- reference,
567
- globalImageCandidatesByName,
568
- globalExpectedReferencesByPath,
569
- );
531
+ for (const plannerIssue of plannerIssuesByReference.get(reference) ?? []) {
532
+ addSectionIssue(plannerIssue.contentFile, plannerIssue.section, plannerIssue.issue);
533
+ }
534
+
535
+ const sourcePath = resolvedPathByReference.get(reference);
570
536
  if (sourcePath) {
571
537
  sourcePathsByImage.set(reference.image, sourcePath);
572
538
  }
@@ -578,13 +544,15 @@ for (const context of contentFileContexts) {
578
544
  }
579
545
  }
580
546
 
547
+ for (const plannerIssue of plannerConflictIssues) {
548
+ addSectionIssue(plannerIssue.contentFile, plannerIssue.section, plannerIssue.issue);
549
+ }
550
+
581
551
  const unreferencedImages = allImageFiles
582
552
  .filter(({ imagePath }) => !referencedImagePaths.has(imagePath))
583
553
  .map(({ contentFile, imagePath }) => `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`)
584
554
  .sort((left, right) => left.localeCompare(right, 'sv'));
585
555
 
586
- addConflictingMoveIssues();
587
-
588
556
  if (hasErrors()) {
589
557
  printReport(shouldWrite ? 'Content sync failed.' : 'Content check failed.', unreferencedImages);
590
558
  process.exit(1);
@@ -609,47 +577,45 @@ if (!canWrite) {
609
577
  process.exit(1);
610
578
  }
611
579
 
612
- const completedMoves = [];
613
- for (const [index, move] of imageMoves.entries()) {
614
- try {
615
- await mkdir(path.dirname(move.to), { recursive: true });
616
- await rename(move.from, move.to);
617
- completedMoves.push(move);
618
- console.log(`Moved image "${move.imageName}" to ${move.contentFile.imagesLabel}/.`);
619
- } catch (error) {
620
- const sourceLabel = getCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from });
621
- const destinationLabel = getExpectedImageLabel(move.contentFile, move.imageName);
622
- const remainingMoves = imageMoves.slice(index);
623
- const lines = [
624
- '',
625
- `Content sync stopped after ${completedMoves.length} of ${imageMoves.length} image moves.`,
626
- ];
627
-
628
- if (error?.code === 'EXDEV') {
629
- lines.push(
630
- `Cannot move "${move.imageName}" because the source and destination are on different filesystems.`,
631
- `Move it manually from ${sourceLabel} to ${destinationLabel}.`,
632
- );
633
- } else {
634
- lines.push(
635
- `Could not move "${move.imageName}" from ${sourceLabel} to ${destinationLabel}.`,
636
- `Filesystem error${error?.code ? ` (${error.code})` : ''}: ${error?.message ?? String(error)}`,
637
- );
638
- }
639
-
640
- if (completedMoves.length > 0) {
641
- lines.push('', 'Completed moves:', ...getMovePlanLines(completedMoves).slice(1));
642
- }
580
+ const applyResult = await applyImageSyncPlan(imageMoves);
581
+ for (const move of applyResult.completedMoves) {
582
+ console.log(`Moved image "${move.imageName}" to ${move.contentFile.imagesLabel}/.`);
583
+ }
643
584
 
585
+ if (applyResult.failedMove) {
586
+ const move = applyResult.failedMove;
587
+ const error = applyResult.error;
588
+ const sourceLabel = getImageCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from });
589
+ const destinationLabel = getExpectedImageLabel(move.contentFile, move.imageName);
590
+ const lines = [
591
+ '',
592
+ `Content sync stopped after ${applyResult.completedMoves.length} of ${imageMoves.length} image moves.`,
593
+ ];
594
+
595
+ if (error?.code === 'EXDEV') {
644
596
  lines.push(
645
- '',
646
- 'Remaining moves:',
647
- ...getMovePlanLines(remainingMoves).slice(1),
648
- '',
649
- 'Fix the reported filesystem problem, then run content:sync again. Already completed moves will be kept.',
597
+ `Cannot move "${move.imageName}" because the source and destination are on different filesystems.`,
598
+ `Move it manually from ${sourceLabel} to ${destinationLabel}.`,
599
+ );
600
+ } else {
601
+ lines.push(
602
+ `Could not move "${move.imageName}" from ${sourceLabel} to ${destinationLabel}.`,
603
+ `Filesystem error${error?.code ? ` (${error.code})` : ''}: ${error?.message ?? String(error)}`,
650
604
  );
605
+ }
651
606
 
652
- console.error(lines.join('\n'));
653
- process.exit(1);
607
+ if (applyResult.completedMoves.length > 0) {
608
+ lines.push('', 'Completed moves:', ...getMovePlanLines(applyResult.completedMoves).slice(1));
654
609
  }
610
+
611
+ lines.push(
612
+ '',
613
+ 'Remaining moves:',
614
+ ...getMovePlanLines(applyResult.remainingMoves).slice(1),
615
+ '',
616
+ 'Fix the reported filesystem problem, then run content:sync again. Already completed moves will be kept.',
617
+ );
618
+
619
+ console.error(lines.join('\n'));
620
+ process.exit(1);
655
621
  }
@@ -1,13 +1,19 @@
1
- import { cp, mkdir, readdir, rm } from 'node:fs/promises';
1
+ import { cp, mkdir, readdir, rm, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import projectConfig from './lib/project-config.mjs';
4
+ import { createSitemapXml, sitemapFilename } from './lib/sitemap.mjs';
3
5
  import {
4
6
  astroPublicDir,
5
7
  astroPublicLabel,
6
8
  sitePublicDir,
7
9
  sitePublicLabel,
8
10
  } from './lib/site-paths.mjs';
11
+ import { getSiteStructure } from './lib/site-structure.mjs';
9
12
 
10
- const keepAstroPublicEntries = new Set(['images']);
13
+ const keepAstroPublicEntries = new Set([
14
+ 'images',
15
+ ...(projectConfig.search.enabled ? ['pagefind'] : []),
16
+ ]);
11
17
 
12
18
  const readDirectory = async (directory) => {
13
19
  try {
@@ -21,6 +27,37 @@ const readDirectory = async (directory) => {
21
27
  }
22
28
  };
23
29
 
30
+ const sourceEntries = await readDirectory(sitePublicDir);
31
+ const generatedPublicFiles = [
32
+ {
33
+ filename: sitemapFilename,
34
+ explanation: `Norna generates ${sitemapFilename} from the public page tree and the URL in site/config.yaml.`,
35
+ },
36
+ {
37
+ filename: '404.html',
38
+ explanation: 'Norna generates 404.html as the site\'s localized missing-page response.',
39
+ },
40
+ ...(projectConfig.search.enabled ? [{
41
+ filename: 'pagefind',
42
+ explanation: 'Norna generates pagefind/ from the rendered editorial content when search is enabled.',
43
+ }] : []),
44
+ ];
45
+ for (const generatedFile of generatedPublicFiles) {
46
+ const conflict = sourceEntries.find(({ name }) => name.toLowerCase() === generatedFile.filename);
47
+ if (!conflict) continue;
48
+
49
+ throw new Error([
50
+ `${sitePublicLabel}/${conflict.name} conflicts with Norna's generated ${generatedFile.filename}.`,
51
+ `Remove that source file. ${generatedFile.explanation}`,
52
+ ].join('\n'));
53
+ }
54
+
55
+ const siteStructure = await getSiteStructure();
56
+ const sitemapXml = createSitemapXml({
57
+ siteStructure,
58
+ siteUrl: projectConfig.site.url,
59
+ });
60
+
24
61
  await mkdir(astroPublicDir, { recursive: true });
25
62
 
26
63
  for (const entry of await readDirectory(astroPublicDir)) {
@@ -31,7 +68,7 @@ for (const entry of await readDirectory(astroPublicDir)) {
31
68
  await rm(path.join(astroPublicDir, entry.name), { force: true, recursive: true });
32
69
  }
33
70
 
34
- for (const entry of await readDirectory(sitePublicDir)) {
71
+ for (const entry of sourceEntries) {
35
72
  await cp(
36
73
  path.join(sitePublicDir, entry.name),
37
74
  path.join(astroPublicDir, entry.name),
@@ -39,4 +76,7 @@ for (const entry of await readDirectory(sitePublicDir)) {
39
76
  );
40
77
  }
41
78
 
79
+ await writeFile(path.join(astroPublicDir, sitemapFilename), sitemapXml);
80
+
42
81
  console.log(`Synced ${sitePublicLabel}/ to ${astroPublicLabel}/.`);
82
+ console.log(`Generated ${astroPublicLabel}/${sitemapFilename} for ${siteStructure.contentFiles.length} public page${siteStructure.contentFiles.length === 1 ? '' : 's'}.`);
@@ -0,0 +1,97 @@
1
+ ---
2
+ import { Check, Copy, X } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+
5
+ const labels = {
6
+ copy: projectConfig.locale.labels.copyCode,
7
+ copied: projectConfig.locale.labels.codeCopied,
8
+ failed: projectConfig.locale.labels.codeCopyFailed,
9
+ };
10
+ ---
11
+
12
+ <template data-code-copy-template>
13
+ <button class="code-block-copy" type="button" aria-label={labels.copy} title={labels.copy}>
14
+ <Copy aria-hidden="true" data-code-copy-icon="copy" size={18} stroke-width={2} />
15
+ <Check aria-hidden="true" data-code-copy-icon="copied" hidden size={18} stroke-width={2} />
16
+ <X aria-hidden="true" data-code-copy-icon="failed" hidden size={18} stroke-width={2} />
17
+ <span class="visually-hidden" aria-live="polite" data-code-copy-status></span>
18
+ </button>
19
+ </template>
20
+
21
+ <script is:inline define:vars={{ labels }}>
22
+ (() => {
23
+ const template = document.querySelector('[data-code-copy-template]');
24
+ if (!(template instanceof HTMLTemplateElement)) return;
25
+
26
+ const resetTimers = new WeakMap();
27
+ const copyText = async (text) => {
28
+ if (navigator.clipboard?.writeText) {
29
+ try {
30
+ await navigator.clipboard.writeText(text);
31
+ return;
32
+ } catch {}
33
+ }
34
+
35
+ const input = document.createElement('textarea');
36
+ input.value = text;
37
+ input.setAttribute('readonly', '');
38
+ input.style.cssText = 'position:fixed;inset:0 auto auto 0;opacity:0;pointer-events:none';
39
+ document.body.append(input);
40
+ input.select();
41
+ const copied = document.execCommand('copy');
42
+ input.remove();
43
+ if (!copied) throw new Error('The browser rejected the copy command.');
44
+ };
45
+
46
+ const showState = (button, state) => {
47
+ button.querySelectorAll('[data-code-copy-icon]').forEach((icon) => {
48
+ icon.hidden = icon.getAttribute('data-code-copy-icon') !== state;
49
+ });
50
+ const status = button.querySelector('[data-code-copy-status]');
51
+ if (status) status.textContent = state === 'copied' ? labels.copied : labels.failed;
52
+ button.dataset.copyState = state;
53
+
54
+ clearTimeout(resetTimers.get(button));
55
+ resetTimers.set(button, setTimeout(() => {
56
+ button.querySelectorAll('[data-code-copy-icon]').forEach((icon) => {
57
+ icon.hidden = icon.getAttribute('data-code-copy-icon') !== 'copy';
58
+ });
59
+ if (status) status.textContent = '';
60
+ delete button.dataset.copyState;
61
+ }, 2000));
62
+ };
63
+
64
+ document.querySelectorAll('#main-content .section-markdown pre > code').forEach((code) => {
65
+ const pre = code.parentElement;
66
+ if (!pre || pre.parentElement?.classList.contains('code-block')) return;
67
+
68
+ const codeExample = pre.parentElement?.classList.contains('norna-code-example')
69
+ ? pre.parentElement
70
+ : null;
71
+ const wrapper = codeExample ?? document.createElement('div');
72
+ wrapper.classList.add('code-block');
73
+ if (!codeExample) {
74
+ pre.before(wrapper);
75
+ wrapper.append(pre);
76
+ }
77
+
78
+ const button = template.content.firstElementChild?.cloneNode(true);
79
+ if (!(button instanceof HTMLButtonElement)) return;
80
+ wrapper.append(button);
81
+ button.addEventListener('click', async () => {
82
+ if (button.dataset.copyBusy === 'true') return;
83
+ button.dataset.copyBusy = 'true';
84
+ try {
85
+ await copyText(code.textContent ?? '');
86
+ showState(button, 'copied');
87
+ } catch {
88
+ showState(button, 'failed');
89
+ } finally {
90
+ delete button.dataset.copyBusy;
91
+ }
92
+ });
93
+ });
94
+
95
+ template.remove();
96
+ })();
97
+ </script>