@janga/norna 0.7.22 → 0.7.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +15 -0
  3. package/bin/norna-cli.mjs +8 -2
  4. package/package.json +28 -3
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +52 -24
  7. package/schemas/content-frontmatter.schema.json +25 -5
  8. package/schemas/page-theme.schema.json +53 -25
  9. package/schemas/sitewide-content.schema.json +19 -18
  10. package/schemas/theme.schema.json +385 -252
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +36 -5
  13. package/scripts/deploy-site.mjs +0 -1
  14. package/scripts/dev-local.mjs +175 -112
  15. package/scripts/generate-search-index.mjs +57 -0
  16. package/scripts/init-site.mjs +1 -0
  17. package/scripts/lib/code-fence-metadata.mjs +223 -0
  18. package/scripts/lib/content-sync-apply.mjs +34 -0
  19. package/scripts/lib/content-sync-plan.mjs +179 -0
  20. package/scripts/lib/edit-source-link.mjs +49 -0
  21. package/scripts/lib/editor-language-service.mjs +160 -12
  22. package/scripts/lib/heading-ids.mjs +1 -1
  23. package/scripts/lib/image-presentation.mjs +6 -0
  24. package/scripts/lib/markdown-links.mjs +182 -0
  25. package/scripts/lib/navigation-model.mjs +44 -12
  26. package/scripts/lib/navigation-review.mjs +396 -0
  27. package/scripts/lib/norna-markdown-blocks.mjs +74 -26
  28. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  29. package/scripts/lib/page-aliases.mjs +184 -0
  30. package/scripts/lib/page-markdown.mjs +88 -7
  31. package/scripts/lib/page-move-plan.mjs +659 -0
  32. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  33. package/scripts/lib/presentation.mjs +10 -37
  34. package/scripts/lib/project-config.mjs +146 -19
  35. package/scripts/lib/public-asset-conventions.mjs +36 -2
  36. package/scripts/lib/schema-definitions.mjs +43 -15
  37. package/scripts/lib/schema-editor-metadata.mjs +60 -32
  38. package/scripts/lib/schema-value-definitions.mjs +6 -2
  39. package/scripts/lib/semantic-callouts.mjs +128 -0
  40. package/scripts/lib/site-content.mjs +28 -5
  41. package/scripts/lib/site-link-graph.mjs +357 -0
  42. package/scripts/lib/site-navigation-tree.mjs +85 -0
  43. package/scripts/lib/site-page-urls.mjs +10 -0
  44. package/scripts/lib/sitemap.mjs +34 -0
  45. package/scripts/lib/social-image-assets.mjs +30 -0
  46. package/scripts/lib/theme-presets.mjs +53 -25
  47. package/scripts/lib/theme-profiles.mjs +6 -8
  48. package/scripts/lib/yaml-config.mjs +6 -2
  49. package/scripts/move-site-page.mjs +256 -0
  50. package/scripts/review-navigation.mjs +32 -0
  51. package/scripts/sync-content-sections.mjs +182 -216
  52. package/scripts/sync-site-public.mjs +43 -3
  53. package/src/components/CodeBlockCopyScript.astro +97 -0
  54. package/src/components/DisplaySettings.astro +4 -4
  55. package/src/components/EditSourceLink.astro +17 -0
  56. package/src/components/ImageCarousel.astro +6 -3
  57. package/src/components/NavigationPageTree.astro +81 -86
  58. package/src/components/NavigationTreeControls.astro +77 -0
  59. package/src/components/PageAliasRedirect.astro +49 -0
  60. package/src/components/PageContentsNavigation.astro +21 -0
  61. package/src/components/PageList.astro +33 -0
  62. package/src/components/PageSequenceNavigation.astro +37 -0
  63. package/src/components/SearchPage.astro +151 -0
  64. package/src/components/SectionNavigationScript.astro +62 -6
  65. package/src/components/SiteNavigation.astro +50 -11
  66. package/src/components/SitePage.astro +113 -20
  67. package/src/components/SiteSection.astro +16 -3
  68. package/src/components/SiteTreeNavigation.astro +8 -2
  69. package/src/components/TreeNavigationScript.astro +207 -26
  70. package/src/content.config.ts +5 -2
  71. package/src/layouts/BaseLayout.astro +71 -10
  72. package/src/lib/readerPreferencesScript.mjs +2 -2
  73. package/src/lib/sectionContent.ts +32 -3
  74. package/src/lib/siteNavigation.ts +31 -52
  75. package/src/lib/sitePages.ts +3 -2
  76. package/src/lib/sitePublicAssets.ts +1 -0
  77. package/src/pages/404.astro +110 -0
  78. package/src/pages/[...slug].astro +45 -8
  79. package/src/styles/content.css +718 -0
  80. package/src/styles/foundations.css +284 -0
  81. package/src/styles/global.css +6 -2510
  82. package/src/styles/media.css +518 -0
  83. package/src/styles/navigation.css +526 -0
  84. package/src/styles/page-layout.css +786 -0
  85. package/src/styles/responsive.css +594 -0
  86. package/starters/basic/README.md +6 -19
  87. package/starters/basic/package.json +1 -0
  88. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -12,7 +12,7 @@ import { presentationPaletteNames } from './presentation-palette-metadata.mjs';
12
12
  import { resolveThemeConfig } from './theme-presets.mjs';
13
13
 
14
14
  export { presentationPaletteNames };
15
- export const colorModeNames = ['system', 'light', 'dark'];
15
+ export const appearanceNames = ['system', 'light', 'dark'];
16
16
  const sectionBackgroundPatterns = Object.freeze({
17
17
  uniform: ['base'],
18
18
  alternating: ['base', 'soft'],
@@ -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
- defaultMode: 'dark',
48
47
  modes: Object.freeze({
49
48
  light: createPaletteMode({
50
49
  appearance: 'light',
@@ -61,7 +60,6 @@ const presentationPalettes = Object.freeze({
61
60
  soft: '#d8d8d3',
62
61
  accent: '#4b4b46',
63
62
  border: 'rgb(23 23 23 / 16%)',
64
- navBackground: 'rgb(247 247 245 / 92%)',
65
63
  navSeparator: 'rgb(23 23 23 / 24%)',
66
64
  },
67
65
  }),
@@ -80,14 +78,12 @@ const presentationPalettes = Object.freeze({
80
78
  soft: '#302f2c',
81
79
  accent: '#d8d2c8',
82
80
  border: 'rgb(255 255 255 / 14%)',
83
- navBackground: 'rgb(0 0 0 / 90%)',
84
81
  navSeparator: 'rgb(255 255 255 / 28%)',
85
82
  },
86
83
  }),
87
84
  }),
88
85
  }),
89
86
  'arctic-blue': Object.freeze({
90
- defaultMode: 'light',
91
87
  modes: Object.freeze({
92
88
  light: createPaletteMode({
93
89
  appearance: 'light',
@@ -104,7 +100,6 @@ const presentationPalettes = Object.freeze({
104
100
  soft: '#d5dfe7',
105
101
  accent: '#2a5877',
106
102
  border: 'rgb(32 41 50 / 16%)',
107
- navBackground: 'rgb(243 246 248 / 92%)',
108
103
  navSeparator: 'rgb(32 41 50 / 24%)',
109
104
  },
110
105
  }),
@@ -123,14 +118,12 @@ const presentationPalettes = Object.freeze({
123
118
  soft: '#354655',
124
119
  accent: '#9bc5de',
125
120
  border: 'rgb(237 243 247 / 16%)',
126
- navBackground: 'rgb(23 32 42 / 92%)',
127
121
  navSeparator: 'rgb(237 243 247 / 28%)',
128
122
  },
129
123
  }),
130
124
  }),
131
125
  }),
132
126
  'mineral-teal': Object.freeze({
133
- defaultMode: 'light',
134
127
  modes: Object.freeze({
135
128
  light: createPaletteMode({
136
129
  appearance: 'light',
@@ -147,7 +140,6 @@ const presentationPalettes = Object.freeze({
147
140
  soft: '#cfdfd9',
148
141
  accent: '#155f5a',
149
142
  border: 'rgb(32 54 49 / 17%)',
150
- navBackground: 'rgb(238 244 241 / 92%)',
151
143
  navSeparator: 'rgb(32 54 49 / 24%)',
152
144
  },
153
145
  }),
@@ -166,14 +158,12 @@ const presentationPalettes = Object.freeze({
166
158
  soft: '#2c554f',
167
159
  accent: '#8bd5ca',
168
160
  border: 'rgb(237 245 241 / 16%)',
169
- navBackground: 'rgb(16 45 44 / 92%)',
170
161
  navSeparator: 'rgb(237 245 241 / 28%)',
171
162
  },
172
163
  }),
173
164
  }),
174
165
  }),
175
166
  'soft-lavender': Object.freeze({
176
- defaultMode: 'light',
177
167
  modes: Object.freeze({
178
168
  light: createPaletteMode({
179
169
  appearance: 'light',
@@ -190,7 +180,6 @@ const presentationPalettes = Object.freeze({
190
180
  soft: '#ded5e7',
191
181
  accent: '#604678',
192
182
  border: 'rgb(46 39 54 / 16%)',
193
- navBackground: 'rgb(247 243 250 / 92%)',
194
183
  navSeparator: 'rgb(46 39 54 / 24%)',
195
184
  },
196
185
  }),
@@ -209,14 +198,12 @@ const presentationPalettes = Object.freeze({
209
198
  soft: '#4b3d57',
210
199
  accent: '#d4b7e3',
211
200
  border: 'rgb(244 237 248 / 16%)',
212
- navBackground: 'rgb(37 31 46 / 92%)',
213
201
  navSeparator: 'rgb(244 237 248 / 28%)',
214
202
  },
215
203
  }),
216
204
  }),
217
205
  }),
218
206
  'warm-paper': Object.freeze({
219
- defaultMode: 'light',
220
207
  modes: Object.freeze({
221
208
  light: createPaletteMode({
222
209
  appearance: 'light',
@@ -233,7 +220,6 @@ const presentationPalettes = Object.freeze({
233
220
  soft: '#d8d0c4',
234
221
  accent: '#5f513c',
235
222
  border: 'rgb(39 37 34 / 18%)',
236
- navBackground: 'rgb(248 245 238 / 92%)',
237
223
  navSeparator: 'rgb(39 37 34 / 24%)',
238
224
  },
239
225
  }),
@@ -252,14 +238,12 @@ const presentationPalettes = Object.freeze({
252
238
  soft: '#3b342b',
253
239
  accent: '#d5bea0',
254
240
  border: 'rgb(243 237 226 / 16%)',
255
- navBackground: 'rgb(27 25 22 / 92%)',
256
241
  navSeparator: 'rgb(243 237 226 / 26%)',
257
242
  },
258
243
  }),
259
244
  }),
260
245
  }),
261
246
  'retro-earth': Object.freeze({
262
- defaultMode: 'light',
263
247
  modes: Object.freeze({
264
248
  light: createPaletteMode({
265
249
  appearance: 'light',
@@ -276,7 +260,6 @@ const presentationPalettes = Object.freeze({
276
260
  soft: '#deca9c',
277
261
  accent: '#5e4514',
278
262
  border: 'rgb(52 41 28 / 18%)',
279
- navBackground: 'rgb(247 239 217 / 92%)',
280
263
  navSeparator: 'rgb(52 41 28 / 25%)',
281
264
  },
282
265
  }),
@@ -295,14 +278,12 @@ const presentationPalettes = Object.freeze({
295
278
  soft: '#54422a',
296
279
  accent: '#e5c36c',
297
280
  border: 'rgb(245 232 201 / 16%)',
298
- navBackground: 'rgb(40 33 23 / 92%)',
299
281
  navSeparator: 'rgb(245 232 201 / 28%)',
300
282
  },
301
283
  }),
302
284
  }),
303
285
  }),
304
286
  'clay-rose': Object.freeze({
305
- defaultMode: 'light',
306
287
  modes: Object.freeze({
307
288
  light: createPaletteMode({
308
289
  appearance: 'light',
@@ -319,7 +300,6 @@ const presentationPalettes = Object.freeze({
319
300
  soft: '#e2cfca',
320
301
  accent: '#71313d',
321
302
  border: 'rgb(60 41 41 / 17%)',
322
- navBackground: 'rgb(247 239 237 / 92%)',
323
303
  navSeparator: 'rgb(60 41 41 / 25%)',
324
304
  },
325
305
  }),
@@ -338,14 +318,12 @@ const presentationPalettes = Object.freeze({
338
318
  soft: '#58404a',
339
319
  accent: '#e6a7b0',
340
320
  border: 'rgb(247 235 235 / 16%)',
341
- navBackground: 'rgb(45 30 34 / 92%)',
342
321
  navSeparator: 'rgb(247 235 235 / 28%)',
343
322
  },
344
323
  }),
345
324
  }),
346
325
  }),
347
326
  'forest-moss': Object.freeze({
348
- defaultMode: 'light',
349
327
  modes: Object.freeze({
350
328
  light: createPaletteMode({
351
329
  appearance: 'light',
@@ -362,7 +340,6 @@ const presentationPalettes = Object.freeze({
362
340
  soft: '#d5ddc5',
363
341
  accent: '#324e25',
364
342
  border: 'rgb(38 50 37 / 17%)',
365
- navBackground: 'rgb(241 243 232 / 92%)',
366
343
  navSeparator: 'rgb(38 50 37 / 24%)',
367
344
  },
368
345
  }),
@@ -381,14 +358,12 @@ const presentationPalettes = Object.freeze({
381
358
  soft: '#394c37',
382
359
  accent: '#add493',
383
360
  border: 'rgb(238 242 230 / 16%)',
384
- navBackground: 'rgb(23 34 24 / 92%)',
385
361
  navSeparator: 'rgb(238 242 230 / 28%)',
386
362
  },
387
363
  }),
388
364
  }),
389
365
  }),
390
366
  'vivid-night': Object.freeze({
391
- defaultMode: 'dark',
392
367
  modes: Object.freeze({
393
368
  light: createPaletteMode({
394
369
  appearance: 'light',
@@ -405,7 +380,6 @@ const presentationPalettes = Object.freeze({
405
380
  soft: '#d5daec',
406
381
  accent: '#3b568f',
407
382
  border: 'rgb(37 36 58 / 16%)',
408
- navBackground: 'rgb(242 244 251 / 92%)',
409
383
  navSeparator: 'rgb(37 36 58 / 24%)',
410
384
  },
411
385
  }),
@@ -424,7 +398,6 @@ const presentationPalettes = Object.freeze({
424
398
  soft: '#403a58',
425
399
  accent: '#9fe4e8',
426
400
  border: 'rgb(244 241 255 / 16%)',
427
- navBackground: 'rgb(29 27 45 / 92%)',
428
401
  navSeparator: 'rgb(244 241 255 / 28%)',
429
402
  },
430
403
  }),
@@ -493,11 +466,11 @@ export const resolveThemePresentation = (theme, sourceLabel = 'theme.yaml') => {
493
466
  const normalizedTheme = resolveThemeConfig(theme, sourceLabel);
494
467
  const paletteName = normalizedTheme.palette ?? 'near-monochrome';
495
468
  const palette = getPresentationPalette(paletteName);
496
- const colorMode = normalizedTheme.colorMode ?? {};
469
+ const appearance = normalizedTheme.appearance ?? {};
497
470
  const readerControls = normalizedTheme.readerControls ?? {};
498
- const defaultColorMode = colorMode.default ?? palette.defaultMode;
499
- if (!colorModeNames.includes(defaultColorMode)) {
500
- throw new Error(`colorMode.default must be one of ${colorModeNames.join(', ')} in ${sourceLabel}.`);
471
+ const defaultAppearance = appearance.default ?? 'system';
472
+ if (!appearanceNames.includes(defaultAppearance)) {
473
+ throw new Error(`appearance.default must be one of ${appearanceNames.join(', ')} in ${sourceLabel}.`);
501
474
  }
502
475
  const typography = resolveTypographyConfig(normalizedTheme.typography ?? defaultTypography, sourceLabel);
503
476
  const textWidth = normalizedTheme.layout?.textWidth;
@@ -508,19 +481,19 @@ export const resolveThemePresentation = (theme, sourceLabel = 'theme.yaml') => {
508
481
 
509
482
  return {
510
483
  paletteName,
511
- palette: palette.modes[defaultColorMode === 'dark' ? 'dark' : 'light'],
484
+ palette: palette.modes[defaultAppearance === 'dark' ? 'dark' : 'light'],
512
485
  paletteModes: palette.modes,
513
- colorMode: {
514
- default: defaultColorMode,
486
+ appearance: {
487
+ default: defaultAppearance,
515
488
  },
516
489
  readerPreferences: {
517
490
  controls: {
518
- appearance: readerControls.colorMode === true,
491
+ appearance: readerControls.appearance === true,
519
492
  readingWidth: true,
520
493
  focusReading: readerControls.focusReading === true,
521
494
  },
522
495
  defaults: {
523
- appearance: defaultColorMode,
496
+ appearance: defaultAppearance,
524
497
  readingWidth: textWidth === 'narrow' ? 'narrow' : textWidth === 'wide' ? 'wide' : 'standard',
525
498
  focusReading: 'off',
526
499
  },
@@ -2,12 +2,18 @@ import { readFile } from 'node:fs/promises';
2
2
  import {
3
3
  validateConfigYamlStructure,
4
4
  } from './site-content.mjs';
5
+ import {
6
+ defaultImageMaxAvailableHeightPercent,
7
+ defaultImagePresentation,
8
+ imagePresentationNames,
9
+ } from './image-presentation.mjs';
5
10
  import { navigationModeNames } from './navigation-model.mjs';
6
11
  import {
7
12
  siteConfigLabel,
8
13
  siteConfigPath,
9
14
  siteThemeLabel,
10
15
  } from './site-paths.mjs';
16
+ import { normalizeEditLinkBaseUrl } from './edit-source-link.mjs';
11
17
  import { readThemeConfig } from './theme-config.mjs';
12
18
  import { resolveThemeConfig } from './theme-presets.mjs';
13
19
  import { parseYamlConfig } from './yaml-config.mjs';
@@ -194,12 +200,24 @@ const readSiteUrl = (config) => {
194
200
  const localeLabels = Object.freeze({
195
201
  en: Object.freeze({
196
202
  breadcrumb: 'Breadcrumb',
197
- colorMode: 'Color mode',
198
- colorModeDark: 'Dark',
199
- colorModeLight: 'Light',
200
- colorModeSystem: 'System',
203
+ appearance: 'Appearance',
204
+ appearanceDark: 'Dark',
205
+ appearanceLight: 'Light',
206
+ appearanceSystem: 'System',
207
+ codeCopied: 'Copied',
208
+ codeCopyFailed: 'Could not copy code',
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',
201
217
  displaySettings: 'Display',
202
218
  focusReading: 'Focus reading',
219
+ footnoteBackReference: 'Back to reference {reference}',
220
+ footnotes: 'Footnotes',
203
221
  readingWidth: 'Reading width',
204
222
  readingWidthNarrow: 'Narrow',
205
223
  readingWidthStandard: 'Standard',
@@ -209,24 +227,60 @@ const localeLabels = Object.freeze({
209
227
  built: 'Built',
210
228
  images: 'Images',
211
229
  imageCarousel: 'image carousel',
230
+ navigationCollapsedAll: 'All navigation items collapsed.',
231
+ navigationCollapseAll: 'Collapse all',
232
+ navigationControls: 'Navigation tree controls',
212
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',
213
241
  navigationMenu: 'Menu',
214
242
  nextImage: 'Next image',
243
+ nextPage: 'Next page',
215
244
  note: 'Note',
245
+ notFound: 'Page not found',
246
+ notFoundText: 'The requested page does not exist or may have moved.',
247
+ pageMoved: 'Page moved',
248
+ pageMovedText: 'This address now identifies',
216
249
  pageNavigation: 'Page contents',
250
+ pageSequence: 'Page sequence',
251
+ pageSections: 'Sections',
217
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.',
218
260
  siteBanners: 'Site notices',
219
261
  siteNavigation: 'Pages',
220
262
  skipToContent: 'Skip to content',
221
263
  }),
222
264
  sv: Object.freeze({
223
265
  breadcrumb: 'Brödsmulor',
224
- colorMode: 'Färgläge',
225
- colorModeDark: 'Mörkt',
226
- colorModeLight: 'Ljust',
227
- colorModeSystem: 'System',
266
+ appearance: 'Utseende',
267
+ appearanceDark: 'Mörkt',
268
+ appearanceLight: 'Ljust',
269
+ appearanceSystem: 'System',
270
+ codeCopied: 'Kopierat',
271
+ codeCopyFailed: 'Kunde inte kopiera koden',
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',
228
280
  displaySettings: 'Visning',
229
281
  focusReading: 'Fokuserad läsning',
282
+ footnoteBackReference: 'Tillbaka till referens {reference}',
283
+ footnotes: 'Fotnoter',
230
284
  readingWidth: 'Textbredd',
231
285
  readingWidthNarrow: 'Smal',
232
286
  readingWidthStandard: 'Standard',
@@ -236,12 +290,36 @@ const localeLabels = Object.freeze({
236
290
  built: 'Byggd',
237
291
  images: 'Bilder',
238
292
  imageCarousel: 'bildkarusell',
293
+ navigationCollapsedAll: 'Alla navigationsposter har fällts ihop.',
294
+ navigationCollapseAll: 'Fäll ihop alla',
295
+ navigationControls: 'Kontroller för navigationsträdet',
239
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',
240
304
  navigationMenu: 'Meny',
241
305
  nextImage: 'Nästa bild',
306
+ nextPage: 'Nästa sida',
242
307
  note: 'Not',
308
+ notFound: 'Sidan hittades inte',
309
+ notFoundText: 'Den begärda sidan finns inte eller kan ha flyttats.',
310
+ pageMoved: 'Sidan har flyttats',
311
+ pageMovedText: 'Den här adressen identifierar nu',
243
312
  pageNavigation: 'Sidinnehåll',
313
+ pageSequence: 'Sidföljd',
314
+ pageSections: 'Avsnitt',
244
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.',
245
323
  siteBanners: 'Meddelanden',
246
324
  siteNavigation: 'Sidor',
247
325
  skipToContent: 'Hoppa till innehållet',
@@ -365,6 +443,21 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
365
443
  const resolvedContentSpacing = readEnum(rawLayoutConfig, 'contentSpacing', 'layout', contentSpacingNames, 'normal', sourceLabel);
366
444
  const resolvedLayoutSpacingDefaults = contentSpacingProfiles[resolvedContentSpacing];
367
445
  const corners = readEnum(rawThemeConfig, 'corners', 'theme', ['square', 'rounded'], 'rounded', sourceLabel);
446
+ const imagePresentation = readEnum(
447
+ rawImagesConfig,
448
+ 'presentation',
449
+ 'images',
450
+ imagePresentationNames,
451
+ defaultImagePresentation,
452
+ sourceLabel,
453
+ );
454
+ if (imagePresentation === 'prose-aligned' && rawImagesConfig.maxAvailableHeightPercent !== undefined) {
455
+ throw new Error([
456
+ `images.maxAvailableHeightPercent cannot be used with images.presentation "prose-aligned" in ${sourceLabel}.`,
457
+ 'Prose-aligned images follow available horizontal space instead of a viewport-height limit.',
458
+ 'Remove images.maxAvailableHeightPercent or set images.presentation to "centered-fit".',
459
+ ].join('\n'));
460
+ }
368
461
 
369
462
  return Object.freeze({
370
463
  layout: Object.freeze({
@@ -392,10 +485,18 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
392
485
  }),
393
486
  }),
394
487
  images: Object.freeze({
395
- maxAvailableHeightPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableHeightPercent', 'images', Object.freeze({
396
- desktop: 74,
397
- mobile: 68,
398
- }), sourceLabel),
488
+ presentation: imagePresentation,
489
+ ...(imagePresentation === 'centered-fit'
490
+ ? {
491
+ maxAvailableHeightPercent: readResponsivePercent(
492
+ rawImagesConfig,
493
+ 'maxAvailableHeightPercent',
494
+ 'images',
495
+ defaultImageMaxAvailableHeightPercent,
496
+ sourceLabel,
497
+ ),
498
+ }
499
+ : {}),
399
500
  maxAvailableWidthPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableWidthPercent', 'images', Object.freeze({
400
501
  desktop: 100,
401
502
  mobile: 100,
@@ -416,6 +517,13 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
416
517
 
417
518
  export const resolveNavigationConfig = (config, sourceLabel = siteConfigLabel) => {
418
519
  const rawNavigation = assertObject(config.navigation ?? {}, 'navigation', sourceLabel);
520
+ if (Object.hasOwn(rawNavigation, 'sectionTracking')) {
521
+ throw new Error([
522
+ `navigation.sectionTracking is no longer supported in ${sourceLabel}.`,
523
+ 'Section tracking is automatic on pages with a contents rail.',
524
+ 'Remove navigation.sectionTracking.',
525
+ ].join('\n'));
526
+ }
419
527
 
420
528
  return Object.freeze({
421
529
  mode: readEnum(
@@ -426,22 +534,41 @@ export const resolveNavigationConfig = (config, sourceLabel = siteConfigLabel) =
426
534
  'automatic',
427
535
  sourceLabel,
428
536
  ),
429
- sectionTracking: readBoolean(
430
- rawNavigation,
431
- 'sectionTracking',
432
- 'navigation',
433
- false,
434
- sourceLabel,
435
- ),
436
537
  });
437
538
  };
438
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
+
439
562
  export const projectConfig = Object.freeze({
440
563
  site: Object.freeze({
441
564
  basePath: siteUrl.pathname,
442
565
  url: siteUrl.href,
443
566
  }),
444
567
  ...resolveThemeVisualConfig(rawTheme, siteThemeLabel),
568
+ editLink: resolveEditLinkConfig(rawConfig, siteConfigLabel),
569
+ search: Object.freeze({
570
+ enabled: readBoolean(rawConfig, 'search', 'config YAML', false),
571
+ }),
445
572
  navigation: Object.freeze({
446
573
  ...resolveNavigationConfig(rawConfig, siteConfigLabel),
447
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
  };