@janga/norna 0.7.22 → 0.7.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/astro.config.mjs +3 -0
  2. package/bin/norna-cli.mjs +2 -2
  3. package/package.json +17 -3
  4. package/schemas/category.schema.json +2 -2
  5. package/schemas/config.schema.json +22 -24
  6. package/schemas/content-frontmatter.schema.json +25 -5
  7. package/schemas/page-theme.schema.json +53 -25
  8. package/schemas/sitewide-content.schema.json +19 -18
  9. package/schemas/theme.schema.json +383 -250
  10. package/scripts/check-config.mjs +24 -4
  11. package/scripts/deploy-site.mjs +0 -1
  12. package/scripts/dev-local.mjs +175 -112
  13. package/scripts/lib/content-sync-apply.mjs +34 -0
  14. package/scripts/lib/content-sync-plan.mjs +179 -0
  15. package/scripts/lib/editor-language-service.mjs +128 -0
  16. package/scripts/lib/heading-ids.mjs +1 -1
  17. package/scripts/lib/image-presentation.mjs +2 -0
  18. package/scripts/lib/markdown-links.mjs +182 -0
  19. package/scripts/lib/navigation-model.mjs +25 -14
  20. package/scripts/lib/norna-markdown-blocks.mjs +1 -0
  21. package/scripts/lib/page-aliases.mjs +164 -0
  22. package/scripts/lib/page-markdown.mjs +56 -6
  23. package/scripts/lib/presentation.mjs +19 -37
  24. package/scripts/lib/project-config.mjs +50 -29
  25. package/scripts/lib/schema-definitions.mjs +24 -13
  26. package/scripts/lib/schema-editor-metadata.mjs +43 -30
  27. package/scripts/lib/schema-value-definitions.mjs +6 -2
  28. package/scripts/lib/site-content.mjs +26 -4
  29. package/scripts/lib/site-link-graph.mjs +326 -0
  30. package/scripts/lib/site-page-urls.mjs +10 -0
  31. package/scripts/lib/sitemap.mjs +34 -0
  32. package/scripts/lib/theme-presets.mjs +51 -23
  33. package/scripts/lib/theme-profiles.mjs +7 -5
  34. package/scripts/lib/yaml-config.mjs +6 -2
  35. package/scripts/sync-content-sections.mjs +116 -213
  36. package/scripts/sync-site-public.mjs +23 -2
  37. package/src/components/CodeBlockCopyScript.astro +92 -0
  38. package/src/components/DisplaySettings.astro +4 -4
  39. package/src/components/ImageCarousel.astro +7 -3
  40. package/src/components/NavigationPageTree.astro +44 -60
  41. package/src/components/PageAliasRedirect.astro +49 -0
  42. package/src/components/PageContentsNavigation.astro +21 -0
  43. package/src/components/SectionNavigationScript.astro +62 -6
  44. package/src/components/SiteNavigation.astro +15 -3
  45. package/src/components/SitePage.astro +42 -2
  46. package/src/components/SiteSection.astro +5 -0
  47. package/src/components/SiteTreeNavigation.astro +0 -1
  48. package/src/content.config.ts +5 -2
  49. package/src/layouts/BaseLayout.astro +14 -8
  50. package/src/lib/readerPreferencesScript.mjs +2 -2
  51. package/src/lib/sitePages.ts +3 -2
  52. package/src/pages/[...slug].astro +35 -8
  53. package/src/styles/content.css +494 -0
  54. package/src/styles/foundations.css +284 -0
  55. package/src/styles/global.css +6 -2510
  56. package/src/styles/media.css +523 -0
  57. package/src/styles/navigation.css +495 -0
  58. package/src/styles/page-layout.css +437 -0
  59. package/src/styles/responsive.css +525 -0
  60. package/starters/basic/README.md +1 -1
@@ -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,11 @@ 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
+ createSiteLinkGraph,
26
+ getSitePublicFiles,
27
+ } from './lib/site-link-graph.mjs';
18
28
  import {
19
29
  siteThemeLabel,
20
30
  siteThemePath,
@@ -25,8 +35,6 @@ const shouldWrite = args.has('--write');
25
35
  const shouldCheck = args.has('--check') || !shouldWrite;
26
36
  const skipPrompt = args.has('--yes');
27
37
  const issues = [];
28
- const imageMoves = [];
29
- const referencedImagePaths = new Set();
30
38
 
31
39
  const addIssue = ({ severity, message, fix, sectionId, sectionLabel }) => {
32
40
  issues.push({ severity, message, fix, sectionId, sectionLabel });
@@ -50,7 +58,7 @@ const addSectionIssue = (contentFile, section, issue) => addIssue({
50
58
 
51
59
  const getMovePlanLines = (moves) => [
52
60
  'Planned image moves:',
53
- ...moves.map((move) => `- ${getCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from })} -> ${getExpectedImageLabel(move.contentFile, move.imageName)}`),
61
+ ...moves.map((move) => `- ${getImageCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from })} -> ${getExpectedImageLabel(move.contentFile, move.imageName)}`),
54
62
  ];
55
63
 
56
64
  const promptForWrite = async (moves) => {
@@ -166,157 +174,6 @@ const getAspectRatioLabel = ({ width, height }) => {
166
174
  return `${width / divisor}:${height / divisor}`;
167
175
  };
168
176
 
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
177
  const warnAboutCarouselAspectRatios = async (contentFile, section, block, sourcePathsByImage) => {
321
178
  if (block.type !== 'image-carousel') return;
322
179
 
@@ -378,19 +235,30 @@ for (const warning of siteStructure.warnings) {
378
235
  });
379
236
  }
380
237
  const allImageFiles = [];
238
+ const managedImageReferences = [];
381
239
  const contentFileContexts = [];
382
- const globalImageCandidatesByName = new Map();
383
- const globalExpectedReferencesByPath = new Map();
384
240
 
385
241
  for (const contentFile of contentFiles) {
386
- const { frontmatter, body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
242
+ const { frontmatter, frontmatterBody, body, source } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
387
243
  const bodyLineOffset = frontmatter.split(/\r?\n/).length - 1;
244
+ const issueCountBeforeFrontmatter = issues.length;
388
245
  validateFrontmatterIndentation(frontmatter, addIssue);
389
246
  validateContentFrontmatterStructure(frontmatter, addIssue);
247
+ let frontmatterData = {};
248
+ if (!issues.slice(issueCountBeforeFrontmatter).some(({ severity }) => severity === 'error')) {
249
+ try {
250
+ frontmatterData = parseContentFrontmatter(frontmatterBody, contentFile.contentLabel);
251
+ } catch (error) {
252
+ addContentIssue(contentFile, {
253
+ severity: 'error',
254
+ message: error instanceof Error ? error.message : String(error),
255
+ fix: 'Correct the page frontmatter using the content reference and project-local schema help.',
256
+ });
257
+ }
258
+ }
390
259
 
391
- const page = await parsePageMarkdown(body, {
260
+ const page = await parsePageMarkdownSource(source, {
392
261
  label: contentFile.contentLabel,
393
- lineOffset: bodyLineOffset,
394
262
  });
395
263
  const {
396
264
  headingIssues: headingIdentifierIssues,
@@ -421,9 +289,7 @@ for (const contentFile of contentFiles) {
421
289
 
422
290
  for (const [imageName, candidates] of imageCandidatesByName) {
423
291
  for (const imagePath of candidates) {
424
- const candidate = { contentFile, imagePath };
425
- allImageFiles.push(candidate);
426
- addGlobalImageCandidate(globalImageCandidatesByName, imageName, candidate);
292
+ allImageFiles.push({ contentFile, imageName, imagePath });
427
293
  }
428
294
  }
429
295
 
@@ -498,13 +364,14 @@ for (const contentFile of contentFiles) {
498
364
  }
499
365
 
500
366
  for (const reference of section.managedImages) {
501
- const expectedPath = getExpectedImagePath(contentFile, reference.image);
502
- addExpectedReference(globalExpectedReferencesByPath, expectedPath, { contentFile, section, reference });
367
+ managedImageReferences.push({ contentFile, section, reference });
503
368
  }
504
369
  }
505
370
 
506
371
  contentFileContexts.push({
507
372
  contentFile,
373
+ frontmatterData,
374
+ page,
508
375
  sections,
509
376
  validSections,
510
377
  blockResultsBySection,
@@ -512,6 +379,44 @@ for (const contentFile of contentFiles) {
512
379
  });
513
380
  }
514
381
 
382
+ const siteLinkGraph = createSiteLinkGraph({
383
+ pageDocuments: contentFileContexts.map(({ contentFile, frontmatterData, page }) => ({
384
+ contentFile,
385
+ data: frontmatterData,
386
+ document: page,
387
+ })),
388
+ publicFiles: await getSitePublicFiles(),
389
+ siteStructure,
390
+ });
391
+ for (const issue of siteLinkGraph.diagnostics) {
392
+ addContentIssue(issue.reference?.sourceContentFile ?? issue.contentFile, issue);
393
+ }
394
+
395
+ const imageSyncPlan = createImageSyncPlan({
396
+ imageCandidates: allImageFiles,
397
+ references: managedImageReferences,
398
+ reportMisplaced: !shouldWrite,
399
+ });
400
+ const {
401
+ moves: imageMoves,
402
+ referencedImagePaths,
403
+ resolvedPathByReference,
404
+ } = imageSyncPlan;
405
+ const plannerIssuesByReference = new Map();
406
+ const plannerConflictIssues = [];
407
+
408
+ for (const plannerIssue of imageSyncPlan.issues) {
409
+ if (plannerIssue.phase === 'conflict') {
410
+ plannerConflictIssues.push(plannerIssue);
411
+ continue;
412
+ }
413
+
414
+ if (!plannerIssuesByReference.has(plannerIssue.reference)) {
415
+ plannerIssuesByReference.set(plannerIssue.reference, []);
416
+ }
417
+ plannerIssuesByReference.get(plannerIssue.reference).push(plannerIssue);
418
+ }
419
+
515
420
  for (const context of contentFileContexts) {
516
421
  const {
517
422
  contentFile,
@@ -560,13 +465,11 @@ for (const context of contentFileContexts) {
560
465
 
561
466
  const sourcePathsByImage = new Map();
562
467
  for (const reference of section.managedImages) {
563
- const sourcePath = await validateImageReference(
564
- contentFile,
565
- section,
566
- reference,
567
- globalImageCandidatesByName,
568
- globalExpectedReferencesByPath,
569
- );
468
+ for (const plannerIssue of plannerIssuesByReference.get(reference) ?? []) {
469
+ addSectionIssue(plannerIssue.contentFile, plannerIssue.section, plannerIssue.issue);
470
+ }
471
+
472
+ const sourcePath = resolvedPathByReference.get(reference);
570
473
  if (sourcePath) {
571
474
  sourcePathsByImage.set(reference.image, sourcePath);
572
475
  }
@@ -578,13 +481,15 @@ for (const context of contentFileContexts) {
578
481
  }
579
482
  }
580
483
 
484
+ for (const plannerIssue of plannerConflictIssues) {
485
+ addSectionIssue(plannerIssue.contentFile, plannerIssue.section, plannerIssue.issue);
486
+ }
487
+
581
488
  const unreferencedImages = allImageFiles
582
489
  .filter(({ imagePath }) => !referencedImagePaths.has(imagePath))
583
490
  .map(({ contentFile, imagePath }) => `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`)
584
491
  .sort((left, right) => left.localeCompare(right, 'sv'));
585
492
 
586
- addConflictingMoveIssues();
587
-
588
493
  if (hasErrors()) {
589
494
  printReport(shouldWrite ? 'Content sync failed.' : 'Content check failed.', unreferencedImages);
590
495
  process.exit(1);
@@ -609,47 +514,45 @@ if (!canWrite) {
609
514
  process.exit(1);
610
515
  }
611
516
 
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
- }
517
+ const applyResult = await applyImageSyncPlan(imageMoves);
518
+ for (const move of applyResult.completedMoves) {
519
+ console.log(`Moved image "${move.imageName}" to ${move.contentFile.imagesLabel}/.`);
520
+ }
643
521
 
522
+ if (applyResult.failedMove) {
523
+ const move = applyResult.failedMove;
524
+ const error = applyResult.error;
525
+ const sourceLabel = getImageCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from });
526
+ const destinationLabel = getExpectedImageLabel(move.contentFile, move.imageName);
527
+ const lines = [
528
+ '',
529
+ `Content sync stopped after ${applyResult.completedMoves.length} of ${imageMoves.length} image moves.`,
530
+ ];
531
+
532
+ if (error?.code === 'EXDEV') {
644
533
  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.',
534
+ `Cannot move "${move.imageName}" because the source and destination are on different filesystems.`,
535
+ `Move it manually from ${sourceLabel} to ${destinationLabel}.`,
650
536
  );
537
+ } else {
538
+ lines.push(
539
+ `Could not move "${move.imageName}" from ${sourceLabel} to ${destinationLabel}.`,
540
+ `Filesystem error${error?.code ? ` (${error.code})` : ''}: ${error?.message ?? String(error)}`,
541
+ );
542
+ }
651
543
 
652
- console.error(lines.join('\n'));
653
- process.exit(1);
544
+ if (applyResult.completedMoves.length > 0) {
545
+ lines.push('', 'Completed moves:', ...getMovePlanLines(applyResult.completedMoves).slice(1));
654
546
  }
547
+
548
+ lines.push(
549
+ '',
550
+ 'Remaining moves:',
551
+ ...getMovePlanLines(applyResult.remainingMoves).slice(1),
552
+ '',
553
+ 'Fix the reported filesystem problem, then run content:sync again. Already completed moves will be kept.',
554
+ );
555
+
556
+ console.error(lines.join('\n'));
557
+ process.exit(1);
655
558
  }
@@ -1,11 +1,14 @@
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
13
  const keepAstroPublicEntries = new Set(['images']);
11
14
 
@@ -21,6 +24,21 @@ const readDirectory = async (directory) => {
21
24
  }
22
25
  };
23
26
 
27
+ const sourceEntries = await readDirectory(sitePublicDir);
28
+ const sitemapConflict = sourceEntries.find(({ name }) => name.toLowerCase() === sitemapFilename);
29
+ if (sitemapConflict) {
30
+ throw new Error([
31
+ `${sitePublicLabel}/${sitemapConflict.name} conflicts with Norna's generated ${sitemapFilename}.`,
32
+ `Remove that source file. Norna generates ${sitemapFilename} from the public page tree and the URL in site/config.yaml.`,
33
+ ].join('\n'));
34
+ }
35
+
36
+ const siteStructure = await getSiteStructure();
37
+ const sitemapXml = createSitemapXml({
38
+ siteStructure,
39
+ siteUrl: projectConfig.site.url,
40
+ });
41
+
24
42
  await mkdir(astroPublicDir, { recursive: true });
25
43
 
26
44
  for (const entry of await readDirectory(astroPublicDir)) {
@@ -31,7 +49,7 @@ for (const entry of await readDirectory(astroPublicDir)) {
31
49
  await rm(path.join(astroPublicDir, entry.name), { force: true, recursive: true });
32
50
  }
33
51
 
34
- for (const entry of await readDirectory(sitePublicDir)) {
52
+ for (const entry of sourceEntries) {
35
53
  await cp(
36
54
  path.join(sitePublicDir, entry.name),
37
55
  path.join(astroPublicDir, entry.name),
@@ -39,4 +57,7 @@ for (const entry of await readDirectory(sitePublicDir)) {
39
57
  );
40
58
  }
41
59
 
60
+ await writeFile(path.join(astroPublicDir, sitemapFilename), sitemapXml);
61
+
42
62
  console.log(`Synced ${sitePublicLabel}/ to ${astroPublicLabel}/.`);
63
+ console.log(`Generated ${astroPublicLabel}/${sitemapFilename} for ${siteStructure.contentFiles.length} public page${siteStructure.contentFiles.length === 1 ? '' : 's'}.`);
@@ -0,0 +1,92 @@
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 wrapper = document.createElement('div');
69
+ wrapper.className = 'code-block';
70
+ pre.before(wrapper);
71
+ wrapper.append(pre);
72
+
73
+ const button = template.content.firstElementChild?.cloneNode(true);
74
+ if (!(button instanceof HTMLButtonElement)) return;
75
+ wrapper.append(button);
76
+ button.addEventListener('click', async () => {
77
+ if (button.dataset.copyBusy === 'true') return;
78
+ button.dataset.copyBusy = 'true';
79
+ try {
80
+ await copyText(code.textContent ?? '');
81
+ showState(button, 'copied');
82
+ } catch {
83
+ showState(button, 'failed');
84
+ } finally {
85
+ delete button.dataset.copyBusy;
86
+ }
87
+ });
88
+ });
89
+
90
+ template.remove();
91
+ })();
92
+ </script>
@@ -20,9 +20,9 @@ interface Props {
20
20
  const { controls, defaults } = Astro.props;
21
21
  const labels = projectConfig.locale.labels;
22
22
  const appearanceOptions = [
23
- { value: 'system', label: labels.colorModeSystem },
24
- { value: 'light', label: labels.colorModeLight },
25
- { value: 'dark', label: labels.colorModeDark },
23
+ { value: 'system', label: labels.appearanceSystem },
24
+ { value: 'light', label: labels.appearanceLight },
25
+ { value: 'dark', label: labels.appearanceDark },
26
26
  ] as const;
27
27
  const widthOptions = [
28
28
  { value: 'narrow', label: labels.readingWidthNarrow },
@@ -51,7 +51,7 @@ const widthOptions = [
51
51
 
52
52
  {controls.appearance && (
53
53
  <fieldset>
54
- <legend>{labels.colorMode}</legend>
54
+ <legend>{labels.appearance}</legend>
55
55
  <div class="display-settings-segmented">
56
56
  {appearanceOptions.map((option) => (
57
57
  <label>
@@ -17,7 +17,7 @@ interface Props {
17
17
  desktop: number;
18
18
  mobile: number;
19
19
  };
20
- maxAvailableHeightPercent: {
20
+ maxAvailableHeightPercent?: {
21
21
  desktop: number;
22
22
  mobile: number;
23
23
  };
@@ -36,8 +36,12 @@ const carouselAspectRatio = getCarouselAspectRatio(images);
36
36
  const carouselStyle = carouselAspectRatio
37
37
  ? [
38
38
  `--image-carousel-aspect-ratio: ${carouselAspectRatio}`,
39
- `--image-carousel-width-from-height-desktop: ${carouselAspectRatio * maxAvailableHeightPercent.desktop}svh`,
40
- `--image-carousel-width-from-height-mobile: ${carouselAspectRatio * maxAvailableHeightPercent.mobile}svh`,
39
+ ...(maxAvailableHeightPercent
40
+ ? [
41
+ `--image-carousel-width-from-height-desktop: ${carouselAspectRatio * maxAvailableHeightPercent.desktop}svh`,
42
+ `--image-carousel-width-from-height-mobile: ${carouselAspectRatio * maxAvailableHeightPercent.mobile}svh`,
43
+ ]
44
+ : []),
41
45
  ].join('; ')
42
46
  : undefined;
43
47
  ---