@janga/norna 0.7.23 → 0.7.25

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 (82) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +14 -0
  3. package/bin/norna-cli.mjs +6 -0
  4. package/package.json +25 -2
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +80 -15
  7. package/schemas/content-frontmatter.schema.json +7 -7
  8. package/schemas/page-theme.schema.json +26 -26
  9. package/schemas/sitewide-content.schema.json +18 -18
  10. package/schemas/theme.schema.json +246 -246
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +16 -1
  13. package/scripts/dev-local.mjs +106 -11
  14. package/scripts/generate-search-index.mjs +57 -0
  15. package/scripts/init-site.mjs +3 -0
  16. package/scripts/lib/code-fence-metadata.mjs +228 -0
  17. package/scripts/lib/edit-source-link.mjs +102 -0
  18. package/scripts/lib/editor-language-service.mjs +32 -12
  19. package/scripts/lib/image-presentation.mjs +4 -0
  20. package/scripts/lib/navigation-model.mjs +34 -13
  21. package/scripts/lib/navigation-review.mjs +396 -0
  22. package/scripts/lib/norna-markdown-blocks.mjs +164 -26
  23. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  24. package/scripts/lib/page-aliases.mjs +21 -1
  25. package/scripts/lib/page-markdown.mjs +32 -1
  26. package/scripts/lib/page-move-plan.mjs +659 -0
  27. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  28. package/scripts/lib/presentation.mjs +1 -10
  29. package/scripts/lib/project-config.mjs +141 -5
  30. package/scripts/lib/public-asset-conventions.mjs +36 -2
  31. package/scripts/lib/schema-definitions.mjs +30 -5
  32. package/scripts/lib/schema-editor-metadata.mjs +36 -4
  33. package/scripts/lib/schema-value-definitions.mjs +4 -1
  34. package/scripts/lib/semantic-callouts.mjs +128 -0
  35. package/scripts/lib/site-content.mjs +2 -1
  36. package/scripts/lib/site-link-graph.mjs +33 -2
  37. package/scripts/lib/site-navigation-tree.mjs +85 -0
  38. package/scripts/lib/site-paths.mjs +14 -2
  39. package/scripts/lib/social-image-assets.mjs +30 -0
  40. package/scripts/lib/table-render-plugin.mjs +33 -0
  41. package/scripts/lib/theme-presets.mjs +2 -2
  42. package/scripts/lib/theme-profiles.mjs +0 -4
  43. package/scripts/move-site-page.mjs +256 -0
  44. package/scripts/review-navigation.mjs +32 -0
  45. package/scripts/sync-content-sections.mjs +67 -4
  46. package/scripts/sync-site-public.mjs +24 -5
  47. package/src/components/CardList.astro +1 -0
  48. package/src/components/CodeBlockCopyScript.astro +11 -5
  49. package/src/components/EditSourceLink.astro +17 -0
  50. package/src/components/ImageCarousel.astro +9 -7
  51. package/src/components/ImageStack.astro +34 -9
  52. package/src/components/ImageStackEnhancement.astro +331 -0
  53. package/src/components/NavigationPageTree.astro +66 -55
  54. package/src/components/NavigationTreeControls.astro +77 -0
  55. package/src/components/PageAliasRedirect.astro +1 -1
  56. package/src/components/PageContentsNavigation.astro +2 -3
  57. package/src/components/PageList.astro +33 -0
  58. package/src/components/PageSequenceNavigation.astro +37 -0
  59. package/src/components/SearchPage.astro +151 -0
  60. package/src/components/SectionNavigationScript.astro +45 -7
  61. package/src/components/SiteNavigation.astro +102 -51
  62. package/src/components/SitePage.astro +107 -39
  63. package/src/components/SiteSection.astro +11 -3
  64. package/src/components/SiteTreeNavigation.astro +11 -1
  65. package/src/components/TableOverflowScript.astro +251 -0
  66. package/src/components/TreeNavigationScript.astro +271 -34
  67. package/src/layouts/BaseLayout.astro +83 -2
  68. package/src/lib/generatedImages.ts +18 -0
  69. package/src/lib/sectionContent.ts +36 -30
  70. package/src/lib/siteNavigation.ts +31 -52
  71. package/src/lib/sitePages.ts +6 -3
  72. package/src/lib/sitePublicAssets.ts +1 -0
  73. package/src/pages/404.astro +110 -0
  74. package/src/pages/[...slug].astro +15 -5
  75. package/src/styles/content.css +538 -39
  76. package/src/styles/media.css +187 -8
  77. package/src/styles/navigation.css +199 -3
  78. package/src/styles/page-layout.css +481 -100
  79. package/src/styles/responsive.css +144 -28
  80. package/starters/basic/README.md +6 -19
  81. package/starters/basic/package.json +1 -0
  82. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -44,7 +44,6 @@ const createPaletteMode = ({ appearance, page, surfaces, frame, css }) => {
44
44
 
45
45
  const presentationPalettes = Object.freeze({
46
46
  'near-monochrome': Object.freeze({
47
- defaultAppearance: 'dark',
48
47
  modes: Object.freeze({
49
48
  light: createPaletteMode({
50
49
  appearance: 'light',
@@ -85,7 +84,6 @@ const presentationPalettes = Object.freeze({
85
84
  }),
86
85
  }),
87
86
  'arctic-blue': Object.freeze({
88
- defaultAppearance: 'light',
89
87
  modes: Object.freeze({
90
88
  light: createPaletteMode({
91
89
  appearance: 'light',
@@ -126,7 +124,6 @@ const presentationPalettes = Object.freeze({
126
124
  }),
127
125
  }),
128
126
  'mineral-teal': Object.freeze({
129
- defaultAppearance: 'light',
130
127
  modes: Object.freeze({
131
128
  light: createPaletteMode({
132
129
  appearance: 'light',
@@ -167,7 +164,6 @@ const presentationPalettes = Object.freeze({
167
164
  }),
168
165
  }),
169
166
  'soft-lavender': Object.freeze({
170
- defaultAppearance: 'light',
171
167
  modes: Object.freeze({
172
168
  light: createPaletteMode({
173
169
  appearance: 'light',
@@ -208,7 +204,6 @@ const presentationPalettes = Object.freeze({
208
204
  }),
209
205
  }),
210
206
  'warm-paper': Object.freeze({
211
- defaultAppearance: 'light',
212
207
  modes: Object.freeze({
213
208
  light: createPaletteMode({
214
209
  appearance: 'light',
@@ -249,7 +244,6 @@ const presentationPalettes = Object.freeze({
249
244
  }),
250
245
  }),
251
246
  'retro-earth': Object.freeze({
252
- defaultAppearance: 'light',
253
247
  modes: Object.freeze({
254
248
  light: createPaletteMode({
255
249
  appearance: 'light',
@@ -290,7 +284,6 @@ const presentationPalettes = Object.freeze({
290
284
  }),
291
285
  }),
292
286
  'clay-rose': Object.freeze({
293
- defaultAppearance: 'light',
294
287
  modes: Object.freeze({
295
288
  light: createPaletteMode({
296
289
  appearance: 'light',
@@ -331,7 +324,6 @@ const presentationPalettes = Object.freeze({
331
324
  }),
332
325
  }),
333
326
  'forest-moss': Object.freeze({
334
- defaultAppearance: 'light',
335
327
  modes: Object.freeze({
336
328
  light: createPaletteMode({
337
329
  appearance: 'light',
@@ -372,7 +364,6 @@ const presentationPalettes = Object.freeze({
372
364
  }),
373
365
  }),
374
366
  'vivid-night': Object.freeze({
375
- defaultAppearance: 'dark',
376
367
  modes: Object.freeze({
377
368
  light: createPaletteMode({
378
369
  appearance: 'light',
@@ -477,7 +468,7 @@ export const resolveThemePresentation = (theme, sourceLabel = 'theme.yaml') => {
477
468
  const palette = getPresentationPalette(paletteName);
478
469
  const appearance = normalizedTheme.appearance ?? {};
479
470
  const readerControls = normalizedTheme.readerControls ?? {};
480
- const defaultAppearance = appearance.default ?? palette.defaultAppearance;
471
+ const defaultAppearance = appearance.default ?? 'system';
481
472
  if (!appearanceNames.includes(defaultAppearance)) {
482
473
  throw new Error(`appearance.default must be one of ${appearanceNames.join(', ')} in ${sourceLabel}.`);
483
474
  }
@@ -2,13 +2,18 @@ import { readFile } from 'node:fs/promises';
2
2
  import {
3
3
  validateConfigYamlStructure,
4
4
  } from './site-content.mjs';
5
- import { defaultImagePresentation, imagePresentationNames } from './image-presentation.mjs';
5
+ import {
6
+ defaultImageMaxAvailableHeightPercent,
7
+ defaultImagePresentation,
8
+ imagePresentationNames,
9
+ } from './image-presentation.mjs';
6
10
  import { navigationModeNames } from './navigation-model.mjs';
7
11
  import {
8
12
  siteConfigLabel,
9
13
  siteConfigPath,
10
14
  siteThemeLabel,
11
15
  } from './site-paths.mjs';
16
+ import { localEditorNames, normalizeEditLinkBaseUrl } from './edit-source-link.mjs';
12
17
  import { readThemeConfig } from './theme-config.mjs';
13
18
  import { resolveThemeConfig } from './theme-presets.mjs';
14
19
  import { parseYamlConfig } from './yaml-config.mjs';
@@ -52,6 +57,16 @@ const readEnum = (object, key, path, allowedValues, fallback, sourceLabel = site
52
57
  return value;
53
58
  };
54
59
 
60
+ const readBoolean = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
61
+ const value = object[key] ?? fallback;
62
+
63
+ if (typeof value !== 'boolean') {
64
+ throw new Error(`${path}.${key} must be true or false in ${sourceLabel}.`);
65
+ }
66
+
67
+ return value;
68
+ };
69
+
55
70
  const readFontFamily = (object, key, path, fallback, sourceLabel = siteConfigLabel) => {
56
71
  const value = object[key] ?? fallback;
57
72
 
@@ -192,8 +207,18 @@ const localeLabels = Object.freeze({
192
207
  codeCopied: 'Copied',
193
208
  codeCopyFailed: 'Could not copy code',
194
209
  copyCode: 'Copy code',
210
+ calloutCaution: 'Caution',
211
+ calloutDanger: 'Danger',
212
+ calloutImportant: 'Important',
213
+ calloutNote: 'Note',
214
+ calloutTip: 'Tip',
215
+ calloutWarning: 'Warning',
216
+ closeNavigation: 'Close navigation',
217
+ editSource: 'Edit this page',
195
218
  displaySettings: 'Display',
196
219
  focusReading: 'Focus reading',
220
+ footnoteBackReference: 'Back to reference {reference}',
221
+ footnotes: 'Footnotes',
197
222
  readingWidth: 'Reading width',
198
223
  readingWidthNarrow: 'Narrow',
199
224
  readingWidthStandard: 'Standard',
@@ -203,17 +228,49 @@ const localeLabels = Object.freeze({
203
228
  built: 'Built',
204
229
  images: 'Images',
205
230
  imageCarousel: 'image carousel',
231
+ imageInspection: 'Image inspection',
232
+ imageInspectionActualSize: 'Show actual size',
233
+ imageInspectionClose: 'Close image inspection',
234
+ imageInspectionFit: 'Fit image to window',
235
+ inspectImage: 'Inspect image: {description}',
236
+ navigationCollapsedAll: 'All navigation items collapsed.',
237
+ navigationCollapseAll: 'Collapse all',
238
+ navigationControls: 'Navigation tree controls',
206
239
  navigationChildren: 'Child pages',
240
+ navigationExpandedAll: 'All navigation items expanded.',
241
+ navigationExpandAll: 'Expand all',
242
+ navigationFilter: 'Filter pages and groups',
243
+ navigationFilterEmpty: 'No matching pages or groups. The current page remains available.',
244
+ navigationFilterMatches: 'Matching navigation items: {count}',
245
+ navigationLocatedCurrent: 'Current page located.',
246
+ navigationLocateCurrent: 'Locate current page',
207
247
  navigationMenu: 'Menu',
208
248
  nextImage: 'Next image',
249
+ nextPage: 'Next page',
209
250
  note: 'Note',
251
+ openInVsCode: 'Open in VS Code',
252
+ notFound: 'Page not found',
253
+ notFoundText: 'The requested page does not exist or may have moved.',
210
254
  pageMoved: 'Page moved',
211
255
  pageMovedText: 'This address now identifies',
212
256
  pageNavigation: 'Page contents',
257
+ pageSequence: 'Page sequence',
258
+ pageSections: 'Sections',
213
259
  previousImage: 'Previous image',
260
+ previousPage: 'Previous page',
261
+ returnHome: 'Go to the homepage',
262
+ search: 'Search',
263
+ searchDescription: 'Search the published content on this site.',
264
+ searchLoading: 'Loading search…',
265
+ searchNoScript: 'Search requires JavaScript. Use the page navigation when JavaScript is unavailable.',
266
+ searchUnavailable: 'Search is unavailable. During local work, run norna build:local to create or refresh the search index.',
214
267
  siteBanners: 'Site notices',
215
268
  siteNavigation: 'Pages',
216
269
  skipToContent: 'Skip to content',
270
+ tableColumns: 'Table columns',
271
+ tableNextColumns: 'Show next columns',
272
+ tableOverflowDescription: 'More table columns are available horizontally.',
273
+ tablePreviousColumns: 'Show previous columns',
217
274
  }),
218
275
  sv: Object.freeze({
219
276
  breadcrumb: 'Brödsmulor',
@@ -224,8 +281,18 @@ const localeLabels = Object.freeze({
224
281
  codeCopied: 'Kopierat',
225
282
  codeCopyFailed: 'Kunde inte kopiera koden',
226
283
  copyCode: 'Kopiera kod',
284
+ calloutCaution: 'Var försiktig',
285
+ calloutDanger: 'Fara',
286
+ calloutImportant: 'Viktigt',
287
+ calloutNote: 'Notera',
288
+ calloutTip: 'Tips',
289
+ calloutWarning: 'Varning',
290
+ closeNavigation: 'Stäng navigationen',
291
+ editSource: 'Redigera den här sidan',
227
292
  displaySettings: 'Visning',
228
293
  focusReading: 'Fokuserad läsning',
294
+ footnoteBackReference: 'Tillbaka till referens {reference}',
295
+ footnotes: 'Fotnoter',
229
296
  readingWidth: 'Textbredd',
230
297
  readingWidthNarrow: 'Smal',
231
298
  readingWidthStandard: 'Standard',
@@ -235,17 +302,49 @@ const localeLabels = Object.freeze({
235
302
  built: 'Byggd',
236
303
  images: 'Bilder',
237
304
  imageCarousel: 'bildkarusell',
305
+ imageInspection: 'Bildgranskning',
306
+ imageInspectionActualSize: 'Visa faktisk storlek',
307
+ imageInspectionClose: 'Stäng bildgranskning',
308
+ imageInspectionFit: 'Anpassa bilden till fönstret',
309
+ inspectImage: 'Granska bild: {description}',
310
+ navigationCollapsedAll: 'Alla navigationsposter har fällts ihop.',
311
+ navigationCollapseAll: 'Fäll ihop alla',
312
+ navigationControls: 'Kontroller för navigationsträdet',
238
313
  navigationChildren: 'Undersidor',
314
+ navigationExpandedAll: 'Alla navigationsposter har fällts ut.',
315
+ navigationExpandAll: 'Fäll ut alla',
316
+ navigationFilter: 'Filtrera sidor och grupper',
317
+ navigationFilterEmpty: 'Inga sidor eller grupper matchar. Den aktuella sidan är fortfarande tillgänglig.',
318
+ navigationFilterMatches: 'Matchande navigationsposter: {count}',
319
+ navigationLocatedCurrent: 'Den aktuella sidan har hittats.',
320
+ navigationLocateCurrent: 'Hitta aktuell sida',
239
321
  navigationMenu: 'Meny',
240
322
  nextImage: 'Nästa bild',
323
+ nextPage: 'Nästa sida',
241
324
  note: 'Not',
325
+ openInVsCode: 'Öppna i VS Code',
326
+ notFound: 'Sidan hittades inte',
327
+ notFoundText: 'Den begärda sidan finns inte eller kan ha flyttats.',
242
328
  pageMoved: 'Sidan har flyttats',
243
329
  pageMovedText: 'Den här adressen identifierar nu',
244
330
  pageNavigation: 'Sidinnehåll',
331
+ pageSequence: 'Sidföljd',
332
+ pageSections: 'Avsnitt',
245
333
  previousImage: 'Föregående bild',
334
+ previousPage: 'Föregående sida',
335
+ returnHome: 'Gå till startsidan',
336
+ search: 'Sök',
337
+ searchDescription: 'Sök i det publicerade innehållet på webbplatsen.',
338
+ searchLoading: 'Laddar sökning…',
339
+ searchNoScript: 'Sökning kräver JavaScript. Använd sidnavigeringen när JavaScript inte är tillgängligt.',
340
+ searchUnavailable: 'Sökningen är inte tillgänglig. Kör norna build:local under lokalt arbete för att skapa eller uppdatera sökindexet.',
246
341
  siteBanners: 'Meddelanden',
247
342
  siteNavigation: 'Sidor',
248
343
  skipToContent: 'Hoppa till innehållet',
344
+ tableColumns: 'Tabellkolumner',
345
+ tableNextColumns: 'Visa nästa kolumner',
346
+ tableOverflowDescription: 'Fler tabellkolumner är tillgängliga i sidled.',
347
+ tablePreviousColumns: 'Visa föregående kolumner',
249
348
  }),
250
349
  });
251
350
 
@@ -411,10 +510,13 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
411
510
  presentation: imagePresentation,
412
511
  ...(imagePresentation === 'centered-fit'
413
512
  ? {
414
- maxAvailableHeightPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableHeightPercent', 'images', Object.freeze({
415
- desktop: 74,
416
- mobile: 68,
417
- }), sourceLabel),
513
+ maxAvailableHeightPercent: readResponsivePercent(
514
+ rawImagesConfig,
515
+ 'maxAvailableHeightPercent',
516
+ 'images',
517
+ defaultImageMaxAvailableHeightPercent,
518
+ sourceLabel,
519
+ ),
418
520
  }
419
521
  : {}),
420
522
  maxAvailableWidthPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableWidthPercent', 'images', Object.freeze({
@@ -457,12 +559,46 @@ export const resolveNavigationConfig = (config, sourceLabel = siteConfigLabel) =
457
559
  });
458
560
  };
459
561
 
562
+ export const resolveEditLinkConfig = (config, sourceLabel = siteConfigLabel) => {
563
+ if (config.editLink === undefined) return null;
564
+
565
+ const rawEditLink = assertObject(config.editLink, 'editLink', sourceLabel);
566
+ const unknownKeys = Object.keys(rawEditLink).filter((key) => !['baseUrl', 'localEditor'].includes(key));
567
+ if (unknownKeys.length > 0) {
568
+ throw new Error(`editLink.${unknownKeys[0]} is not a valid setting in ${sourceLabel}.`);
569
+ }
570
+ if (!Object.hasOwn(rawEditLink, 'baseUrl') && !Object.hasOwn(rawEditLink, 'localEditor')) {
571
+ throw new Error(`editLink must specify baseUrl, localEditor, or both in ${sourceLabel}.`);
572
+ }
573
+
574
+ let baseUrl = null;
575
+ if (Object.hasOwn(rawEditLink, 'baseUrl')) {
576
+ try {
577
+ baseUrl = normalizeEditLinkBaseUrl(rawEditLink.baseUrl, 'editLink.baseUrl');
578
+ } catch (error) {
579
+ const message = error instanceof Error ? error.message : String(error);
580
+ throw new Error(`${message.replace(/\.$/, '')} in ${sourceLabel}.`);
581
+ }
582
+ }
583
+
584
+ return Object.freeze({
585
+ baseUrl,
586
+ localEditor: Object.hasOwn(rawEditLink, 'localEditor')
587
+ ? readEnum(rawEditLink, 'localEditor', 'editLink', localEditorNames, undefined, sourceLabel)
588
+ : null,
589
+ });
590
+ };
591
+
460
592
  export const projectConfig = Object.freeze({
461
593
  site: Object.freeze({
462
594
  basePath: siteUrl.pathname,
463
595
  url: siteUrl.href,
464
596
  }),
465
597
  ...resolveThemeVisualConfig(rawTheme, siteThemeLabel),
598
+ editLink: resolveEditLinkConfig(rawConfig, siteConfigLabel),
599
+ search: Object.freeze({
600
+ enabled: readBoolean(rawConfig, 'search', 'config YAML', false),
601
+ }),
466
602
  navigation: Object.freeze({
467
603
  ...resolveNavigationConfig(rawConfig, siteConfigLabel),
468
604
  scrollBehavior: readEnum(
@@ -18,10 +18,22 @@ export const browserIconAssetFilenames = Object.freeze(
18
18
  browserIconAssetDefinitions.map(({ filename }) => filename),
19
19
  );
20
20
 
21
+ export const socialImageAssetDefinitions = Object.freeze([
22
+ Object.freeze({ filename: 'social-image.png', type: 'image/png' }),
23
+ Object.freeze({ filename: 'social-image.jpg', type: 'image/jpeg' }),
24
+ Object.freeze({ filename: 'social-image.jpeg', type: 'image/jpeg' }),
25
+ ]);
26
+
27
+ export const socialImageAssetFilenames = Object.freeze(
28
+ socialImageAssetDefinitions.map(({ filename }) => filename),
29
+ );
30
+
21
31
  const logoExtensions = new Set(['.svg', '.png', '.jpg', '.jpeg']);
22
32
  const faviconExtensions = new Set(['.svg', '.png', '.ico']);
33
+ const socialImageExtensions = new Set(['.png', '.jpg', '.jpeg']);
23
34
  const exactLogoNames = new Set(logoAssetFilenames);
24
35
  const exactBrowserIconNames = new Set(browserIconAssetFilenames);
36
+ const exactSocialImageNames = new Set(socialImageAssetFilenames);
25
37
 
26
38
  export const isLogoAssetFilename = (filename) => exactLogoNames.has(filename);
27
39
 
@@ -60,6 +72,13 @@ const getSuspiciousAsset = (filename) => {
60
72
  };
61
73
  }
62
74
 
75
+ if (exactSocialImageNames.has(lowerFilename) && filename !== lowerFilename) {
76
+ return {
77
+ code: 'public-asset-case',
78
+ message: `Norna does not recognize "${filename}" as the social sharing image on case-sensitive file systems. Rename it to "${lowerFilename}".`,
79
+ };
80
+ }
81
+
63
82
  if (parsed.name === 'logo' && !logoExtensions.has(parsed.ext)) {
64
83
  return {
65
84
  code: 'unsupported-logo-file',
@@ -74,6 +93,13 @@ const getSuspiciousAsset = (filename) => {
74
93
  };
75
94
  }
76
95
 
96
+ if (parsed.name === 'social-image' && !socialImageExtensions.has(parsed.ext)) {
97
+ return {
98
+ code: 'unsupported-social-image-file',
99
+ message: `Norna does not recognize "${filename}" as the social sharing image. Use exactly one of ${socialImageAssetFilenames.join(', ')}.`,
100
+ };
101
+ }
102
+
77
103
  if (/^favicon[-_.]/.test(lowerFilename) && !exactBrowserIconNames.has(lowerFilename)) {
78
104
  return {
79
105
  code: 'unrecognized-favicon-file',
@@ -95,6 +121,13 @@ const getSuspiciousAsset = (filename) => {
95
121
  };
96
122
  }
97
123
 
124
+ if (socialImageExtensions.has(parsed.ext) && editDistance(parsed.name, 'social-image') <= 2) {
125
+ return {
126
+ code: 'possible-social-image-typo',
127
+ message: `"${filename}" looks like a misspelled social sharing image filename. Norna recognizes ${socialImageAssetFilenames.join(', ')}.`,
128
+ };
129
+ }
130
+
98
131
  return null;
99
132
  };
100
133
 
@@ -102,11 +135,12 @@ export const inspectPublicAssetFilenames = (filenames) => {
102
135
  const files = [...filenames].sort((left, right) => left.localeCompare(right, 'en'));
103
136
  const logos = files.filter(isLogoAssetFilename);
104
137
  const browserIcons = files.filter((filename) => exactBrowserIconNames.has(filename));
138
+ const socialImages = files.filter((filename) => exactSocialImageNames.has(filename));
105
139
  const suspicious = files.flatMap((filename) => {
106
- if (isLogoAssetFilename(filename) || exactBrowserIconNames.has(filename)) return [];
140
+ if (isLogoAssetFilename(filename) || exactBrowserIconNames.has(filename) || exactSocialImageNames.has(filename)) return [];
107
141
  const issue = getSuspiciousAsset(filename);
108
142
  return issue ? [{ filename, ...issue }] : [];
109
143
  });
110
144
 
111
- return { browserIcons, files, logos, suspicious };
145
+ return { browserIcons, files, logos, socialImages, suspicious };
112
146
  };
@@ -1,4 +1,5 @@
1
1
  import { z } from 'astro/zod';
2
+ import { localEditorNames } from './edit-source-link.mjs';
2
3
  import { imagePresentationNames } from './image-presentation.mjs';
3
4
  import { navigationModeNames } from './navigation-model.mjs';
4
5
  import { presentationPaletteNames } from './presentation-palette-metadata.mjs';
@@ -17,13 +18,13 @@ const textWidth = z.enum(['narrow', 'normal', 'wide']).describe('Maximum width o
17
18
  const headingWeight = z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).describe('CSS font weight.');
18
19
  const typographyProfile = z.enum(['restrained', 'dense', 'reading', 'statement']).describe('Coordinated typography defaults. Omit this to use the selected preset.');
19
20
  const themePreset = z.enum(themePresetNames).describe('Complete Norna visual preset. Start here and add overrides only when needed.');
20
- const presentationPalette = z.enum(presentationPaletteNames).describe('Coordinated site color palette. Every palette provides light and dark variants. Omit this to use the selected preset.');
21
+ const presentationPalette = z.enum(presentationPaletteNames).describe('Coordinated site color palette. Every palette provides light and dark variants without changing the initial appearance. Omit this to use the selected preset.');
21
22
  const themeAppearance = z.object({
22
- default: z.enum(['system', 'light', 'dark']).optional().describe('Initial appearance. System follows the visitor\'s operating-system preference.'),
23
+ default: z.enum(['system', 'light', 'dark']).optional().describe('Initial appearance. Omit this setting to follow the visitor\'s operating-system preference.'),
23
24
  }).strict().refine(
24
25
  (value) => value.default !== undefined,
25
26
  'Specify default.',
26
- ).describe('Site-wide initial appearance.');
27
+ ).describe('Site-wide initial appearance. The default is system.');
27
28
  const readerControls = z.object({
28
29
  appearance: z.boolean().optional().describe('Show an Appearance control that lets readers choose System, Light, or Dark.'),
29
30
  focusReading: z.boolean().optional().describe('Show a Focus reading control that lets readers hide navigation, breadcrumbs, and the footer. Sites with tree navigation always show this control.'),
@@ -35,9 +36,31 @@ const spacingDensity = z.enum(['compact', 'normal', 'airy']).describe('Coordinat
35
36
  const contentSpacing = z.enum(['compact', 'normal', 'spacious']).describe('Vertical spacing between page sections and structured content blocks.');
36
37
  const backgroundPattern = z.enum(['uniform', 'alternating', 'accented']).describe('How coordinated backgrounds are assigned to H2 sections. Non-uniform patterns are unavailable with tree navigation.');
37
38
  const cornerTreatment = z.enum(['square', 'rounded']).describe('Site-wide corner treatment for navigation, cards and framed content.');
38
- const cardListWidth = z.enum(['text', 'narrow', 'normal', 'wide']).describe('Default maximum width for card lists. A width written in a norna-card-list block overrides this value.');
39
+ const cardListWidth = z.enum(['text', 'narrow', 'normal', 'wide']).describe('Default maximum width for card lists. A width written in a card-list block overrides this value.');
39
40
  const imagePresentation = z.enum(imagePresentationNames).describe('How managed image stacks and carousels are placed on the page. Prose-aligned starts them at the body-text edge; centered-fit centers them and constrains them by available width and viewport height.');
40
- const navigationMode = z.enum(navigationModeNames).describe('Site-wide navigation policy. Automatic selects an effective mode for each page from its active top-level branch.');
41
+ const navigationMode = z.enum(navigationModeNames).describe('Site-wide navigation policy. Automatic uses sections for one page, top navigation for a flat multi-page site, and a stable left rail throughout a hierarchical site.');
42
+ const editLink = z.object({
43
+ baseUrl: z.string().url().refine((value) => {
44
+ try {
45
+ const url = new URL(value);
46
+ return ['http:', 'https:'].includes(url.protocol)
47
+ && !url.username
48
+ && !url.password
49
+ && !url.search
50
+ && !url.hash;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }, 'Use an absolute http or https URL without credentials, a query string, or a fragment.')
55
+ .describe('Absolute edit URL prefix containing the repository, branch, and any repository subdirectory.')
56
+ .optional(),
57
+ localEditor: z.enum(localEditorNames)
58
+ .optional()
59
+ .describe('Editor used by source links in a development preview opened through a loopback address.'),
60
+ }).strict().refine(
61
+ (value) => value.baseUrl !== undefined || value.localEditor !== undefined,
62
+ 'Specify baseUrl, localEditor, or both.',
63
+ ).describe('Optional local and remote links from rendered pages to their Markdown source files.');
41
64
  const createLineHeight = (minimum, role) => z.number()
42
65
  .min(minimum, `Use a unitless ${role} line height of at least ${minimum}.`)
43
66
  .max(3, `Use a unitless ${role} line height of at most 3.`)
@@ -222,7 +245,9 @@ const sitewideFooter = z.object({
222
245
  const configShape = {
223
246
  url: z.string().url().describe('Absolute public URL for the built site.'),
224
247
  language: z.string().regex(/^(?:en|sv)(?:-[a-zA-Z0-9]+)*$/).optional().describe('Site language tag using Norna\'s English or Swedish interface text; the default is en.'),
248
+ editLink: editLink.optional(),
225
249
  navigation: configNavigation.optional(),
250
+ search: z.boolean().optional().default(false).describe('Generate a static site search page and Pagefind index. The default is false.'),
226
251
  scrollBehavior: z.enum(['instant', 'smooth']).optional().default('instant').describe('Use instant anchors by default or the browser\'s native smooth scrolling.'),
227
252
  };
228
253
 
@@ -159,11 +159,43 @@ const addConfigHelp = (jsonSchema) => {
159
159
  'Sets the page language and selects Norna\'s built-in English or Swedish interface text. The default is `en`.',
160
160
  documentationLink('Language reference', 'configuration.md', 'language'),
161
161
  ], ['en', 'sv', 'en-GB', 'sv-SE']);
162
+ addHelp(jsonSchema, 'editLink', [
163
+ yamlExample('editLink:\n localEditor: vscode\n baseUrl: https://github.com/owner/repository/edit/main/'),
164
+ 'Links each rendered page to its `content.md` source. A loopback development preview uses the explicitly selected local editor; published output and LAN previews use the optional remote base URL. Specify either destination or both.',
165
+ documentationLink('Edit-link reference', 'configuration.md', 'edit-link'),
166
+ ]);
167
+ const editLink = schemaProperty(jsonSchema, 'editLink');
168
+ editLink.anyOf = [{ required: ['localEditor'] }, { required: ['baseUrl'] }];
169
+ addSnippets(jsonSchema, 'editLink', [schemaSnippet({
170
+ label: 'Local and remote source links',
171
+ body: {
172
+ localEditor: 'vscode',
173
+ baseUrl: 'https://github.com/${1:owner}/${2:repository}/edit/${3:main}/',
174
+ },
175
+ description: 'Open page source in VS Code during same-computer development and on the remote source host elsewhere.',
176
+ file: 'configuration.md',
177
+ anchor: 'edit-link',
178
+ })]);
179
+ addHelp(jsonSchema, 'editLink.baseUrl', [
180
+ yamlExample('editLink:\n baseUrl: https://github.com/owner/repository/edit/main/'),
181
+ 'Optional absolute remote edit URL prefix. Include any repository subdirectory and branch. Norna adds the project-relative path to each page\'s `content.md` file. This destination is used by published output and LAN previews.',
182
+ documentationLink('Edit-link reference', 'configuration.md', 'edit-link'),
183
+ ], ['https://github.com/owner/repository/edit/main/']);
184
+ addHelp(jsonSchema, 'editLink.localEditor', [
185
+ yamlExample('editLink:\n localEditor: vscode'),
186
+ 'Optional editor for a development preview opened through localhost or another loopback address. `vscode` opens the absolute page source path through VS Code\'s registered URL handler. The setting is never emitted as a local path in published output.',
187
+ documentationLink('Edit-link reference', 'configuration.md', 'edit-link'),
188
+ ], ['vscode']);
162
189
  addHelp(jsonSchema, 'navigation', [
163
190
  yamlExample('navigation:\n mode: automatic'),
164
- 'Sets the site-wide navigation policy. `automatic` selects an effective mode for each page from its active top-level branch.',
191
+ 'Sets the site-wide navigation policy. `automatic` uses sections for one page, top navigation for a flat multi-page site, and a stable left rail throughout a hierarchical site.',
165
192
  documentationLink('Navigation reference', 'pages.md', 'navigation'),
166
193
  ]);
194
+ addHelp(jsonSchema, 'search', [
195
+ yamlExample('search: true'),
196
+ 'Generates a localized `/search/` page and a static Pagefind index during each build. Search is off by default, and only the search page loads the search JavaScript.',
197
+ documentationLink('Search reference', 'configuration.md', 'search'),
198
+ ], [true, false]);
167
199
  addFieldHelp(
168
200
  jsonSchema,
169
201
  'navigation.mode',
@@ -212,7 +244,7 @@ const addThemeHelp = (jsonSchema) => {
212
244
  ]);
213
245
  addHelp(jsonSchema, 'blocks.cardList', [
214
246
  yamlExample('blocks:\n cardList:\n width: text'),
215
- 'Sets site-wide defaults for card lists. A `width` option in an individual `norna-card-list` block overrides this setting.',
247
+ 'Sets site-wide defaults for card lists. A `width` option in an individual `card-list` block overrides this setting.',
216
248
  documentationLink('Content block defaults', 'theme.md', 'content-block-defaults'),
217
249
  ]);
218
250
  addFieldHelp(
@@ -241,12 +273,12 @@ const addThemeHelp = (jsonSchema) => {
241
273
  ]);
242
274
  addHelp(jsonSchema, 'palette', [
243
275
  yamlExample('palette: warm-paper'),
244
- 'Chooses the site color character. Every palette provides coordinated light and dark variants.',
276
+ 'Chooses the site color character. Every palette provides coordinated light and dark variants without changing the initial Appearance.',
245
277
  documentationLink('Palette and appearance', 'theme.md', 'palette-and-appearance'),
246
278
  ], presentationPaletteNames);
247
279
  addHelp(jsonSchema, 'appearance', [
248
280
  yamlExample('appearance:\n default: system'),
249
- 'Selects the initial appearance. Enable the reader-facing choice under readerControls.',
281
+ 'Selects the initial Appearance. Omit this setting to follow the visitor\'s system preference. Enable the reader-facing choice under readerControls.',
250
282
  documentationLink('Appearance', 'theme.md', 'appearance'),
251
283
  ]);
252
284
  addFieldHelp(jsonSchema, 'appearance.default', 'appearance:\n default: system', 'theme.md', 'appearance', ['system', 'light', 'dark']);
@@ -23,7 +23,7 @@ export const schemaValueDefinitions = Object.freeze([
23
23
  smooth: option('Browser smooth', 'Use the browser\'s native smooth scrolling for anchor navigation.'),
24
24
  }),
25
25
  definition(['automatic', 'sections', 'top', 'tree'], {
26
- automatic: option('Automatic', 'Choose sections for one page, top navigation for Home and independent top-level pages, and tree navigation inside branches with listed children or categories.'),
26
+ automatic: option('Automatic', 'Choose sections for one page, top navigation for a flat multi-page site, and a stable left rail throughout a site with listed child pages or categories.'),
27
27
  sections: option('Sections', 'Navigate the sections of the current page.'),
28
28
  top: option('Top', 'Use pages in the top navigation and sections below them.'),
29
29
  tree: option('Tree', 'Use a left page rail and a separate right H2/H3 contents rail where the page has enough headings.'),
@@ -32,6 +32,9 @@ export const schemaValueDefinitions = Object.freeze([
32
32
  en: option('English', 'Use Norna\'s built-in English interface text.'),
33
33
  sv: option('Swedish', 'Use Norna\'s built-in Swedish interface text.'),
34
34
  }),
35
+ definition(['vscode'], {
36
+ vscode: option('Visual Studio Code', 'Open the current page source in the locally installed Visual Studio Code application.'),
37
+ }),
35
38
  definition(['left', 'center', 'right'], {
36
39
  left: option('Left', 'Align text with the left edge of its text area.'),
37
40
  center: option('Center', 'Center text within its text area.'),