@janga/norna 0.7.22 → 0.7.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/astro.config.mjs +3 -0
  2. package/bin/norna-cli.mjs +2 -2
  3. package/package.json +17 -3
  4. package/schemas/category.schema.json +2 -2
  5. package/schemas/config.schema.json +22 -24
  6. package/schemas/content-frontmatter.schema.json +25 -5
  7. package/schemas/page-theme.schema.json +53 -25
  8. package/schemas/sitewide-content.schema.json +19 -18
  9. package/schemas/theme.schema.json +383 -250
  10. package/scripts/check-config.mjs +24 -4
  11. package/scripts/deploy-site.mjs +0 -1
  12. package/scripts/dev-local.mjs +175 -112
  13. package/scripts/lib/content-sync-apply.mjs +34 -0
  14. package/scripts/lib/content-sync-plan.mjs +179 -0
  15. package/scripts/lib/editor-language-service.mjs +128 -0
  16. package/scripts/lib/heading-ids.mjs +1 -1
  17. package/scripts/lib/image-presentation.mjs +2 -0
  18. package/scripts/lib/markdown-links.mjs +182 -0
  19. package/scripts/lib/navigation-model.mjs +25 -14
  20. package/scripts/lib/norna-markdown-blocks.mjs +1 -0
  21. package/scripts/lib/page-aliases.mjs +164 -0
  22. package/scripts/lib/page-markdown.mjs +56 -6
  23. package/scripts/lib/presentation.mjs +19 -37
  24. package/scripts/lib/project-config.mjs +50 -29
  25. package/scripts/lib/schema-definitions.mjs +24 -13
  26. package/scripts/lib/schema-editor-metadata.mjs +43 -30
  27. package/scripts/lib/schema-value-definitions.mjs +6 -2
  28. package/scripts/lib/site-content.mjs +26 -4
  29. package/scripts/lib/site-link-graph.mjs +326 -0
  30. package/scripts/lib/site-page-urls.mjs +10 -0
  31. package/scripts/lib/sitemap.mjs +34 -0
  32. package/scripts/lib/theme-presets.mjs +51 -23
  33. package/scripts/lib/theme-profiles.mjs +7 -5
  34. package/scripts/lib/yaml-config.mjs +6 -2
  35. package/scripts/sync-content-sections.mjs +116 -213
  36. package/scripts/sync-site-public.mjs +23 -2
  37. package/src/components/CodeBlockCopyScript.astro +92 -0
  38. package/src/components/DisplaySettings.astro +4 -4
  39. package/src/components/ImageCarousel.astro +7 -3
  40. package/src/components/NavigationPageTree.astro +44 -60
  41. package/src/components/PageAliasRedirect.astro +49 -0
  42. package/src/components/PageContentsNavigation.astro +21 -0
  43. package/src/components/SectionNavigationScript.astro +62 -6
  44. package/src/components/SiteNavigation.astro +15 -3
  45. package/src/components/SitePage.astro +42 -2
  46. package/src/components/SiteSection.astro +5 -0
  47. package/src/components/SiteTreeNavigation.astro +0 -1
  48. package/src/content.config.ts +5 -2
  49. package/src/layouts/BaseLayout.astro +14 -8
  50. package/src/lib/readerPreferencesScript.mjs +2 -2
  51. package/src/lib/sitePages.ts +3 -2
  52. package/src/pages/[...slug].astro +35 -8
  53. package/src/styles/content.css +494 -0
  54. package/src/styles/foundations.css +284 -0
  55. package/src/styles/global.css +6 -2510
  56. package/src/styles/media.css +523 -0
  57. package/src/styles/navigation.css +495 -0
  58. package/src/styles/page-layout.css +437 -0
  59. package/src/styles/responsive.css +525 -0
  60. package/starters/basic/README.md +1 -1
@@ -1,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';
@@ -17,25 +18,26 @@ const headingWeight = z.union([z.literal(400), z.literal(500), z.literal(600), z
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
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 themeColorMode = z.object({
21
- default: z.enum(['system', 'light', 'dark']).optional().describe('Initial color mode. System follows the visitor\'s operating-system preference.'),
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
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.');
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
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 navigationMode = z.enum(navigationModeNames).describe('Site-wide navigation model. Automatic selects from the site structure.');
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.');
39
41
  const createLineHeight = (minimum, role) => z.number()
40
42
  .min(minimum, `Use a unitless ${role} line height of at least ${minimum}.`)
41
43
  .max(3, `Use a unitless ${role} line height of at most 3.`)
@@ -156,10 +158,11 @@ const pageThemeLayout = z.object({
156
158
  'Specify contentSpacing, textWidth, or both.',
157
159
  ).describe('Page-local layout settings inherited by descendant pages.');
158
160
  const themeImages = z.object({
161
+ presentation: imagePresentation.optional().describe('Managed-image presentation method. Omit this to keep the selected preset or inherited page setting.'),
159
162
  width: visualCssLength.optional().describe('Maximum managed-image width. Omit this to keep the selected preset.'),
160
163
  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.');
164
+ maxAvailableHeightPercent: responsivePercent.optional().describe('Maximum percentage of viewport height used by centered-fit images. This setting is unavailable with prose-aligned presentation.'),
165
+ }).strict().describe('Optional defaults for placing managed images inside the page content area. Persistent navigation remains outside this area.');
163
166
  const themeCardList = z.object({
164
167
  width: cardListWidth,
165
168
  }).strict().describe('Site-wide card-list defaults.');
@@ -171,7 +174,6 @@ const themeBlocks = z.object({
171
174
  ).describe('Optional site-wide defaults for structured Norna content blocks.');
172
175
  const configNavigation = z.object({
173
176
  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
177
  }).strict().describe('Site-wide navigation behavior.');
176
178
  const themeSections = z.object({
177
179
  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 +187,17 @@ const pageThemeSections = z.object({
185
187
  const pageNavigation = z.object({
186
188
  listed: z.boolean().optional().default(true).describe('List this page in site navigation. The page remains public when false.'),
187
189
  }).strict();
190
+ const pageAlias = z.string().regex(
191
+ /^\/(?:[a-z0-9]+(?:-[a-z0-9]+)*\/)+$/,
192
+ 'Use an old site-relative Norna page URL such as "/guides/install/". Start and end with / and use lowercase letters, numbers, and single hyphens.',
193
+ ).describe('Previous site-relative URL that permanently identifies this page.');
188
194
  const pageMetadata = z.object({
189
195
  description: z.string().min(1).optional().describe('Optional page-specific meta description.'),
196
+ aliases: z.array(pageAlias)
197
+ .min(1)
198
+ .refine((values) => new Set(values).size === values.length, 'Page aliases must be unique within the page.')
199
+ .optional()
200
+ .describe('Previous site-relative URLs that permanently identify this page. The current page is always the target.'),
190
201
  }).strict().describe('Optional metadata for this homepage or additional page. The Markdown H1 supplies the page title.');
191
202
  const sitewideLogo = z.object({
192
203
  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.'),
@@ -222,7 +233,7 @@ const siteShape = {
222
233
 
223
234
  const themeVisualShape = {
224
235
  preset: themePreset.optional().describe('Complete visual starting point. Add only the overrides the site actually needs.'),
225
- colorMode: themeColorMode.optional(),
236
+ appearance: themeAppearance.optional(),
226
237
  readerControls: readerControls.optional(),
227
238
  corners: cornerTreatment.optional().describe('Site-wide corner treatment. Omit this to use the selected preset.'),
228
239
  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
  };
@@ -154,7 +161,7 @@ const addConfigHelp = (jsonSchema) => {
154
161
  ], ['en', 'sv', 'en-GB', 'sv-SE']);
155
162
  addHelp(jsonSchema, 'navigation', [
156
163
  yamlExample('navigation:\n mode: automatic'),
157
- 'Sets one navigation model for the complete site. `automatic` selects from the page hierarchy.',
164
+ 'Sets the site-wide navigation policy. `automatic` selects an effective mode for each page from its active top-level branch.',
158
165
  documentationLink('Navigation reference', 'pages.md', 'navigation'),
159
166
  ]);
160
167
  addFieldHelp(
@@ -165,14 +172,6 @@ const addConfigHelp = (jsonSchema) => {
165
172
  'navigation',
166
173
  ['automatic', 'sections', 'top', 'tree'],
167
174
  );
168
- addFieldHelp(
169
- jsonSchema,
170
- 'navigation.sectionTracking',
171
- 'navigation:\n sectionTracking: true',
172
- 'configuration.md',
173
- 'navigationsectiontracking',
174
- [true, false],
175
- );
176
175
  addHelp(jsonSchema, 'scrollBehavior', [
177
176
  yamlExample('scrollBehavior: smooth'),
178
177
  'Controls same-page anchor movement. `instant` is the default; `smooth` uses the browser\'s native smooth scrolling.',
@@ -202,8 +201,8 @@ const addThemeHelp = (jsonSchema) => {
202
201
  documentationLink('Layout reference', 'theme.md', 'layout'),
203
202
  ]);
204
203
  addHelp(jsonSchema, 'images', [
205
- yamlExample('images:\n width: 900px\n maxAvailableHeightPercent: 74'),
206
- 'Overrides the available width and height used by Norna-managed images.',
204
+ yamlExample('images:\n presentation: prose-aligned\n width: 900px'),
205
+ '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
206
  documentationLink('Image sizing reference', 'theme.md', 'image-sizing'),
208
207
  ]);
209
208
  addHelp(jsonSchema, 'blocks', [
@@ -243,28 +242,30 @@ const addThemeHelp = (jsonSchema) => {
243
242
  addHelp(jsonSchema, 'palette', [
244
243
  yamlExample('palette: warm-paper'),
245
244
  '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'),
245
+ documentationLink('Palette and appearance', 'theme.md', 'palette-and-appearance'),
247
246
  ], 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'),
247
+ addHelp(jsonSchema, 'appearance', [
248
+ yamlExample('appearance:\n default: system'),
249
+ 'Selects the initial appearance. Enable the reader-facing choice under readerControls.',
250
+ documentationLink('Appearance', 'theme.md', 'appearance'),
252
251
  ]);
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}',
252
+ addFieldHelp(jsonSchema, 'appearance.default', 'appearance:\n default: system', 'theme.md', 'appearance', ['system', 'light', 'dark']);
253
+ addSnippets(jsonSchema, 'appearance', [schemaSnippet({
254
+ label: 'Set the initial appearance',
255
+ body: 'appearance:\n default: ${1:system}',
257
256
  description: 'Follow the system color preference or choose a fixed initial appearance.',
258
257
  file: 'theme.md',
259
- anchor: 'color-mode',
258
+ anchor: 'appearance',
260
259
  })]);
261
260
  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.',
261
+ yamlExample('readerControls:\n appearance: true\n focusReading: true'),
262
+ 'Choose which optional controls readers can use in the site-wide Display panel.',
263
+ '`appearance: true` lets readers choose System, Light, or Dark. `focusReading: true` lets them hide navigation, breadcrumbs, and the footer while reading.',
264
+ 'Reading width is always included. Sites with tree navigation always include Focus reading, even when it is not enabled here.',
264
265
  documentationLink('Reader Display controls', 'theme.md', 'reader-display-controls'),
265
266
  ]);
266
267
  for (const [propertyPath, example] of [
267
- ['readerControls.colorMode', 'readerControls:\n colorMode: true'],
268
+ ['readerControls.appearance', 'readerControls:\n appearance: true'],
268
269
  ['readerControls.focusReading', 'readerControls:\n focusReading: true'],
269
270
  ]) {
270
271
  addFieldHelp(
@@ -277,8 +278,8 @@ const addThemeHelp = (jsonSchema) => {
277
278
  }
278
279
  addSnippets(jsonSchema, 'readerControls', [schemaSnippet({
279
280
  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.',
281
+ body: 'readerControls:\n appearance: ${1:true}\n focusReading: ${2:true}',
282
+ description: 'Show optional Appearance and Focus reading controls. Reading width is already included, and tree navigation always includes Focus reading.',
282
283
  file: 'theme.md',
283
284
  anchor: 'reader-display-controls',
284
285
  })]);
@@ -352,6 +353,7 @@ const addThemeHelp = (jsonSchema) => {
352
353
  })]);
353
354
 
354
355
  const imageFields = [
356
+ ['images.presentation', 'images:\n presentation: prose-aligned'],
355
357
  ['images.width', 'images:\n width: 900px'],
356
358
  ['images.maxAvailableWidthPercent', 'images:\n maxAvailableWidthPercent:\n desktop: 100\n mobile: 100'],
357
359
  ['images.maxAvailableWidthPercent.desktop', 'images:\n maxAvailableWidthPercent:\n desktop: 100'],
@@ -464,11 +466,12 @@ const addPageThemeHelp = (jsonSchema) => {
464
466
  addFieldHelp(jsonSchema, 'layout.textWidth', 'layout:\n textWidth: narrow', 'theme.md', 'page-themes', ['narrow', 'normal', 'wide']);
465
467
  addFieldHelp(jsonSchema, 'layout.contentSpacing', 'layout:\n contentSpacing: compact', 'theme.md', 'page-themes', ['compact', 'normal', 'spacious']);
466
468
  addHelp(jsonSchema, 'images', [
467
- yamlExample('images:\n width: 900px'),
468
- 'Adjusts managed-image sizing for pages below this directory.',
469
+ yamlExample('images:\n presentation: prose-aligned\n width: 900px'),
470
+ 'Adjusts managed-image presentation and sizing inside the content area for pages below this directory. Persistent navigation remains outside this area.',
469
471
  documentationLink('Image sizing reference', 'theme.md', 'image-sizing'),
470
472
  ]);
471
473
  const imageFields = [
474
+ ['images.presentation', 'images:\n presentation: prose-aligned'],
472
475
  ['images.width', 'images:\n width: 900px'],
473
476
  ['images.maxAvailableWidthPercent', 'images:\n maxAvailableWidthPercent:\n desktop: 100\n mobile: 100'],
474
477
  ['images.maxAvailableWidthPercent.desktop', 'images:\n maxAvailableWidthPercent:\n desktop: 100'],
@@ -563,8 +566,8 @@ const addContentHelp = (jsonSchema) => {
563
566
  documentationLink('Page title and frontmatter reference', 'content.md', 'page-title-and-frontmatter'),
564
567
  ].join('\n\n');
565
568
  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.',
569
+ yamlExample('page:\n description: About this project.\n aliases:\n - /old-about/'),
570
+ '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
571
  documentationLink('Page title and frontmatter reference', 'content.md', 'page-title-and-frontmatter'),
569
572
  ]);
570
573
  addHelp(jsonSchema, 'page.description', [
@@ -572,6 +575,16 @@ const addContentHelp = (jsonSchema) => {
572
575
  'An optional page-specific description rendered as `<meta name="description">`. It is not visible in page content.',
573
576
  documentationLink('Page title and frontmatter reference', 'content.md', 'page-title-and-frontmatter'),
574
577
  ], ['About this project.']);
578
+ addHelp(jsonSchema, 'page.aliases', [
579
+ yamlExample('page:\n aliases:\n - /old-about/'),
580
+ '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.',
581
+ documentationLink('Preserve old page URLs', 'pages.md', 'preserve-old-page-urls'),
582
+ ], [['/old-about/']]);
583
+ addHelp(jsonSchema, 'page.aliases.[]', [
584
+ yamlExample('page:\n aliases:\n - /old-about/'),
585
+ '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.',
586
+ documentationLink('Alias path rules', 'pages.md', 'preserve-old-page-urls'),
587
+ ], ['/old-about/']);
575
588
  addHelp(jsonSchema, 'navigation', [
576
589
  yamlExample('navigation:\n listed: false'),
577
590
  '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 Home and independent top-level pages, and tree navigation inside branches with listed children 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.'),
@@ -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,12 @@ 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',
28
33
  'body',
29
34
  'blockGap',
30
35
  'caption',
31
- 'colorMode',
36
+ 'appearance',
32
37
  'carousel',
33
38
  'contentSpacing',
34
39
  'desktop',
@@ -104,7 +109,10 @@ export const splitSiteFile = (source, label = siteContentLabel) => {
104
109
  };
105
110
  };
106
111
 
107
- export const readSiteFile = async (sitePath, label = siteContentLabel) => splitSiteFile(await readFile(sitePath, 'utf8'), label);
112
+ export const readSiteFile = async (sitePath, label = siteContentLabel) => {
113
+ const source = await readFile(sitePath, 'utf8');
114
+ return { ...splitSiteFile(source, label), source };
115
+ };
108
116
 
109
117
  export const validateFrontmatterIndentation = (frontmatter, addIssue) => {
110
118
  validateYamlIndentation(frontmatter, (issue) => addIssue({
@@ -157,7 +165,12 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
157
165
  fix = fileKind === 'page theme'
158
166
  ? 'Remove "shape:" from this page theme. Set "corners: square" or "corners: rounded" in the root theme.yaml when an override is needed.'
159
167
  : '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)) {
168
+ } else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'colorMode') {
169
+ message = `Frontmatter line ${lineNumber}: Theme setting "colorMode" was replaced by "appearance".`;
170
+ fix = fileKind === 'page theme'
171
+ ? 'Remove "colorMode:" from this page theme. Set "appearance:" in the root theme.yaml when an override is needed.'
172
+ : 'Replace "colorMode:" with "appearance:".';
173
+ } else if (fileKind === 'page theme' && ['preset', 'appearance', 'readerControls', 'corners', 'palette', 'typography'].includes(key)) {
161
174
  message = `Frontmatter line ${lineNumber}: page themes may not define site-wide visual identity through "${key}".`;
162
175
  fix = `Move "${key}:" to the root theme.yaml. Page themes may set only layout.textWidth, layout.contentSpacing, images, and sections.backgroundPattern.`;
163
176
  } else if ((fileKind === 'theme' || fileKind === 'page theme') && ['logo', 'site'].includes(key)) {
@@ -186,6 +199,15 @@ export const validateContentFrontmatterStructure = (frontmatter, addIssue) =>
186
199
  fileKind: 'content',
187
200
  });
188
201
 
202
+ export const parseContentFrontmatter = (frontmatterBody, label = siteContentLabel) => parseYamlConfig(
203
+ frontmatterBody,
204
+ `${label} frontmatter`,
205
+ {
206
+ schema: siteSchema,
207
+ validateStructure: validateContentFrontmatterStructure,
208
+ },
209
+ );
210
+
189
211
  export const validateConfigYamlStructure = (frontmatter, addIssue) =>
190
212
  validateFrontmatterStructure(frontmatter, addIssue, {
191
213
  knownTopLevelFrontmatterKeys: knownConfigTopLevelFrontmatterKeys,