@janga/norna 0.7.23 → 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 (71) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +12 -0
  3. package/bin/norna-cli.mjs +6 -0
  4. package/package.json +13 -2
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +45 -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 +12 -1
  13. package/scripts/generate-search-index.mjs +57 -0
  14. package/scripts/init-site.mjs +1 -0
  15. package/scripts/lib/code-fence-metadata.mjs +223 -0
  16. package/scripts/lib/edit-source-link.mjs +49 -0
  17. package/scripts/lib/editor-language-service.mjs +32 -12
  18. package/scripts/lib/image-presentation.mjs +4 -0
  19. package/scripts/lib/navigation-model.mjs +34 -13
  20. package/scripts/lib/navigation-review.mjs +396 -0
  21. package/scripts/lib/norna-markdown-blocks.mjs +73 -26
  22. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  23. package/scripts/lib/page-aliases.mjs +21 -1
  24. package/scripts/lib/page-markdown.mjs +32 -1
  25. package/scripts/lib/page-move-plan.mjs +659 -0
  26. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  27. package/scripts/lib/presentation.mjs +1 -10
  28. package/scripts/lib/project-config.mjs +111 -5
  29. package/scripts/lib/public-asset-conventions.mjs +36 -2
  30. package/scripts/lib/schema-definitions.mjs +22 -5
  31. package/scripts/lib/schema-editor-metadata.mjs +19 -4
  32. package/scripts/lib/schema-value-definitions.mjs +1 -1
  33. package/scripts/lib/semantic-callouts.mjs +128 -0
  34. package/scripts/lib/site-content.mjs +2 -1
  35. package/scripts/lib/site-link-graph.mjs +33 -2
  36. package/scripts/lib/site-navigation-tree.mjs +85 -0
  37. package/scripts/lib/social-image-assets.mjs +30 -0
  38. package/scripts/lib/theme-presets.mjs +2 -2
  39. package/scripts/lib/theme-profiles.mjs +0 -4
  40. package/scripts/move-site-page.mjs +256 -0
  41. package/scripts/review-navigation.mjs +32 -0
  42. package/scripts/sync-content-sections.mjs +67 -4
  43. package/scripts/sync-site-public.mjs +24 -5
  44. package/src/components/CodeBlockCopyScript.astro +9 -4
  45. package/src/components/EditSourceLink.astro +17 -0
  46. package/src/components/ImageCarousel.astro +5 -6
  47. package/src/components/NavigationPageTree.astro +66 -55
  48. package/src/components/NavigationTreeControls.astro +77 -0
  49. package/src/components/PageAliasRedirect.astro +1 -1
  50. package/src/components/PageList.astro +33 -0
  51. package/src/components/PageSequenceNavigation.astro +37 -0
  52. package/src/components/SearchPage.astro +151 -0
  53. package/src/components/SiteNavigation.astro +35 -8
  54. package/src/components/SitePage.astro +79 -26
  55. package/src/components/SiteSection.astro +11 -3
  56. package/src/components/SiteTreeNavigation.astro +8 -1
  57. package/src/components/TreeNavigationScript.astro +207 -26
  58. package/src/layouts/BaseLayout.astro +57 -2
  59. package/src/lib/sectionContent.ts +32 -3
  60. package/src/lib/siteNavigation.ts +31 -52
  61. package/src/lib/sitePublicAssets.ts +1 -0
  62. package/src/pages/404.astro +110 -0
  63. package/src/pages/[...slug].astro +15 -5
  64. package/src/styles/content.css +242 -18
  65. package/src/styles/media.css +0 -5
  66. package/src/styles/navigation.css +31 -0
  67. package/src/styles/page-layout.css +438 -89
  68. package/src/styles/responsive.css +77 -8
  69. package/starters/basic/README.md +6 -19
  70. package/starters/basic/package.json +1 -0
  71. 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 { 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,17 @@ 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
+ editSource: 'Edit this page',
195
217
  displaySettings: 'Display',
196
218
  focusReading: 'Focus reading',
219
+ footnoteBackReference: 'Back to reference {reference}',
220
+ footnotes: 'Footnotes',
197
221
  readingWidth: 'Reading width',
198
222
  readingWidthNarrow: 'Narrow',
199
223
  readingWidthStandard: 'Standard',
@@ -203,14 +227,36 @@ const localeLabels = Object.freeze({
203
227
  built: 'Built',
204
228
  images: 'Images',
205
229
  imageCarousel: 'image carousel',
230
+ navigationCollapsedAll: 'All navigation items collapsed.',
231
+ navigationCollapseAll: 'Collapse all',
232
+ navigationControls: 'Navigation tree controls',
206
233
  navigationChildren: 'Child pages',
234
+ navigationExpandedAll: 'All navigation items expanded.',
235
+ navigationExpandAll: 'Expand all',
236
+ navigationFilter: 'Filter pages and groups',
237
+ navigationFilterEmpty: 'No matching pages or groups. The current page remains available.',
238
+ navigationFilterMatches: 'Matching navigation items: {count}',
239
+ navigationLocatedCurrent: 'Current page located.',
240
+ navigationLocateCurrent: 'Locate current page',
207
241
  navigationMenu: 'Menu',
208
242
  nextImage: 'Next image',
243
+ nextPage: 'Next page',
209
244
  note: 'Note',
245
+ notFound: 'Page not found',
246
+ notFoundText: 'The requested page does not exist or may have moved.',
210
247
  pageMoved: 'Page moved',
211
248
  pageMovedText: 'This address now identifies',
212
249
  pageNavigation: 'Page contents',
250
+ pageSequence: 'Page sequence',
251
+ pageSections: 'Sections',
213
252
  previousImage: 'Previous image',
253
+ previousPage: 'Previous page',
254
+ returnHome: 'Go to the homepage',
255
+ search: 'Search',
256
+ searchDescription: 'Search the published content on this site.',
257
+ searchLoading: 'Loading search…',
258
+ searchNoScript: 'Search requires JavaScript. Use the page navigation when JavaScript is unavailable.',
259
+ searchUnavailable: 'Search is unavailable. During local work, run norna build:local to create or refresh the search index.',
214
260
  siteBanners: 'Site notices',
215
261
  siteNavigation: 'Pages',
216
262
  skipToContent: 'Skip to content',
@@ -224,8 +270,17 @@ const localeLabels = Object.freeze({
224
270
  codeCopied: 'Kopierat',
225
271
  codeCopyFailed: 'Kunde inte kopiera koden',
226
272
  copyCode: 'Kopiera kod',
273
+ calloutCaution: 'Var försiktig',
274
+ calloutDanger: 'Fara',
275
+ calloutImportant: 'Viktigt',
276
+ calloutNote: 'Notera',
277
+ calloutTip: 'Tips',
278
+ calloutWarning: 'Varning',
279
+ editSource: 'Redigera den här sidan',
227
280
  displaySettings: 'Visning',
228
281
  focusReading: 'Fokuserad läsning',
282
+ footnoteBackReference: 'Tillbaka till referens {reference}',
283
+ footnotes: 'Fotnoter',
229
284
  readingWidth: 'Textbredd',
230
285
  readingWidthNarrow: 'Smal',
231
286
  readingWidthStandard: 'Standard',
@@ -235,14 +290,36 @@ const localeLabels = Object.freeze({
235
290
  built: 'Byggd',
236
291
  images: 'Bilder',
237
292
  imageCarousel: 'bildkarusell',
293
+ navigationCollapsedAll: 'Alla navigationsposter har fällts ihop.',
294
+ navigationCollapseAll: 'Fäll ihop alla',
295
+ navigationControls: 'Kontroller för navigationsträdet',
238
296
  navigationChildren: 'Undersidor',
297
+ navigationExpandedAll: 'Alla navigationsposter har fällts ut.',
298
+ navigationExpandAll: 'Fäll ut alla',
299
+ navigationFilter: 'Filtrera sidor och grupper',
300
+ navigationFilterEmpty: 'Inga sidor eller grupper matchar. Den aktuella sidan är fortfarande tillgänglig.',
301
+ navigationFilterMatches: 'Matchande navigationsposter: {count}',
302
+ navigationLocatedCurrent: 'Den aktuella sidan har hittats.',
303
+ navigationLocateCurrent: 'Hitta aktuell sida',
239
304
  navigationMenu: 'Meny',
240
305
  nextImage: 'Nästa bild',
306
+ nextPage: 'Nästa sida',
241
307
  note: 'Not',
308
+ notFound: 'Sidan hittades inte',
309
+ notFoundText: 'Den begärda sidan finns inte eller kan ha flyttats.',
242
310
  pageMoved: 'Sidan har flyttats',
243
311
  pageMovedText: 'Den här adressen identifierar nu',
244
312
  pageNavigation: 'Sidinnehåll',
313
+ pageSequence: 'Sidföljd',
314
+ pageSections: 'Avsnitt',
245
315
  previousImage: 'Föregående bild',
316
+ previousPage: 'Föregående sida',
317
+ returnHome: 'Gå till startsidan',
318
+ search: 'Sök',
319
+ searchDescription: 'Sök i det publicerade innehållet på webbplatsen.',
320
+ searchLoading: 'Laddar sökning…',
321
+ searchNoScript: 'Sökning kräver JavaScript. Använd sidnavigeringen när JavaScript inte är tillgängligt.',
322
+ searchUnavailable: 'Sökningen är inte tillgänglig. Kör norna build:local under lokalt arbete för att skapa eller uppdatera sökindexet.',
246
323
  siteBanners: 'Meddelanden',
247
324
  siteNavigation: 'Sidor',
248
325
  skipToContent: 'Hoppa till innehållet',
@@ -411,10 +488,13 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
411
488
  presentation: imagePresentation,
412
489
  ...(imagePresentation === 'centered-fit'
413
490
  ? {
414
- maxAvailableHeightPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableHeightPercent', 'images', Object.freeze({
415
- desktop: 74,
416
- mobile: 68,
417
- }), sourceLabel),
491
+ maxAvailableHeightPercent: readResponsivePercent(
492
+ rawImagesConfig,
493
+ 'maxAvailableHeightPercent',
494
+ 'images',
495
+ defaultImageMaxAvailableHeightPercent,
496
+ sourceLabel,
497
+ ),
418
498
  }
419
499
  : {}),
420
500
  maxAvailableWidthPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableWidthPercent', 'images', Object.freeze({
@@ -457,12 +537,38 @@ export const resolveNavigationConfig = (config, sourceLabel = siteConfigLabel) =
457
537
  });
458
538
  };
459
539
 
540
+ export const resolveEditLinkConfig = (config, sourceLabel = siteConfigLabel) => {
541
+ if (config.editLink === undefined) return null;
542
+
543
+ const rawEditLink = assertObject(config.editLink, 'editLink', sourceLabel);
544
+ if (!Object.hasOwn(rawEditLink, 'baseUrl')) {
545
+ throw new Error(`editLink.baseUrl is required when editLink is configured in ${sourceLabel}.`);
546
+ }
547
+ const unknownKeys = Object.keys(rawEditLink).filter((key) => key !== 'baseUrl');
548
+ if (unknownKeys.length > 0) {
549
+ throw new Error(`editLink.${unknownKeys[0]} is not a valid setting in ${sourceLabel}.`);
550
+ }
551
+
552
+ try {
553
+ return Object.freeze({
554
+ baseUrl: normalizeEditLinkBaseUrl(rawEditLink.baseUrl, 'editLink.baseUrl'),
555
+ });
556
+ } catch (error) {
557
+ const message = error instanceof Error ? error.message : String(error);
558
+ throw new Error(`${message.replace(/\.$/, '')} in ${sourceLabel}.`);
559
+ }
560
+ };
561
+
460
562
  export const projectConfig = Object.freeze({
461
563
  site: Object.freeze({
462
564
  basePath: siteUrl.pathname,
463
565
  url: siteUrl.href,
464
566
  }),
465
567
  ...resolveThemeVisualConfig(rawTheme, siteThemeLabel),
568
+ editLink: resolveEditLinkConfig(rawConfig, siteConfigLabel),
569
+ search: Object.freeze({
570
+ enabled: readBoolean(rawConfig, 'search', 'config YAML', false),
571
+ }),
466
572
  navigation: Object.freeze({
467
573
  ...resolveNavigationConfig(rawConfig, siteConfigLabel),
468
574
  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
  };
@@ -17,13 +17,13 @@ const textWidth = z.enum(['narrow', 'normal', 'wide']).describe('Maximum width o
17
17
  const headingWeight = z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).describe('CSS font weight.');
18
18
  const typographyProfile = z.enum(['restrained', 'dense', 'reading', 'statement']).describe('Coordinated typography defaults. Omit this to use the selected preset.');
19
19
  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.');
20
+ 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
21
  const themeAppearance = z.object({
22
- default: z.enum(['system', 'light', 'dark']).optional().describe('Initial appearance. System follows the visitor\'s operating-system preference.'),
22
+ default: z.enum(['system', 'light', 'dark']).optional().describe('Initial appearance. Omit this setting to follow the visitor\'s operating-system preference.'),
23
23
  }).strict().refine(
24
24
  (value) => value.default !== undefined,
25
25
  'Specify default.',
26
- ).describe('Site-wide initial appearance.');
26
+ ).describe('Site-wide initial appearance. The default is system.');
27
27
  const readerControls = z.object({
28
28
  appearance: z.boolean().optional().describe('Show an Appearance control that lets readers choose System, Light, or Dark.'),
29
29
  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 +35,24 @@ const spacingDensity = z.enum(['compact', 'normal', 'airy']).describe('Coordinat
35
35
  const contentSpacing = z.enum(['compact', 'normal', 'spacious']).describe('Vertical spacing between page sections and structured content blocks.');
36
36
  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
37
  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.');
38
+ 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
39
  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.');
40
+ 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.');
41
+ const editLink = z.object({
42
+ baseUrl: z.string().url().refine((value) => {
43
+ try {
44
+ const url = new URL(value);
45
+ return ['http:', 'https:'].includes(url.protocol)
46
+ && !url.username
47
+ && !url.password
48
+ && !url.search
49
+ && !url.hash;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }, 'Use an absolute http or https URL without credentials, a query string, or a fragment.')
54
+ .describe('Absolute edit URL prefix containing the repository, branch, and any repository subdirectory.'),
55
+ }).strict().describe('Optional base URL for links from rendered pages to their Markdown source files.');
41
56
  const createLineHeight = (minimum, role) => z.number()
42
57
  .min(minimum, `Use a unitless ${role} line height of at least ${minimum}.`)
43
58
  .max(3, `Use a unitless ${role} line height of at most 3.`)
@@ -222,7 +237,9 @@ const sitewideFooter = z.object({
222
237
  const configShape = {
223
238
  url: z.string().url().describe('Absolute public URL for the built site.'),
224
239
  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.'),
240
+ editLink: editLink.optional(),
225
241
  navigation: configNavigation.optional(),
242
+ search: z.boolean().optional().default(false).describe('Generate a static site search page and Pagefind index. The default is false.'),
226
243
  scrollBehavior: z.enum(['instant', 'smooth']).optional().default('instant').describe('Use instant anchors by default or the browser\'s native smooth scrolling.'),
227
244
  };
228
245
 
@@ -159,11 +159,26 @@ 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 baseUrl: https://github.com/owner/repository/edit/main/'),
164
+ 'Adds a localized **Edit this page** link from each rendered page to its `content.md` source file. The base URL identifies the repository, branch, and any repository subdirectory; Norna appends the page source path.',
165
+ documentationLink('Edit-link reference', 'configuration.md', 'edit-link'),
166
+ ]);
167
+ addHelp(jsonSchema, 'editLink.baseUrl', [
168
+ yamlExample('editLink:\n baseUrl: https://github.com/owner/repository/edit/main/'),
169
+ 'Absolute edit URL prefix. Include any repository subdirectory and branch in this URL. Norna adds the project-relative path to each page\'s `content.md` file.',
170
+ documentationLink('Edit-link reference', 'configuration.md', 'edit-link'),
171
+ ], ['https://github.com/owner/repository/edit/main/']);
162
172
  addHelp(jsonSchema, 'navigation', [
163
173
  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.',
174
+ '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
175
  documentationLink('Navigation reference', 'pages.md', 'navigation'),
166
176
  ]);
177
+ addHelp(jsonSchema, 'search', [
178
+ yamlExample('search: true'),
179
+ '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.',
180
+ documentationLink('Search reference', 'configuration.md', 'search'),
181
+ ], [true, false]);
167
182
  addFieldHelp(
168
183
  jsonSchema,
169
184
  'navigation.mode',
@@ -212,7 +227,7 @@ const addThemeHelp = (jsonSchema) => {
212
227
  ]);
213
228
  addHelp(jsonSchema, 'blocks.cardList', [
214
229
  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.',
230
+ 'Sets site-wide defaults for card lists. A `width` option in an individual `card-list` block overrides this setting.',
216
231
  documentationLink('Content block defaults', 'theme.md', 'content-block-defaults'),
217
232
  ]);
218
233
  addFieldHelp(
@@ -241,12 +256,12 @@ const addThemeHelp = (jsonSchema) => {
241
256
  ]);
242
257
  addHelp(jsonSchema, 'palette', [
243
258
  yamlExample('palette: warm-paper'),
244
- 'Chooses the site color character. Every palette provides coordinated light and dark variants.',
259
+ 'Chooses the site color character. Every palette provides coordinated light and dark variants without changing the initial Appearance.',
245
260
  documentationLink('Palette and appearance', 'theme.md', 'palette-and-appearance'),
246
261
  ], presentationPaletteNames);
247
262
  addHelp(jsonSchema, 'appearance', [
248
263
  yamlExample('appearance:\n default: system'),
249
- 'Selects the initial appearance. Enable the reader-facing choice under readerControls.',
264
+ 'Selects the initial Appearance. Omit this setting to follow the visitor\'s system preference. Enable the reader-facing choice under readerControls.',
250
265
  documentationLink('Appearance', 'theme.md', 'appearance'),
251
266
  ]);
252
267
  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.'),
@@ -0,0 +1,128 @@
1
+ export const semanticCalloutTypes = Object.freeze([
2
+ 'NOTE',
3
+ 'TIP',
4
+ 'IMPORTANT',
5
+ 'WARNING',
6
+ 'CAUTION',
7
+ 'DANGER',
8
+ ]);
9
+
10
+ const semanticCalloutTypeSet = new Set(semanticCalloutTypes);
11
+ const markerPattern = /^\[!([A-Za-z][A-Za-z0-9-]*)\]([^\n]*)(?:\n|$)/;
12
+
13
+ const hasMeaningfulContent = (node) => {
14
+ if (!node || typeof node !== 'object') return false;
15
+ if ('value' in node && typeof node.value === 'string') return node.value.trim().length > 0;
16
+ if (node.type === 'image') return Boolean(node.alt);
17
+ return Array.isArray(node.children) && node.children.some(hasMeaningfulContent);
18
+ };
19
+
20
+ export const getSemanticCalloutMarker = (node) => {
21
+ if (node?.type !== 'blockquote') return null;
22
+ const firstParagraph = node.children?.[0];
23
+ const firstText = firstParagraph?.type === 'paragraph' ? firstParagraph.children?.[0] : null;
24
+ if (firstText?.type !== 'text') return null;
25
+
26
+ const match = firstText.value.match(markerPattern);
27
+ if (!match) {
28
+ return firstText.value.startsWith('[!')
29
+ ? { malformed: true, firstParagraph, firstText }
30
+ : null;
31
+ }
32
+
33
+ const rawType = match[1] ?? '';
34
+ const type = rawType.toUpperCase();
35
+ const markerSource = match[0];
36
+ const title = (match[2] ?? '').trim();
37
+ const firstTextRemainder = firstText.value.slice(markerSource.length);
38
+ const firstParagraphRemainder = [
39
+ { ...firstText, value: firstTextRemainder },
40
+ ...(firstParagraph.children?.slice(1) ?? []),
41
+ ];
42
+ const hasBody = firstParagraphRemainder.some(hasMeaningfulContent)
43
+ || (node.children?.slice(1) ?? []).some(hasMeaningfulContent);
44
+
45
+ return {
46
+ firstParagraph,
47
+ firstText,
48
+ firstTextRemainder,
49
+ hasBody,
50
+ malformed: false,
51
+ markerSource,
52
+ rawType,
53
+ supported: semanticCalloutTypeSet.has(type),
54
+ title,
55
+ type,
56
+ };
57
+ };
58
+
59
+ const formatDiagnostic = ({ code, fix, label, line, message, offset }) => ({
60
+ code,
61
+ fix,
62
+ line,
63
+ message: `${label} line ${line}: ${message}`,
64
+ offset,
65
+ });
66
+
67
+ export const getSemanticCalloutDiagnostics = (tree, options = {}) => {
68
+ const diagnostics = [];
69
+ const label = options.label ?? 'Markdown';
70
+ const lineOffset = options.lineOffset ?? 0;
71
+
72
+ const visit = (node, blockquoteDepth = 0) => {
73
+ if (!node || typeof node !== 'object') return;
74
+ const nextBlockquoteDepth = blockquoteDepth + (node.type === 'blockquote' ? 1 : 0);
75
+ const marker = getSemanticCalloutMarker(node);
76
+
77
+ if (marker) {
78
+ const line = lineOffset + (node.position?.start.line ?? 1);
79
+ const offset = node.position?.start.offset ?? 0;
80
+ let issue = null;
81
+
82
+ if (blockquoteDepth > 0) {
83
+ issue = {
84
+ code: 'nested-semantic-callout',
85
+ message: 'Semantic callouts cannot be nested inside another blockquote or callout.',
86
+ fix: 'Move the inner callout after the surrounding blockquote.',
87
+ };
88
+ } else if (marker.malformed) {
89
+ issue = {
90
+ code: 'invalid-semantic-callout-marker',
91
+ message: 'This blockquote starts like a semantic callout but its marker is incomplete.',
92
+ fix: 'Write the marker on its own first line, for example "> [!WARNING]".',
93
+ };
94
+ } else if (marker.rawType !== marker.type) {
95
+ issue = {
96
+ code: 'invalid-semantic-callout-type-case',
97
+ message: `Semantic callout type "${marker.rawType}" must use uppercase ASCII letters.`,
98
+ fix: `Write "> [!${marker.type}]".`,
99
+ };
100
+ } else if (!marker.supported) {
101
+ issue = {
102
+ code: 'unknown-semantic-callout-type',
103
+ message: `Unknown semantic callout type "${marker.rawType}".`,
104
+ fix: `Use one of: ${semanticCalloutTypes.join(', ')}.`,
105
+ };
106
+ } else if (marker.title) {
107
+ issue = {
108
+ code: 'unsupported-semantic-callout-title',
109
+ message: `Semantic callout markers cannot contain a custom title ("${marker.title}").`,
110
+ fix: `Keep "> [!${marker.type}]" on its own line. Begin the callout body with bold text when local emphasis is useful.`,
111
+ };
112
+ } else if (!marker.hasBody) {
113
+ issue = {
114
+ code: 'empty-semantic-callout',
115
+ message: `${marker.type} callout has no content.`,
116
+ fix: 'Add at least one quoted content line after the marker.',
117
+ };
118
+ }
119
+
120
+ if (issue) diagnostics.push(formatDiagnostic({ ...issue, label, line, offset }));
121
+ }
122
+
123
+ for (const child of node.children ?? []) visit(child, nextBlockquoteDepth);
124
+ };
125
+
126
+ visit(tree);
127
+ return diagnostics;
128
+ };
@@ -30,6 +30,7 @@ const knownNestedFrontmatterKeys = new Set([
30
30
  'align',
31
31
  'aliases',
32
32
  'alt',
33
+ 'baseUrl',
33
34
  'body',
34
35
  'blockGap',
35
36
  'caption',
@@ -152,7 +153,7 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
152
153
  } else if (fileKind === 'content' && key === 'description') {
153
154
  fix = 'Indent "description:" under "page:". The optional meta description belongs in the page object.';
154
155
  } else if (fileKind === 'content' && key === 'images') {
155
- fix = 'Put local image references in norna-image-stack or norna-image-carousel blocks in the Markdown body.';
156
+ fix = 'Put local image references in image-stack or image-carousel blocks in the Markdown body.';
156
157
  } else if (fileKind === 'content' && knownThemeTopLevelFrontmatterKeys.has(key)) {
157
158
  fix = `Move "${key}:" to theme.yaml. Visual settings do not belong in content frontmatter.`;
158
159
  } else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'navigation') {