@janga/norna 0.7.22 → 0.7.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +15 -0
  3. package/bin/norna-cli.mjs +8 -2
  4. package/package.json +28 -3
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +52 -24
  7. package/schemas/content-frontmatter.schema.json +25 -5
  8. package/schemas/page-theme.schema.json +53 -25
  9. package/schemas/sitewide-content.schema.json +19 -18
  10. package/schemas/theme.schema.json +385 -252
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +36 -5
  13. package/scripts/deploy-site.mjs +0 -1
  14. package/scripts/dev-local.mjs +175 -112
  15. package/scripts/generate-search-index.mjs +57 -0
  16. package/scripts/init-site.mjs +1 -0
  17. package/scripts/lib/code-fence-metadata.mjs +223 -0
  18. package/scripts/lib/content-sync-apply.mjs +34 -0
  19. package/scripts/lib/content-sync-plan.mjs +179 -0
  20. package/scripts/lib/edit-source-link.mjs +49 -0
  21. package/scripts/lib/editor-language-service.mjs +160 -12
  22. package/scripts/lib/heading-ids.mjs +1 -1
  23. package/scripts/lib/image-presentation.mjs +6 -0
  24. package/scripts/lib/markdown-links.mjs +182 -0
  25. package/scripts/lib/navigation-model.mjs +44 -12
  26. package/scripts/lib/navigation-review.mjs +396 -0
  27. package/scripts/lib/norna-markdown-blocks.mjs +74 -26
  28. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  29. package/scripts/lib/page-aliases.mjs +184 -0
  30. package/scripts/lib/page-markdown.mjs +88 -7
  31. package/scripts/lib/page-move-plan.mjs +659 -0
  32. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  33. package/scripts/lib/presentation.mjs +10 -37
  34. package/scripts/lib/project-config.mjs +146 -19
  35. package/scripts/lib/public-asset-conventions.mjs +36 -2
  36. package/scripts/lib/schema-definitions.mjs +43 -15
  37. package/scripts/lib/schema-editor-metadata.mjs +60 -32
  38. package/scripts/lib/schema-value-definitions.mjs +6 -2
  39. package/scripts/lib/semantic-callouts.mjs +128 -0
  40. package/scripts/lib/site-content.mjs +28 -5
  41. package/scripts/lib/site-link-graph.mjs +357 -0
  42. package/scripts/lib/site-navigation-tree.mjs +85 -0
  43. package/scripts/lib/site-page-urls.mjs +10 -0
  44. package/scripts/lib/sitemap.mjs +34 -0
  45. package/scripts/lib/social-image-assets.mjs +30 -0
  46. package/scripts/lib/theme-presets.mjs +53 -25
  47. package/scripts/lib/theme-profiles.mjs +6 -8
  48. package/scripts/lib/yaml-config.mjs +6 -2
  49. package/scripts/move-site-page.mjs +256 -0
  50. package/scripts/review-navigation.mjs +32 -0
  51. package/scripts/sync-content-sections.mjs +182 -216
  52. package/scripts/sync-site-public.mjs +43 -3
  53. package/src/components/CodeBlockCopyScript.astro +97 -0
  54. package/src/components/DisplaySettings.astro +4 -4
  55. package/src/components/EditSourceLink.astro +17 -0
  56. package/src/components/ImageCarousel.astro +6 -3
  57. package/src/components/NavigationPageTree.astro +81 -86
  58. package/src/components/NavigationTreeControls.astro +77 -0
  59. package/src/components/PageAliasRedirect.astro +49 -0
  60. package/src/components/PageContentsNavigation.astro +21 -0
  61. package/src/components/PageList.astro +33 -0
  62. package/src/components/PageSequenceNavigation.astro +37 -0
  63. package/src/components/SearchPage.astro +151 -0
  64. package/src/components/SectionNavigationScript.astro +62 -6
  65. package/src/components/SiteNavigation.astro +50 -11
  66. package/src/components/SitePage.astro +113 -20
  67. package/src/components/SiteSection.astro +16 -3
  68. package/src/components/SiteTreeNavigation.astro +8 -2
  69. package/src/components/TreeNavigationScript.astro +207 -26
  70. package/src/content.config.ts +5 -2
  71. package/src/layouts/BaseLayout.astro +71 -10
  72. package/src/lib/readerPreferencesScript.mjs +2 -2
  73. package/src/lib/sectionContent.ts +32 -3
  74. package/src/lib/siteNavigation.ts +31 -52
  75. package/src/lib/sitePages.ts +3 -2
  76. package/src/lib/sitePublicAssets.ts +1 -0
  77. package/src/pages/404.astro +110 -0
  78. package/src/pages/[...slug].astro +45 -8
  79. package/src/styles/content.css +718 -0
  80. package/src/styles/foundations.css +284 -0
  81. package/src/styles/global.css +6 -2510
  82. package/src/styles/media.css +518 -0
  83. package/src/styles/navigation.css +526 -0
  84. package/src/styles/page-layout.css +786 -0
  85. package/src/styles/responsive.css +594 -0
  86. package/starters/basic/README.md +6 -19
  87. package/starters/basic/package.json +1 -0
  88. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -1,4 +1,5 @@
1
1
  import { z } from 'astro/zod';
2
+ import { imagePresentationNames } from './image-presentation.mjs';
2
3
  import { navigationModeNames } from './navigation-model.mjs';
3
4
  import { presentationPaletteNames } from './presentation-palette-metadata.mjs';
4
5
  import { themePresetNames } from './theme-presets.mjs';
@@ -16,26 +17,42 @@ const textWidth = z.enum(['narrow', 'normal', 'wide']).describe('Maximum width o
16
17
  const headingWeight = z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).describe('CSS font weight.');
17
18
  const typographyProfile = z.enum(['restrained', 'dense', 'reading', 'statement']).describe('Coordinated typography defaults. Omit this to use the selected preset.');
18
19
  const themePreset = z.enum(themePresetNames).describe('Complete Norna visual preset. Start here and add overrides only when needed.');
19
- 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 themeColorMode = z.object({
21
- default: z.enum(['system', 'light', 'dark']).optional().describe('Initial color mode. System follows the visitor\'s operating-system preference.'),
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
+ const themeAppearance = z.object({
22
+ default: z.enum(['system', 'light', 'dark']).optional().describe('Initial appearance. Omit this setting to follow the visitor\'s operating-system preference.'),
22
23
  }).strict().refine(
23
24
  (value) => value.default !== undefined,
24
25
  'Specify default.',
25
- ).describe('Site-wide light and dark mode behaviour.');
26
+ ).describe('Site-wide initial appearance. The default is system.');
26
27
  const readerControls = z.object({
27
- colorMode: z.boolean().optional().describe('Let readers choose System, Light, or Dark color mode in the site-wide Display panel.'),
28
- focusReading: z.boolean().optional().describe('Let readers temporarily hide navigation and other secondary page chrome while reading. Tree navigation always provides this control.'),
28
+ appearance: z.boolean().optional().describe('Show an Appearance control that lets readers choose System, Light, or Dark.'),
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.'),
29
30
  }).strict().refine(
30
- (value) => value.colorMode !== undefined || value.focusReading !== undefined,
31
- 'Specify colorMode, focusReading, or both.',
32
- ).describe('Optional site-wide reader choices grouped with the always-available reading-width choice in the Display panel.');
31
+ (value) => value.appearance !== undefined || value.focusReading !== undefined,
32
+ 'Specify appearance, focusReading, or both.',
33
+ ).describe('Optional controls for the site-wide Display panel.');
33
34
  const spacingDensity = z.enum(['compact', 'normal', 'airy']).describe('Coordinated spacing density. Omit this to use the selected preset.');
34
35
  const contentSpacing = z.enum(['compact', 'normal', 'spacious']).describe('Vertical spacing between page sections and structured content blocks.');
35
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.');
36
37
  const cornerTreatment = z.enum(['square', 'rounded']).describe('Site-wide corner treatment for navigation, cards and framed content.');
37
- 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 navigationMode = z.enum(navigationModeNames).describe('Site-wide navigation model. Automatic selects from the site structure.');
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
+ 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 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.');
39
56
  const createLineHeight = (minimum, role) => z.number()
40
57
  .min(minimum, `Use a unitless ${role} line height of at least ${minimum}.`)
41
58
  .max(3, `Use a unitless ${role} line height of at most 3.`)
@@ -156,10 +173,11 @@ const pageThemeLayout = z.object({
156
173
  'Specify contentSpacing, textWidth, or both.',
157
174
  ).describe('Page-local layout settings inherited by descendant pages.');
158
175
  const themeImages = z.object({
176
+ presentation: imagePresentation.optional().describe('Managed-image presentation method. Omit this to keep the selected preset or inherited page setting.'),
159
177
  width: visualCssLength.optional().describe('Maximum managed-image width. Omit this to keep the selected preset.'),
160
178
  maxAvailableWidthPercent: responsivePercent.optional().describe('Maximum percentage of available horizontal space used by managed images.'),
161
- maxAvailableHeightPercent: responsivePercent.optional().describe('Maximum percentage of viewport height used by managed images.'),
162
- }).strict().describe('Optional defaults for managed image presentation.');
179
+ maxAvailableHeightPercent: responsivePercent.optional().describe('Maximum percentage of viewport height used by centered-fit images. This setting is unavailable with prose-aligned presentation.'),
180
+ }).strict().describe('Optional defaults for placing managed images inside the page content area. Persistent navigation remains outside this area.');
163
181
  const themeCardList = z.object({
164
182
  width: cardListWidth,
165
183
  }).strict().describe('Site-wide card-list defaults.');
@@ -171,7 +189,6 @@ const themeBlocks = z.object({
171
189
  ).describe('Optional site-wide defaults for structured Norna content blocks.');
172
190
  const configNavigation = z.object({
173
191
  mode: navigationMode.optional().describe('Navigation model. Omit this to let Norna choose from the site structure.'),
174
- sectionTracking: z.boolean().optional().default(false).describe('As the reader scrolls in tree navigation, mark the last H2 or H3 that has reached the reading area below the sticky header. The URL and keyboard focus do not change.'),
175
192
  }).strict().describe('Site-wide navigation behavior.');
176
193
  const themeSections = z.object({
177
194
  backgroundPattern: backgroundPattern.optional().describe('Section background pattern. Alternating and accented create full-width bands with sections or top navigation; tree navigation requires uniform.'),
@@ -185,8 +202,17 @@ const pageThemeSections = z.object({
185
202
  const pageNavigation = z.object({
186
203
  listed: z.boolean().optional().default(true).describe('List this page in site navigation. The page remains public when false.'),
187
204
  }).strict();
205
+ const pageAlias = z.string().regex(
206
+ /^\/(?:[a-z0-9]+(?:-[a-z0-9]+)*\/)+$/,
207
+ 'Use an old site-relative Norna page URL such as "/guides/install/". Start and end with / and use lowercase letters, numbers, and single hyphens.',
208
+ ).describe('Previous site-relative URL that permanently identifies this page.');
188
209
  const pageMetadata = z.object({
189
210
  description: z.string().min(1).optional().describe('Optional page-specific meta description.'),
211
+ aliases: z.array(pageAlias)
212
+ .min(1)
213
+ .refine((values) => new Set(values).size === values.length, 'Page aliases must be unique within the page.')
214
+ .optional()
215
+ .describe('Previous site-relative URLs that permanently identify this page. The current page is always the target.'),
190
216
  }).strict().describe('Optional metadata for this homepage or additional page. The Markdown H1 supplies the page title.');
191
217
  const sitewideLogo = z.object({
192
218
  height: visualCssLength.optional().describe('Displayed logo height on wider screens. Omit it to use 2.6rem; narrow screens cap the height at 2.15rem. Width follows the intrinsic aspect ratio.'),
@@ -211,7 +237,9 @@ const sitewideFooter = z.object({
211
237
  const configShape = {
212
238
  url: z.string().url().describe('Absolute public URL for the built site.'),
213
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(),
214
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.'),
215
243
  scrollBehavior: z.enum(['instant', 'smooth']).optional().default('instant').describe('Use instant anchors by default or the browser\'s native smooth scrolling.'),
216
244
  };
217
245
 
@@ -222,7 +250,7 @@ const siteShape = {
222
250
 
223
251
  const themeVisualShape = {
224
252
  preset: themePreset.optional().describe('Complete visual starting point. Add only the overrides the site actually needs.'),
225
- colorMode: themeColorMode.optional(),
253
+ appearance: themeAppearance.optional(),
226
254
  readerControls: readerControls.optional(),
227
255
  corners: cornerTreatment.optional().describe('Site-wide corner treatment. Omit this to use the selected preset.'),
228
256
  layout: themeLayout.optional(),
@@ -33,6 +33,13 @@ const schemaProperty = (schema, propertyPath) => schemaProperties(schema, proper
33
33
  const addHelp = (schema, propertyPath, paragraphs, examples = []) => {
34
34
  for (const property of schemaProperties(schema, propertyPath)) {
35
35
  property.markdownDescription = paragraphs.join('\n\n');
36
+ const reference = paragraphs.findLast((paragraph) => /^\[[^\]]+\]\(https:\/\//.test(paragraph));
37
+ if (reference && Array.isArray(property.oneOf)) {
38
+ for (const candidate of property.oneOf) {
39
+ if (!Object.hasOwn(candidate, 'const') || !candidate.description) continue;
40
+ candidate.markdownDescription = [candidate.description, reference].join('\n\n');
41
+ }
42
+ }
36
43
  if (examples.length > 0) property.examples = examples;
37
44
  }
38
45
  };
@@ -152,11 +159,26 @@ const addConfigHelp = (jsonSchema) => {
152
159
  'Sets the page language and selects Norna\'s built-in English or Swedish interface text. The default is `en`.',
153
160
  documentationLink('Language reference', 'configuration.md', 'language'),
154
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/']);
155
172
  addHelp(jsonSchema, 'navigation', [
156
173
  yamlExample('navigation:\n mode: automatic'),
157
- 'Sets one navigation model for the complete site. `automatic` selects from the page hierarchy.',
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.',
158
175
  documentationLink('Navigation reference', 'pages.md', 'navigation'),
159
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]);
160
182
  addFieldHelp(
161
183
  jsonSchema,
162
184
  'navigation.mode',
@@ -165,14 +187,6 @@ const addConfigHelp = (jsonSchema) => {
165
187
  'navigation',
166
188
  ['automatic', 'sections', 'top', 'tree'],
167
189
  );
168
- addFieldHelp(
169
- jsonSchema,
170
- 'navigation.sectionTracking',
171
- 'navigation:\n sectionTracking: true',
172
- 'configuration.md',
173
- 'navigationsectiontracking',
174
- [true, false],
175
- );
176
190
  addHelp(jsonSchema, 'scrollBehavior', [
177
191
  yamlExample('scrollBehavior: smooth'),
178
192
  'Controls same-page anchor movement. `instant` is the default; `smooth` uses the browser\'s native smooth scrolling.',
@@ -202,8 +216,8 @@ const addThemeHelp = (jsonSchema) => {
202
216
  documentationLink('Layout reference', 'theme.md', 'layout'),
203
217
  ]);
204
218
  addHelp(jsonSchema, 'images', [
205
- yamlExample('images:\n width: 900px\n maxAvailableHeightPercent: 74'),
206
- 'Overrides the available width and height used by Norna-managed images.',
219
+ yamlExample('images:\n presentation: prose-aligned\n width: 900px'),
220
+ 'Overrides how Norna-managed image stacks and carousels align inside the page content area and how much space they may use. Persistent navigation remains outside this area.',
207
221
  documentationLink('Image sizing reference', 'theme.md', 'image-sizing'),
208
222
  ]);
209
223
  addHelp(jsonSchema, 'blocks', [
@@ -213,7 +227,7 @@ const addThemeHelp = (jsonSchema) => {
213
227
  ]);
214
228
  addHelp(jsonSchema, 'blocks.cardList', [
215
229
  yamlExample('blocks:\n cardList:\n width: text'),
216
- '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.',
217
231
  documentationLink('Content block defaults', 'theme.md', 'content-block-defaults'),
218
232
  ]);
219
233
  addFieldHelp(
@@ -242,29 +256,31 @@ const addThemeHelp = (jsonSchema) => {
242
256
  ]);
243
257
  addHelp(jsonSchema, 'palette', [
244
258
  yamlExample('palette: warm-paper'),
245
- 'Chooses the site color character. Every palette provides coordinated light and dark variants.',
246
- documentationLink('Palette and color mode', 'theme.md', 'palette-and-color-mode'),
259
+ 'Chooses the site color character. Every palette provides coordinated light and dark variants without changing the initial Appearance.',
260
+ documentationLink('Palette and appearance', 'theme.md', 'palette-and-appearance'),
247
261
  ], presentationPaletteNames);
248
- addHelp(jsonSchema, 'colorMode', [
249
- yamlExample('colorMode:\n default: system'),
250
- 'Controls the initial light or dark appearance. Enable the reader-facing choice under readerControls.',
251
- documentationLink('Color mode', 'theme.md', 'color-mode'),
262
+ addHelp(jsonSchema, 'appearance', [
263
+ yamlExample('appearance:\n default: system'),
264
+ 'Selects the initial Appearance. Omit this setting to follow the visitor\'s system preference. Enable the reader-facing choice under readerControls.',
265
+ documentationLink('Appearance', 'theme.md', 'appearance'),
252
266
  ]);
253
- addFieldHelp(jsonSchema, 'colorMode.default', 'colorMode:\n default: system', 'theme.md', 'color-mode', ['system', 'light', 'dark']);
254
- addSnippets(jsonSchema, 'colorMode', [schemaSnippet({
255
- label: 'Set the initial color mode',
256
- body: 'colorMode:\n default: ${1:system}',
267
+ addFieldHelp(jsonSchema, 'appearance.default', 'appearance:\n default: system', 'theme.md', 'appearance', ['system', 'light', 'dark']);
268
+ addSnippets(jsonSchema, 'appearance', [schemaSnippet({
269
+ label: 'Set the initial appearance',
270
+ body: 'appearance:\n default: ${1:system}',
257
271
  description: 'Follow the system color preference or choose a fixed initial appearance.',
258
272
  file: 'theme.md',
259
- anchor: 'color-mode',
273
+ anchor: 'appearance',
260
274
  })]);
261
275
  addHelp(jsonSchema, 'readerControls', [
262
- yamlExample('readerControls:\n colorMode: true\n focusReading: true'),
263
- 'Adds optional color-mode and focus-reading choices to the site-wide Display panel. Reading width is always available, and tree navigation always provides Focus reading.',
276
+ yamlExample('readerControls:\n appearance: true\n focusReading: true'),
277
+ 'Choose which optional controls readers can use in the site-wide Display panel.',
278
+ '`appearance: true` lets readers choose System, Light, or Dark. `focusReading: true` lets them hide navigation, breadcrumbs, and the footer while reading.',
279
+ 'Reading width is always included. Sites with tree navigation always include Focus reading, even when it is not enabled here.',
264
280
  documentationLink('Reader Display controls', 'theme.md', 'reader-display-controls'),
265
281
  ]);
266
282
  for (const [propertyPath, example] of [
267
- ['readerControls.colorMode', 'readerControls:\n colorMode: true'],
283
+ ['readerControls.appearance', 'readerControls:\n appearance: true'],
268
284
  ['readerControls.focusReading', 'readerControls:\n focusReading: true'],
269
285
  ]) {
270
286
  addFieldHelp(
@@ -277,8 +293,8 @@ const addThemeHelp = (jsonSchema) => {
277
293
  }
278
294
  addSnippets(jsonSchema, 'readerControls', [schemaSnippet({
279
295
  label: 'Configure the Display panel',
280
- body: 'readerControls:\n colorMode: ${1:true}\n focusReading: ${2:true}',
281
- description: 'Offer optional color-mode and focus-reading choices alongside the universal reading-width control. Tree navigation always provides Focus reading.',
296
+ body: 'readerControls:\n appearance: ${1:true}\n focusReading: ${2:true}',
297
+ description: 'Show optional Appearance and Focus reading controls. Reading width is already included, and tree navigation always includes Focus reading.',
282
298
  file: 'theme.md',
283
299
  anchor: 'reader-display-controls',
284
300
  })]);
@@ -352,6 +368,7 @@ const addThemeHelp = (jsonSchema) => {
352
368
  })]);
353
369
 
354
370
  const imageFields = [
371
+ ['images.presentation', 'images:\n presentation: prose-aligned'],
355
372
  ['images.width', 'images:\n width: 900px'],
356
373
  ['images.maxAvailableWidthPercent', 'images:\n maxAvailableWidthPercent:\n desktop: 100\n mobile: 100'],
357
374
  ['images.maxAvailableWidthPercent.desktop', 'images:\n maxAvailableWidthPercent:\n desktop: 100'],
@@ -464,11 +481,12 @@ const addPageThemeHelp = (jsonSchema) => {
464
481
  addFieldHelp(jsonSchema, 'layout.textWidth', 'layout:\n textWidth: narrow', 'theme.md', 'page-themes', ['narrow', 'normal', 'wide']);
465
482
  addFieldHelp(jsonSchema, 'layout.contentSpacing', 'layout:\n contentSpacing: compact', 'theme.md', 'page-themes', ['compact', 'normal', 'spacious']);
466
483
  addHelp(jsonSchema, 'images', [
467
- yamlExample('images:\n width: 900px'),
468
- 'Adjusts managed-image sizing for pages below this directory.',
484
+ yamlExample('images:\n presentation: prose-aligned\n width: 900px'),
485
+ 'Adjusts managed-image presentation and sizing inside the content area for pages below this directory. Persistent navigation remains outside this area.',
469
486
  documentationLink('Image sizing reference', 'theme.md', 'image-sizing'),
470
487
  ]);
471
488
  const imageFields = [
489
+ ['images.presentation', 'images:\n presentation: prose-aligned'],
472
490
  ['images.width', 'images:\n width: 900px'],
473
491
  ['images.maxAvailableWidthPercent', 'images:\n maxAvailableWidthPercent:\n desktop: 100\n mobile: 100'],
474
492
  ['images.maxAvailableWidthPercent.desktop', 'images:\n maxAvailableWidthPercent:\n desktop: 100'],
@@ -563,8 +581,8 @@ const addContentHelp = (jsonSchema) => {
563
581
  documentationLink('Page title and frontmatter reference', 'content.md', 'page-title-and-frontmatter'),
564
582
  ].join('\n\n');
565
583
  addHelp(jsonSchema, 'page', [
566
- yamlExample('page:\n description: About this project.'),
567
- 'Contains optional metadata for the current homepage or additional page. The Markdown H1 supplies its title.',
584
+ yamlExample('page:\n description: About this project.\n aliases:\n - /old-about/'),
585
+ 'Contains optional metadata for the current homepage or additional page. The Markdown H1 supplies its title. An alias records an old URL for this same page.',
568
586
  documentationLink('Page title and frontmatter reference', 'content.md', 'page-title-and-frontmatter'),
569
587
  ]);
570
588
  addHelp(jsonSchema, 'page.description', [
@@ -572,6 +590,16 @@ const addContentHelp = (jsonSchema) => {
572
590
  'An optional page-specific description rendered as `<meta name="description">`. It is not visible in page content.',
573
591
  documentationLink('Page title and frontmatter reference', 'content.md', 'page-title-and-frontmatter'),
574
592
  ], ['About this project.']);
593
+ addHelp(jsonSchema, 'page.aliases', [
594
+ yamlExample('page:\n aliases:\n - /old-about/'),
595
+ 'Lists previous site-relative URLs that permanently identify this page. The page containing the list is always the target. Paths omit the configured base path and are excluded from the sitemap.',
596
+ documentationLink('Preserve old page URLs', 'pages.md', 'preserve-old-page-urls'),
597
+ ], [['/old-about/']]);
598
+ addHelp(jsonSchema, 'page.aliases.[]', [
599
+ yamlExample('page:\n aliases:\n - /old-about/'),
600
+ 'Use a previous Norna page URL. Start and end with `/`; use lowercase ASCII letters, numbers, and single hyphens; do not include the configured base path, a query string, or a fragment.',
601
+ documentationLink('Alias path rules', 'pages.md', 'preserve-old-page-urls'),
602
+ ], ['/old-about/']);
575
603
  addHelp(jsonSchema, 'navigation', [
576
604
  yamlExample('navigation:\n listed: false'),
577
605
  'Optionally excludes an additional page from site navigation without removing its public URL.',
@@ -23,10 +23,10 @@ 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 a shallow multi-page site, and tree navigation for deeper content.'),
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
- tree: option('Tree', 'Use a hierarchical site-wide navigation tree.'),
29
+ tree: option('Tree', 'Use a left page rail and a separate right H2/H3 contents rail where the page has enough headings.'),
30
30
  }),
31
31
  definition(['en', 'sv'], {
32
32
  en: option('English', 'Use Norna\'s built-in English interface text.'),
@@ -54,6 +54,10 @@ export const schemaValueDefinitions = Object.freeze([
54
54
  normal: option('Normal', 'Limit the complete card list to at most 56rem.'),
55
55
  wide: option('Wide', 'Allow the complete card list to use the available page-layout width.'),
56
56
  }),
57
+ definition(['prose-aligned', 'centered-fit'], {
58
+ 'prose-aligned': option('Prose-aligned', 'Start managed images and captions at the body-text edge and size them from the page content area.'),
59
+ 'centered-fit': option('Centered fit', 'Center managed images and captions in the page media area and constrain them by available width and viewport height.'),
60
+ }),
57
61
  definition([400, 500, 600, 700], {
58
62
  400: option('Regular', 'Use regular font weight.'),
59
63
  500: option('Medium', 'Use medium font weight.'),
@@ -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
+ };
@@ -1,11 +1,15 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import {
4
+ schemaTopLevelKeys,
5
+ siteSchema,
6
+ } from './schema-definitions.mjs';
3
7
  import {
4
8
  siteContentLabel,
5
9
  sitewideContentLabel,
6
10
  } from './site-paths.mjs';
7
- import { schemaTopLevelKeys } from './schema-definitions.mjs';
8
11
  import { getIndentInfo, validateYamlIndentation } from './yaml-indentation.mjs';
12
+ import { parseYamlConfig } from './yaml-config.mjs';
9
13
 
10
14
  export { getContentFiles } from './site-structure.mjs';
11
15
 
@@ -24,11 +28,13 @@ const knownPageThemeTopLevelFrontmatterKeys = new Set(
24
28
  const knownSitewideTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.sitewide);
25
29
  const knownNestedFrontmatterKeys = new Set([
26
30
  'align',
31
+ 'aliases',
27
32
  'alt',
33
+ 'baseUrl',
28
34
  'body',
29
35
  'blockGap',
30
36
  'caption',
31
- 'colorMode',
37
+ 'appearance',
32
38
  'carousel',
33
39
  'contentSpacing',
34
40
  'desktop',
@@ -104,7 +110,10 @@ export const splitSiteFile = (source, label = siteContentLabel) => {
104
110
  };
105
111
  };
106
112
 
107
- export const readSiteFile = async (sitePath, label = siteContentLabel) => splitSiteFile(await readFile(sitePath, 'utf8'), label);
113
+ export const readSiteFile = async (sitePath, label = siteContentLabel) => {
114
+ const source = await readFile(sitePath, 'utf8');
115
+ return { ...splitSiteFile(source, label), source };
116
+ };
108
117
 
109
118
  export const validateFrontmatterIndentation = (frontmatter, addIssue) => {
110
119
  validateYamlIndentation(frontmatter, (issue) => addIssue({
@@ -144,7 +153,7 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
144
153
  } else if (fileKind === 'content' && key === 'description') {
145
154
  fix = 'Indent "description:" under "page:". The optional meta description belongs in the page object.';
146
155
  } else if (fileKind === 'content' && key === 'images') {
147
- 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.';
148
157
  } else if (fileKind === 'content' && knownThemeTopLevelFrontmatterKeys.has(key)) {
149
158
  fix = `Move "${key}:" to theme.yaml. Visual settings do not belong in content frontmatter.`;
150
159
  } else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'navigation') {
@@ -157,7 +166,12 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
157
166
  fix = fileKind === 'page theme'
158
167
  ? 'Remove "shape:" from this page theme. Set "corners: square" or "corners: rounded" in the root theme.yaml when an override is needed.'
159
168
  : 'Replace "shape:" with "corners:". Use "square" or replace the old "soft" value with "rounded".';
160
- } else if (fileKind === 'page theme' && ['preset', 'corners', 'palette', 'typography'].includes(key)) {
169
+ } else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'colorMode') {
170
+ message = `Frontmatter line ${lineNumber}: Theme setting "colorMode" was replaced by "appearance".`;
171
+ fix = fileKind === 'page theme'
172
+ ? 'Remove "colorMode:" from this page theme. Set "appearance:" in the root theme.yaml when an override is needed.'
173
+ : 'Replace "colorMode:" with "appearance:".';
174
+ } else if (fileKind === 'page theme' && ['preset', 'appearance', 'readerControls', 'corners', 'palette', 'typography'].includes(key)) {
161
175
  message = `Frontmatter line ${lineNumber}: page themes may not define site-wide visual identity through "${key}".`;
162
176
  fix = `Move "${key}:" to the root theme.yaml. Page themes may set only layout.textWidth, layout.contentSpacing, images, and sections.backgroundPattern.`;
163
177
  } else if ((fileKind === 'theme' || fileKind === 'page theme') && ['logo', 'site'].includes(key)) {
@@ -186,6 +200,15 @@ export const validateContentFrontmatterStructure = (frontmatter, addIssue) =>
186
200
  fileKind: 'content',
187
201
  });
188
202
 
203
+ export const parseContentFrontmatter = (frontmatterBody, label = siteContentLabel) => parseYamlConfig(
204
+ frontmatterBody,
205
+ `${label} frontmatter`,
206
+ {
207
+ schema: siteSchema,
208
+ validateStructure: validateContentFrontmatterStructure,
209
+ },
210
+ );
211
+
189
212
  export const validateConfigYamlStructure = (frontmatter, addIssue) =>
190
213
  validateFrontmatterStructure(frontmatter, addIssue, {
191
214
  knownTopLevelFrontmatterKeys: knownConfigTopLevelFrontmatterKeys,