@janga/norna 0.7.27 → 0.7.28

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 (109) hide show
  1. package/README.md +37 -28
  2. package/astro.config.mjs +19 -1
  3. package/bin/norna-cli.mjs +5 -3
  4. package/package.json +9 -5
  5. package/schemas/card-list.schema.json +1 -1
  6. package/schemas/config.schema.json +66 -66
  7. package/schemas/content-frontmatter.schema.json +16 -7
  8. package/schemas/manifest.json +49 -3
  9. package/schemas/page-theme.schema.json +1585 -41
  10. package/schemas/root-theme.schema.json +1767 -0
  11. package/schemas/sitewide-content.schema.json +18 -18
  12. package/schemas/theme.schema.json +243 -243
  13. package/scripts/add-site-node.mjs +13 -161
  14. package/scripts/build-site.mjs +11 -6
  15. package/scripts/check-config.mjs +7 -23
  16. package/scripts/dev-local.mjs +17 -10
  17. package/scripts/export-theme-preset.mjs +2 -2
  18. package/scripts/generate-images.mjs +7 -6
  19. package/scripts/generate-schemas.mjs +9 -6
  20. package/scripts/lib/category-destinations.mjs +13 -12
  21. package/scripts/lib/documentation-links.mjs +1 -1
  22. package/scripts/lib/documentation-routes.json +67 -60
  23. package/scripts/lib/docusaurus-migration-audit.mjs +9 -6
  24. package/scripts/lib/editor-file-policy.mjs +18 -0
  25. package/scripts/lib/editor-language-service.mjs +53 -55
  26. package/scripts/lib/editor-page-addresses.mjs +55 -0
  27. package/scripts/lib/editor-page-placement.mjs +172 -0
  28. package/scripts/lib/editor-site-editing-tree.mjs +146 -0
  29. package/scripts/lib/editor-site-files.mjs +153 -0
  30. package/scripts/lib/editor-site-links.mjs +79 -0
  31. package/scripts/lib/editor-site-tree.mjs +151 -0
  32. package/scripts/lib/editor-source-files.mjs +87 -0
  33. package/scripts/lib/editor-theme-help.mjs +43 -0
  34. package/scripts/lib/locale-registry.mjs +1 -1
  35. package/scripts/lib/locales/en.mjs +1 -1
  36. package/scripts/lib/navigation-model.mjs +2 -44
  37. package/scripts/lib/navigation-review.mjs +14 -50
  38. package/scripts/lib/norna-markdown-blocks.mjs +1 -1
  39. package/scripts/lib/page-aliases.mjs +3 -1
  40. package/scripts/lib/page-model.mjs +17 -20
  41. package/scripts/lib/page-move-apply.mjs +117 -0
  42. package/scripts/lib/page-move-plan.mjs +27 -59
  43. package/scripts/lib/presentation.mjs +3 -3
  44. package/scripts/lib/project-config.mjs +7 -8
  45. package/scripts/lib/schema-definitions.mjs +11 -38
  46. package/scripts/lib/schema-editor-metadata.mjs +12 -59
  47. package/scripts/lib/schema-value-definitions.mjs +1 -1
  48. package/scripts/lib/site-content.mjs +7 -24
  49. package/scripts/lib/site-conventions.mjs +19 -1
  50. package/scripts/lib/site-navigation-tree.mjs +2 -7
  51. package/scripts/lib/site-node-create.mjs +169 -0
  52. package/scripts/lib/site-paths.mjs +22 -10
  53. package/scripts/lib/site-structure.mjs +72 -82
  54. package/scripts/lib/site-upgrade.mjs +30 -0
  55. package/scripts/lib/source-files.mjs +34 -0
  56. package/scripts/lib/theme-config.mjs +26 -68
  57. package/scripts/lib/theme-packages.mjs +47 -0
  58. package/scripts/lib/theme-presets.mjs +20 -53
  59. package/scripts/lib/typography.mjs +2 -2
  60. package/scripts/lib/yaml-config.mjs +1 -1
  61. package/scripts/lib/yaml-source-edit.mjs +94 -0
  62. package/scripts/move-site-page.mjs +3 -124
  63. package/scripts/show-typography.mjs +32 -17
  64. package/scripts/sync-content-sections.mjs +9 -7
  65. package/scripts/sync-site-public.mjs +1 -1
  66. package/scripts/upgrade-site.mjs +17 -0
  67. package/src/components/AreaNavigationMenu.astro +24 -0
  68. package/src/components/AreaNavigationPanel.astro +55 -0
  69. package/src/components/AreaNavigationSwitcher.astro +30 -0
  70. package/src/components/ImageStackEnhancement.astro +0 -7
  71. package/src/components/NavigationContinuity.astro +26 -0
  72. package/src/components/NavigationPageTree.astro +13 -4
  73. package/src/components/NavigationState.astro +11 -0
  74. package/src/components/NavigationTreeControls.astro +3 -2
  75. package/src/components/SearchPage.astro +9 -1
  76. package/src/components/SectionNavigationScript.astro +106 -26
  77. package/src/components/SiteNavigation.astro +45 -20
  78. package/src/components/SitePage.astro +37 -61
  79. package/src/components/SiteTreeNavigation.astro +38 -18
  80. package/src/components/TopPageMenu.astro +4 -3
  81. package/src/components/TreeNavigationScript.astro +50 -57
  82. package/src/content.config.ts +10 -8
  83. package/src/layouts/BaseLayout.astro +15 -39
  84. package/src/lib/areaNavigation.ts +42 -0
  85. package/src/lib/navigationContinuity.ts +212 -0
  86. package/src/lib/navigationFollowing.ts +28 -18
  87. package/src/lib/navigationState.mjs +88 -0
  88. package/src/lib/navigationStateScript.mjs +76 -0
  89. package/src/lib/navigationVisibility.ts +9 -0
  90. package/src/lib/pageThemes.ts +5 -42
  91. package/src/lib/sectionContent.ts +3 -2
  92. package/src/lib/siteNavigation.ts +6 -0
  93. package/src/lib/sitePages.ts +9 -6
  94. package/src/pages/404.astro +1 -1
  95. package/src/pages/[...slug].astro +5 -15
  96. package/src/pages/index.astro +1 -3
  97. package/src/styles/area-navigation.css +399 -0
  98. package/src/styles/content.css +2 -29
  99. package/src/styles/navigation-continuity.css +163 -0
  100. package/src/styles/page-layout.css +57 -68
  101. package/src/styles/responsive.css +0 -4
  102. package/starters/basic/README.md +9 -8
  103. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  104. package/schemas/category.schema.json +0 -20
  105. package/src/components/PageContentsNavigation.astro +0 -20
  106. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  107. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  108. /package/starters/basic/site/{config.yaml → site-config/settings.yaml} +0 -0
  109. /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
@@ -1,77 +1,35 @@
1
- import { readFile, readdir } from 'node:fs/promises';
1
+ import { readdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import {
4
- validatePageThemeYamlStructure,
5
- validateThemeYamlStructure,
6
- } from './site-content.mjs';
7
- import {
8
- sitePagesDir,
9
- sitePagesLabel,
10
- siteThemeLabel,
11
- siteThemePath,
12
- } from './site-paths.mjs';
3
+ import { getSiteSourcePaths } from './site-conventions.mjs';
4
+ import { siteDir, siteThemePath } from './site-paths.mjs';
5
+ import { readThemePackage } from './theme-packages.mjs';
6
+ import { getSourcePageLocation } from './source-files.mjs';
13
7
  import { resolveThemePresentation } from './presentation.mjs';
14
- import { resolveThemeConfig } from './theme-presets.mjs';
15
- import { pageThemeSchema, themeVisualSchema } from './schema-definitions.mjs';
16
- import { parseYamlConfig } from './yaml-config.mjs';
17
8
 
18
9
  export const readThemeConfig = async () => {
19
- const themeFile = await readFile(siteThemePath, 'utf8').catch((error) => {
20
- if (error?.code === 'ENOENT') {
21
- throw new Error(`${siteThemeLabel} is required. Create it before running Norna.`);
22
- }
23
-
24
- throw error;
25
- });
26
- const config = parseYamlConfig(themeFile, siteThemeLabel, {
27
- schema: themeVisualSchema,
28
- validateStructure: validateThemeYamlStructure,
29
- });
30
- resolveThemeConfig(config, siteThemeLabel);
31
- resolveThemePresentation(config, siteThemeLabel);
32
-
33
- return config;
34
- };
35
-
36
- const getPageThemeFiles = async (directory, relativeDirectory = '') => {
37
- const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
38
- if (error?.code === 'ENOENT') return [];
39
- throw error;
40
- });
41
- const files = [];
42
-
43
- for (const entry of entries) {
44
- const relativePath = path.join(relativeDirectory, entry.name);
45
- const absolutePath = path.join(directory, entry.name);
46
- if (entry.isDirectory()) {
47
- files.push(...await getPageThemeFiles(absolutePath, relativePath));
48
- continue;
49
- }
50
-
51
- if (entry.name === 'theme.yaml') {
52
- files.push({
53
- path: absolutePath,
54
- label: `${sitePagesLabel}/${relativePath.split(path.sep).join('/')}`,
55
- });
56
- }
57
- }
58
-
59
- return files;
10
+ const theme = await readThemePackage({ siteRoot: siteDir, filename: siteThemePath });
11
+ resolveThemePresentation(theme.config, theme.label);
12
+ return theme.config;
60
13
  };
61
14
 
62
15
  export const validatePageThemeFiles = async () => {
63
- const files = await getPageThemeFiles(sitePagesDir);
64
- const configs = [];
65
-
66
- for (const file of files) {
67
- const source = await readFile(file.path, 'utf8');
68
- const config = parseYamlConfig(source, file.label, {
69
- schema: pageThemeSchema,
70
- validateStructure: validatePageThemeYamlStructure,
16
+ const themes = [];
17
+ const visit = async (directory) => {
18
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
19
+ if (error.code === 'ENOENT') return [];
20
+ throw error;
71
21
  });
72
-
73
- configs.push({ ...file, config });
74
- }
75
-
76
- return configs;
22
+ for (const entry of entries) {
23
+ const filename = path.join(directory, entry.name);
24
+ if (entry.isDirectory()) { await visit(filename); continue; }
25
+ if (!['tree-theme.yaml', 'page-theme.yaml', 'theme.yaml'].includes(entry.name)) continue;
26
+ const location = getSourcePageLocation(siteDir, directory);
27
+ if (!location) continue;
28
+ if (entry.name === 'theme.yaml') throw new Error(`${filename} uses the former theme filename. Use page-theme.yaml for one page only or tree-theme.yaml for inherited branch settings.`);
29
+ if (filename === siteThemePath) continue;
30
+ themes.push({ ...await readThemePackage({ siteRoot: siteDir, filename }), pageDirectory: location.pageDirectory });
31
+ }
32
+ };
33
+ await visit(getSiteSourcePaths(siteDir).root);
34
+ return themes;
77
35
  };
@@ -0,0 +1,47 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getPageDirectoryAncestors, parsePageDirectoryPath } from './page-model.mjs';
4
+ import { getPageSourceDirectory } from './site-conventions.mjs';
5
+ import { getSourceFileDefinition } from './source-files.mjs';
6
+ import { mergeThemeOverrides } from './theme-presets.mjs';
7
+ import { resolveThemePresentation } from './presentation.mjs';
8
+ import { validateThemeYamlStructure } from './site-content.mjs';
9
+ import { parseYamlConfig } from './yaml-config.mjs';
10
+
11
+ export const readThemePackage = async ({ siteRoot, filename, sources = new Map() }) => {
12
+ const definition = getSourceFileDefinition(siteRoot, filename);
13
+ if (!definition || !['rootTheme', 'theme', 'pageTheme'].includes(definition.schemaKind)) throw new Error(`Not a permitted theme location: ${filename}.`);
14
+ const source = sources.has(filename) ? sources.get(filename) : await readFile(filename, 'utf8').catch((error) => {
15
+ if (error.code !== 'ENOENT') throw error;
16
+ if (definition.required) throw new Error(`${filename} is required. Create the root tree theme before running Norna.`);
17
+ return null;
18
+ });
19
+ if (source === null) return null;
20
+ return { path: filename, label: filename, config: parseYamlConfig(source, filename, { schema: definition.schema, validateStructure: validateThemeYamlStructure }) };
21
+ };
22
+
23
+ // Resolve authored overrides only; preset expansion stays in the shared visual
24
+ // resolvers. A page-only file is never considered while walking ancestors.
25
+ export const selectPageTheme = async ({ siteRoot, pageDirectory = '.', sources, includePageTheme = true }) => {
26
+ siteRoot = path.resolve(siteRoot);
27
+ parsePageDirectoryPath(pageDirectory);
28
+ const ancestors = pageDirectory === '.' ? ['.'] : ['.', ...getPageDirectoryAncestors(pageDirectory)];
29
+ let treeTheme;
30
+ const sourceFiles = [];
31
+ for (const ancestor of ancestors) {
32
+ const filename = path.join(siteRoot, getPageSourceDirectory(ancestor), 'tree-theme.yaml');
33
+ const candidate = await readThemePackage({ siteRoot, filename, sources });
34
+ if (candidate) {
35
+ treeTheme = { ...candidate, config: mergeThemeOverrides(treeTheme?.config, candidate.config) };
36
+ sourceFiles.push(candidate);
37
+ }
38
+ }
39
+ const pageTheme = includePageTheme
40
+ ? await readThemePackage({ siteRoot, filename: path.join(siteRoot, getPageSourceDirectory(pageDirectory), 'page-theme.yaml'), sources })
41
+ : null;
42
+ const selected = pageTheme ? { ...pageTheme, config: mergeThemeOverrides(treeTheme.config, pageTheme.config) } : treeTheme;
43
+ if (pageTheme) sourceFiles.push(pageTheme);
44
+ resolveThemePresentation(treeTheme.config, treeTheme.label);
45
+ resolveThemePresentation(selected.config, selected.label);
46
+ return { treeTheme, pageTheme, selected, sourceFiles };
47
+ };
@@ -93,7 +93,7 @@ export const getThemePresetMetadata = (presetName) => {
93
93
  };
94
94
  };
95
95
 
96
- export const getThemePreset = (presetName, sourceLabel = 'theme.yaml') => {
96
+ export const getThemePreset = (presetName, sourceLabel = 'root/tree-theme.yaml') => {
97
97
  const preset = themePresets[presetName];
98
98
 
99
99
  if (!preset) {
@@ -103,62 +103,29 @@ export const getThemePreset = (presetName, sourceLabel = 'theme.yaml') => {
103
103
  return structuredClone(preset);
104
104
  };
105
105
 
106
- export const resolveThemeConfig = (theme = {}, sourceLabel = 'theme.yaml') => {
107
- const presetName = theme?.preset;
108
- if (presetName === undefined) return structuredClone(theme ?? {});
106
+ // Keep authored fields separate from preset defaults so navigation can distinguish
107
+ // an explicit surface request from an adaptable preset default.
108
+ export const mergeThemeOverrides = (base = {}, override = {}) => {
109
+ const merged = override.preset !== undefined ? structuredClone(override) : mergeDeep(base, override);
110
+ if (override.images?.presentation === 'prose-aligned' && override.images.maxAvailableHeightPercent === undefined) {
111
+ delete merged.images.maxAvailableHeightPercent;
112
+ }
113
+ return merged;
114
+ };
109
115
 
116
+ export const resolveThemeConfig = (theme = {}, sourceLabel = 'root/tree-theme.yaml') => {
117
+ const presetName = theme?.preset;
110
118
  const overrides = structuredClone(theme ?? {});
111
- delete overrides.preset;
112
-
113
- const resolved = {
114
- preset: presetName,
119
+ const resolved = presetName === undefined ? overrides : {
115
120
  ...mergeDeep(getThemePreset(presetName, sourceLabel), overrides),
116
121
  };
117
-
118
- if (
119
- overrides.images?.presentation === 'prose-aligned'
120
- && overrides.images.maxAvailableHeightPercent === undefined
121
- ) {
122
+ if (overrides.images?.presentation === 'prose-aligned' && overrides.images.maxAvailableHeightPercent === undefined) {
122
123
  delete resolved.images.maxAvailableHeightPercent;
123
124
  }
124
-
125
- return resolved;
126
- };
127
-
128
- const mergePageThemePart = (base, override, keys) => Object.fromEntries(keys
129
- .filter((key) => override?.[key] !== undefined || base?.[key] !== undefined)
130
- .map((key) => [key, override?.[key] ?? base?.[key]]));
131
-
132
- export const mergePageThemeConfig = (base = {}, override = {}) => {
133
- const images = {
134
- ...(base.images ?? {}),
135
- ...mergePageThemePart(base.images, override.images, [
136
- 'presentation',
137
- 'width',
138
- 'maxAvailableWidthPercent',
139
- 'maxAvailableHeightPercent',
140
- ]),
141
- };
142
-
143
- if (
144
- override.images?.presentation === 'prose-aligned'
145
- && override.images.maxAvailableHeightPercent === undefined
146
- ) {
147
- delete images.maxAvailableHeightPercent;
125
+ if (resolved.images?.presentation === 'prose-aligned' && resolved.images.maxAvailableHeightPercent !== undefined) {
126
+ throw new Error(`images.maxAvailableHeightPercent cannot be used with images.presentation "prose-aligned" in ${sourceLabel}. Remove images.maxAvailableHeightPercent or set images.presentation to "centered-fit".`);
148
127
  }
149
-
150
- return {
151
- ...base,
152
- layout: {
153
- ...(base.layout ?? {}),
154
- ...mergePageThemePart(base.layout, override.layout, ['contentSpacing', 'textWidth']),
155
- },
156
- images,
157
- sections: {
158
- ...(base.sections ?? {}),
159
- ...mergePageThemePart(base.sections, override.sections, ['backgroundPattern']),
160
- },
161
- };
128
+ return resolved;
162
129
  };
163
130
 
164
131
  const quote = (value) => JSON.stringify(value);
@@ -171,7 +138,7 @@ const responsiveValueLines = (label, value, indent = 2) => {
171
138
  ];
172
139
  };
173
140
 
174
- export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml') => {
141
+ export const renderThemePresetReference = (presetName, sourceLabel = 'root/tree-theme.yaml') => {
175
142
  const preset = getThemePreset(presetName, sourceLabel);
176
143
  const metadata = getThemePresetMetadata(presetName);
177
144
  const { appearance, corners, layout, images, blocks, typography, palette, sections } = preset;
@@ -179,8 +146,8 @@ export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml
179
146
  return [
180
147
  `# Original values for Norna's "${presetName}" theme preset.`,
181
148
  `# ${metadata.description}`,
182
- '# This is a reference file. Norna only loads theme.yaml.',
183
- '# Keep the preset in theme.yaml and copy only the values you want to override.',
149
+ '# This is a reference file. Norna loads root/tree-theme.yaml for the shared theme.',
150
+ '# Keep the preset in root/tree-theme.yaml and copy only the values you want to override.',
184
151
  `# Available theme presets: ${themePresetNames.join(', ')}.`,
185
152
  `preset: ${presetName}`,
186
153
  '',
@@ -152,7 +152,7 @@ const evaluateResponsiveSize = (value, viewportWidth) => {
152
152
  return Math.min(parsed.maximumRem * 16, Math.max(parsed.minimumRem * 16, preferredPixels));
153
153
  };
154
154
 
155
- export const assertTypographyContract = (typography, sourceLabel = 'theme.yaml') => {
155
+ export const assertTypographyContract = (typography, sourceLabel = 'root/tree-theme.yaml') => {
156
156
  const values = typography?.values ?? typography;
157
157
  if (!values?.headings || !values?.body || !values?.caption) {
158
158
  throw new Error(`Incomplete typography configuration in ${sourceLabel}.`);
@@ -427,7 +427,7 @@ export const defaultTypography = {
427
427
  rhythm: 'normal',
428
428
  };
429
429
 
430
- export const resolveTypographyConfig = (typography = defaultTypography, sourceLabel = 'theme.yaml') => {
430
+ export const resolveTypographyConfig = (typography = defaultTypography, sourceLabel = 'root/tree-theme.yaml') => {
431
431
  const profileName = typography?.profile ?? defaultTypography.profile;
432
432
  const rhythmName = typography?.rhythm ?? defaultTypography.rhythm;
433
433
  const profile = typographyProfiles[profileName];
@@ -60,7 +60,7 @@ export const parseYamlConfig = (source, label, {
60
60
  if (issues.length > 0) {
61
61
  throw new Error([
62
62
  `${label} has invalid YAML structure.`,
63
- ...issues.map((issue) => `- ${issue.message}`),
63
+ ...issues.map((issue) => `- ${issue.message}${issue.fix ? ` ${issue.fix}` : ''}`),
64
64
  ].join('\n'));
65
65
  }
66
66
 
@@ -0,0 +1,94 @@
1
+ import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
2
+
3
+ const collectComments = (token, start, end, comments = []) => {
4
+ if (!token || typeof token !== 'object') return comments;
5
+ if (token.type === 'comment' && token.offset >= start && token.offset < end) comments.push(token.source);
6
+ for (const value of Object.values(token)) {
7
+ if (Array.isArray(value)) for (const item of value) collectComments(item, start, end, comments);
8
+ else if (value && typeof value === 'object') collectComments(value, start, end, comments);
9
+ }
10
+ return comments;
11
+ };
12
+
13
+ export const changeYamlField = (yaml, keys, value, eol) => {
14
+ const document = parseDocument(yaml, { keepSourceTokens: true });
15
+ if (document.errors.length) throw new Error('Repair the YAML before editing it automatically.');
16
+ if (document.contents && !isMap(document.contents)) throw new Error('This YAML document requires a mapping.');
17
+ const scalar = (data) => JSON.stringify(data);
18
+ const entryText = (key, data, indent) => {
19
+ const prefix = ' '.repeat(indent);
20
+ if (Array.isArray(data)) return `${prefix}${key}:${eol}${data.map((entry) => `${prefix} - ${scalar(entry)}${eol}`).join('')}`;
21
+ return data && typeof data === 'object' && !Array.isArray(data)
22
+ ? `${prefix}${key}:${eol}${Object.entries(data).map(([child, value]) => entryText(child, value, indent + 2)).join('')}`
23
+ : `${prefix}${key}: ${scalar(data)}${eol}`;
24
+ };
25
+ const edit = (start, end, text) => yaml.slice(0, start) + text + yaml.slice(end);
26
+ const insert = (map, key, data) => {
27
+ if (!map) return yaml + (yaml && !yaml.endsWith('\n') ? eol : '') + entryText(key, data, 0);
28
+ if (map.flow) {
29
+ const position = map.range[1] - 1;
30
+ const separator = map.items.length && !yaml.slice(0, position).trimEnd().endsWith(',') ? ', ' : '';
31
+ return edit(position, position, `${separator}${key}: ${scalar(data)}`);
32
+ }
33
+ const indent = map.items[0]?.key.srcToken.indent ?? 0;
34
+ const position = map.range[2];
35
+ return edit(position, position, `${position && yaml[position - 1] !== '\n' ? eol : ''}${entryText(key, data, indent)}`);
36
+ };
37
+ const visit = (map, remaining, ancestors = []) => {
38
+ const [key, ...rest] = remaining;
39
+ const pair = map?.items.find((item) => item.key.value === key);
40
+ if (!pair) {
41
+ if (value === undefined) return yaml;
42
+ return insert(map, key, rest.reduceRight((child, part) => ({ [part]: child }), value));
43
+ }
44
+ if (rest.length) {
45
+ if (!isMap(pair.value)) throw new Error(`Edit ${key} as a YAML mapping in the source before editing it automatically.`);
46
+ return visit(pair.value, rest, [...ancestors, { map, pair }]);
47
+ }
48
+ if (value !== undefined) {
49
+ if ((!isScalar(pair.value) && !(Array.isArray(value) && isSeq(pair.value))) || pair.value.anchor || pair.value.tag
50
+ || (isSeq(pair.value) && pair.value.items.some((item) => !isScalar(item) || item.anchor || item.tag))) {
51
+ throw new Error(`Edit ${keys.join('.')} in the source; this field uses YAML anchors, tags or values that cannot be changed automatically.`);
52
+ }
53
+ if (Array.isArray(value) && !map.flow) {
54
+ // Keep aliases in block form so a later page:move can preserve
55
+ // old addresses without asking the author to reformat our output.
56
+ const start = yaml.lastIndexOf('\n', pair.key.range[0] - 1) + 1;
57
+ const end = pair.value.range[2];
58
+ const indent = pair.key.srcToken.indent ?? 0;
59
+ const comments = collectComments(pair.srcToken, start, end);
60
+ return edit(start, end, comments.map((comment) => `${' '.repeat(indent)}${comment}${eol}`).join('')
61
+ + entryText(pair.key.value, value, indent));
62
+ }
63
+ const start = pair.value.range[0];
64
+ const end = pair.value.range[1];
65
+ const comments = collectComments(pair.value.srcToken, start, end);
66
+ const trailing = yaml.slice(start, end).endsWith('\n') ? eol : '';
67
+ return edit(start, end, scalar(value) + comments.map((comment) => ` ${comment}`).join('') + trailing);
68
+ }
69
+ return remove(map, pair, ancestors);
70
+ };
71
+ const remove = (map, pair, ancestors) => {
72
+ if (!map.flow && map.items.length === 1 && ancestors.length) {
73
+ const parent = ancestors.pop();
74
+ return remove(parent.map, parent.pair, ancestors);
75
+ }
76
+ let start = pair.key.range[0];
77
+ let end = pair.value?.range[2] ?? pair.key.range[2];
78
+ if (map.flow) {
79
+ end = pair.value?.range[1] ?? pair.key.range[1];
80
+ const index = map.items.indexOf(pair);
81
+ const previousComma = pair.srcToken?.start.find((token) => token.type === 'comma');
82
+ const nextComma = map.items[index + 1]?.srcToken?.start.find((token) => token.type === 'comma');
83
+ if (previousComma) start = previousComma.offset;
84
+ else if (nextComma) end = nextComma.offset + 1;
85
+ } else {
86
+ start = yaml.lastIndexOf('\n', start - 1) + 1;
87
+ }
88
+ const comments = collectComments(pair.srcToken, start, end);
89
+ const indent = pair.key.srcToken.indent ?? 0;
90
+ const replacement = comments.map((comment) => `${' '.repeat(indent)}${comment}${eol}`).join('');
91
+ return edit(start, end, replacement);
92
+ };
93
+ return visit(document.contents, keys);
94
+ };
@@ -1,13 +1,4 @@
1
- import { randomUUID } from 'node:crypto';
2
- import {
3
- access,
4
- mkdir,
5
- readFile,
6
- rename,
7
- rm,
8
- rmdir,
9
- writeFile,
10
- } from 'node:fs/promises';
1
+ import { applyPageMovePlan } from './lib/page-move-apply.mjs';
11
2
  import path from 'node:path';
12
3
  import {
13
4
  createPageMovePlan,
@@ -21,6 +12,7 @@ import {
21
12
  sitePagesDir,
22
13
  sitePagesLabel,
23
14
  siteProjectRoot,
15
+ siteDir,
24
16
  } from './lib/site-paths.mjs';
25
17
  import { getSiteStructure } from './lib/site-structure.mjs';
26
18
 
@@ -101,119 +93,6 @@ const toDisplayPath = (filePath) => {
101
93
  : filePath).split(path.sep).join('/');
102
94
  };
103
95
 
104
- const fileExists = async (filePath) => access(filePath).then(() => true, () => false);
105
-
106
- const writeFileAtomically = async (filePath, source, mode) => {
107
- const temporaryPath = path.join(
108
- path.dirname(filePath),
109
- `.${path.basename(filePath)}.norna-${process.pid}-${randomUUID()}.tmp`,
110
- );
111
- try {
112
- await writeFile(temporaryPath, source, { mode: mode & 0o777 });
113
- await rename(temporaryPath, filePath);
114
- } finally {
115
- await rm(temporaryPath, { force: true });
116
- }
117
- };
118
-
119
- const assertSourcesUnchanged = async (plan) => {
120
- for (const file of plan.sourceFiles) {
121
- const currentSource = await readFile(file.contentPath, 'utf8');
122
- if (currentSource !== file.source) {
123
- throw new Error([
124
- `Page move stopped because ${file.contentLabel} changed after the plan was created.`,
125
- 'Run the command again to build a fresh plan. No file was changed.',
126
- ].join('\n'));
127
- }
128
- }
129
- };
130
-
131
- const assertFinalSite = async () => {
132
- const structure = await getSiteStructure();
133
- const graph = await getSiteLinkGraph({ siteStructure: structure });
134
- if (graph.diagnostics.length === 0) return;
135
- throw new Error([
136
- 'Post-move link validation failed.',
137
- ...graph.diagnostics.flatMap((diagnostic) => [
138
- `- ${diagnostic.message}`,
139
- ...(diagnostic.fix ? [` Fix: ${diagnostic.fix}`] : []),
140
- ]),
141
- ].join('\n'));
142
- };
143
-
144
- const applyPlan = async (plan) => {
145
- await assertSourcesUnchanged(plan);
146
- let collectionCreated = false;
147
- let directoryMoved = false;
148
- let fileWritten = false;
149
-
150
- try {
151
- if (plan.mode === 'move') {
152
- if (!plan.destinationCollectionExists) {
153
- await mkdir(plan.destinationCollectionDirectory);
154
- collectionCreated = true;
155
- }
156
- try {
157
- await rename(plan.sourceDirectory, plan.destinationDirectory);
158
- directoryMoved = true;
159
- } catch (error) {
160
- if (error?.code === 'EXDEV') {
161
- throw new Error('Page directories must stay on one filesystem so Norna can move the complete subtree atomically.');
162
- }
163
- throw error;
164
- }
165
- }
166
-
167
- for (const change of plan.fileChanges) {
168
- await writeFileAtomically(change.finalPath, change.updatedSource, change.mode);
169
- fileWritten = true;
170
- }
171
- await assertFinalSite();
172
- } catch (error) {
173
- const changesStarted = directoryMoved || fileWritten;
174
- const rollbackErrors = [];
175
- if (directoryMoved) {
176
- try {
177
- await rename(plan.destinationDirectory, plan.sourceDirectory);
178
- directoryMoved = false;
179
- } catch (rollbackError) {
180
- rollbackErrors.push(`Could not restore the page directory: ${rollbackError.message}`);
181
- }
182
- }
183
-
184
- for (const change of plan.fileChanges) {
185
- try {
186
- const restorePath = directoryMoved && change.finalPath !== change.originalPath
187
- ? change.finalPath
188
- : change.originalPath;
189
- await writeFileAtomically(restorePath, change.originalSource, change.mode);
190
- } catch (rollbackError) {
191
- rollbackErrors.push(`Could not restore ${change.contentLabel}: ${rollbackError.message}`);
192
- }
193
- }
194
-
195
- if (collectionCreated && !directoryMoved) {
196
- try {
197
- await rmdir(plan.destinationCollectionDirectory);
198
- } catch (rollbackError) {
199
- if (rollbackError?.code !== 'ENOENT' && rollbackError?.code !== 'ENOTEMPTY') {
200
- rollbackErrors.push(`Could not remove the newly created pages directory: ${rollbackError.message}`);
201
- }
202
- }
203
- }
204
-
205
- throw new Error([
206
- error instanceof Error ? error.message : String(error),
207
- rollbackErrors.length === 0
208
- ? (changesStarted
209
- ? 'The original page directory and content files were restored.'
210
- : 'No source file was changed.')
211
- : 'Rollback was incomplete. Inspect these paths before running page:move again:',
212
- ...rollbackErrors.map((message) => `- ${message}`),
213
- ].join('\n'));
214
- }
215
- };
216
-
217
96
  const main = async () => {
218
97
  const options = parseArgs(process.argv.slice(2));
219
98
  if (options.help) {
@@ -244,7 +123,7 @@ const main = async () => {
244
123
  return;
245
124
  }
246
125
 
247
- await applyPlan(plan);
126
+ await applyPageMovePlan(plan, { siteRoot: siteDir });
248
127
  console.log(`\nPage move completed: ${plan.from} -> ${plan.to}`);
249
128
  };
250
129
 
@@ -1,6 +1,4 @@
1
1
  import {
2
- defaultTypography,
3
- resolveTypographyConfig,
4
2
  toYamlLines,
5
3
  typographyProfiles,
6
4
  typographyRhythms,
@@ -13,10 +11,13 @@ import {
13
11
  } from './lib/site-content.mjs';
14
12
  import { parsePageMarkdown } from './lib/page-markdown.mjs';
15
13
  import {
14
+ siteDir,
16
15
  siteThemeLabel,
17
16
  } from './lib/site-paths.mjs';
18
17
  import { readThemeConfig } from './lib/theme-config.mjs';
19
18
  import { resolveThemeConfig } from './lib/theme-presets.mjs';
19
+ import { selectPageTheme } from './lib/theme-packages.mjs';
20
+ import { resolveThemePresentation } from './lib/presentation.mjs';
20
21
 
21
22
  const mode = process.argv[2] ?? 'show';
22
23
 
@@ -137,14 +138,14 @@ const defaultSources = (profileName, rhythmName) => {
137
138
  return sources;
138
139
  };
139
140
 
140
- const applyOverrideSources = (sources, typographyConfig, sourceLabel) => {
141
+ const applyOverrideSources = (sources, typographyConfig, sourceFor) => {
141
142
  const overrides = typographyConfig?.overrides;
142
143
  if (!overrides) return sources;
143
144
 
144
145
  for (const path of typographyValuePaths) {
145
146
  if (hasPath(overrides, path)) {
146
147
  setPath(sources, path, {
147
- source: `${sourceLabel} override`,
148
+ source: `${sourceFor(['typography', 'overrides', ...path])} override`,
148
149
  inherited: false,
149
150
  });
150
151
  }
@@ -153,25 +154,38 @@ const applyOverrideSources = (sources, typographyConfig, sourceLabel) => {
153
154
  return sources;
154
155
  };
155
156
 
156
- const resolveAnnotatedTypographyConfig = (typographyConfig, sourceLabel) => {
157
- const resolved = resolveTypographyConfig(typographyConfig ?? defaultTypography);
157
+ const resolveAnnotatedTypographyConfig = (theme, sourceLabel, sourceFiles) => {
158
+ const normalized = resolveThemeConfig(theme, sourceLabel);
159
+ const typographyConfig = normalized.typography;
160
+ const resolved = resolveThemePresentation(theme, sourceLabel).typography;
161
+ // Attribute a value to its last authored setting, stopping at the preset
162
+ // that replaced its base. The resolver still determines all effective values.
163
+ const sourceFor = (propertyPath) => {
164
+ for (const file of sourceFiles.toReversed()) {
165
+ if (hasPath(file.config, propertyPath)) return file.label;
166
+ if (file.config.preset !== undefined) return `preset:${file.config.preset} (${file.label})`;
167
+ }
168
+ return 'engine default';
169
+ };
158
170
  const sources = applyOverrideSources(
159
171
  defaultSources(resolved.profile, resolved.rhythm),
160
172
  typographyConfig,
161
- sourceLabel,
173
+ sourceFor,
162
174
  );
175
+ if (normalized.layout?.textWidth !== undefined) {
176
+ setPath(sources, ['body', 'width'], { source: sourceFor(['layout', 'textWidth']), inherited: false });
177
+ }
163
178
 
164
179
  return {
165
180
  profile: {
166
181
  value: resolved.profile,
167
- source: typographyConfig?.profile ? sourceLabel : 'engine default',
168
- ...(typographyConfig?.profile ? {} : { inherited: true }),
182
+ source: sourceFor(['typography', 'profile']),
169
183
  },
170
184
  rhythm: {
171
185
  value: resolved.rhythm,
172
- source: typographyConfig?.rhythm ? sourceLabel : 'engine default',
173
- ...(typographyConfig?.rhythm ? {} : { inherited: true }),
186
+ source: sourceFor(['typography', 'rhythm']),
174
187
  },
188
+ fontFamily: { value: typographyConfig.fontFamily, source: sourceFor(['typography', 'fontFamily']) },
175
189
  resolved,
176
190
  sources,
177
191
  };
@@ -180,16 +194,16 @@ const resolveAnnotatedTypographyConfig = (typographyConfig, sourceLabel) => {
180
194
  const formatAnnotatedTypography = (annotated) => ({
181
195
  profile: annotated.profile,
182
196
  rhythm: annotated.rhythm,
197
+ fontFamily: annotated.fontFamily,
183
198
  resolved: annotateResolvedValues(annotated.resolved, annotated.sources),
184
199
  });
185
200
 
186
201
  const readThemeTypography = async () => {
187
- const themeConfig = resolveThemeConfig(await readThemeConfig(), siteThemeLabel);
188
- const themeTypographyConfig = themeConfig.typography;
189
- return resolveAnnotatedTypographyConfig(themeTypographyConfig ?? defaultTypography, siteThemeLabel);
202
+ const config = await readThemeConfig();
203
+ return resolveAnnotatedTypographyConfig(config, siteThemeLabel, [{ config, label: siteThemeLabel }]);
190
204
  };
191
205
 
192
- const readPageTypography = async (contentFile, siteThemeTypography) => {
206
+ const readPageTypography = async (contentFile) => {
193
207
  const { frontmatter, body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
194
208
  const indentationIssues = [];
195
209
  validateFrontmatterIndentation(frontmatter, (issue) => indentationIssues.push(issue));
@@ -201,7 +215,8 @@ const readPageTypography = async (contentFile, siteThemeTypography) => {
201
215
  ].join('\n'));
202
216
  }
203
217
 
204
- const pageTypography = siteThemeTypography;
218
+ const { selected, sourceFiles } = await selectPageTheme({ siteRoot: siteDir, pageDirectory: contentFile.pageDirectory });
219
+ const pageTypography = resolveAnnotatedTypographyConfig(selected.config, selected.label, sourceFiles);
205
220
  const sections = (await parsePageMarkdown(body, { label: contentFile.contentLabel })).sections
206
221
  .map((section) => {
207
222
  return {
@@ -225,7 +240,7 @@ if (mode === 'profiles') {
225
240
  }).join('\n'));
226
241
  } else if (mode === 'show') {
227
242
  const themeTypography = await readThemeTypography();
228
- const pages = await Promise.all((await getContentFiles()).map((contentFile) => readPageTypography(contentFile, themeTypography)));
243
+ const pages = await Promise.all((await getContentFiles()).map(readPageTypography));
229
244
  const output = {
230
245
  theme: {
231
246
  source: siteThemeLabel,