@janga/norna 0.7.26 → 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 (110) hide show
  1. package/README.md +51 -35
  2. package/astro.config.mjs +19 -1
  3. package/bin/norna-cli.mjs +6 -4
  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 +17 -8
  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 +33 -2
  22. package/scripts/lib/documentation-routes.json +229 -0
  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 +39 -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 +12 -39
  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/PageAliasRedirect.astro +44 -3
  76. package/src/components/SearchPage.astro +9 -1
  77. package/src/components/SectionNavigationScript.astro +106 -26
  78. package/src/components/SiteNavigation.astro +45 -20
  79. package/src/components/SitePage.astro +37 -61
  80. package/src/components/SiteTreeNavigation.astro +38 -18
  81. package/src/components/TopPageMenu.astro +4 -3
  82. package/src/components/TreeNavigationScript.astro +50 -57
  83. package/src/content.config.ts +15 -16
  84. package/src/layouts/BaseLayout.astro +15 -39
  85. package/src/lib/areaNavigation.ts +42 -0
  86. package/src/lib/navigationContinuity.ts +212 -0
  87. package/src/lib/navigationFollowing.ts +28 -18
  88. package/src/lib/navigationState.mjs +88 -0
  89. package/src/lib/navigationStateScript.mjs +76 -0
  90. package/src/lib/navigationVisibility.ts +9 -0
  91. package/src/lib/pageThemes.ts +5 -42
  92. package/src/lib/sectionContent.ts +3 -2
  93. package/src/lib/siteNavigation.ts +6 -0
  94. package/src/lib/sitePages.ts +12 -23
  95. package/src/pages/404.astro +1 -1
  96. package/src/pages/[...slug].astro +5 -15
  97. package/src/pages/index.astro +1 -3
  98. package/src/styles/area-navigation.css +399 -0
  99. package/src/styles/content.css +5 -31
  100. package/src/styles/navigation-continuity.css +163 -0
  101. package/src/styles/page-layout.css +57 -68
  102. package/src/styles/responsive.css +0 -4
  103. package/starters/basic/README.md +10 -9
  104. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  105. package/schemas/category.schema.json +0 -20
  106. package/src/components/PageContentsNavigation.astro +0 -20
  107. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  108. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  109. /package/starters/basic/site/{config.yaml → site-config/settings.yaml} +0 -0
  110. /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
@@ -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,
@@ -233,7 +233,6 @@ try {
233
233
  const siteStructure = await getSiteStructure();
234
234
  const contentFiles = siteStructure.contentFiles;
235
235
  for (const warning of siteStructure.warnings) {
236
- if (warning.code === 'empty-category') continue;
237
236
  addIssue({
238
237
  severity: 'warning',
239
238
  message: warning.message,
@@ -424,17 +423,20 @@ const listedNavigationTree = getListedSiteNavigationTree(siteStructure.nodes.map
424
423
  return {
425
424
  node: {
426
425
  ...node,
427
- navigation: {
428
- listed: node.kind === 'category'
429
- ? true
430
- : context?.frontmatterData.navigation?.listed ?? true,
431
- },
426
+ navigation: { listed: context?.frontmatterData.navigation?.listed ?? true },
432
427
  },
433
428
  };
434
429
  }));
435
430
 
436
431
  for (const context of contentFileContexts) {
437
432
  const childPages = getDirectChildPages(listedNavigationTree, context.contentFile.pagePath);
433
+ if (context.frontmatterData.page?.listChildren && childPages.length === 0) {
434
+ addContentIssue(context.contentFile, {
435
+ severity: 'warning',
436
+ message: 'page.listChildren has no listed direct child pages to display.',
437
+ fix: 'Add a listed direct child page, or remove page.listChildren while this page has no children.',
438
+ });
439
+ }
438
440
  const warnedChildren = new Set();
439
441
 
440
442
  for (const section of context.sections) {
@@ -444,7 +446,7 @@ for (const context of contentFileContexts) {
444
446
  if (childPages.length === 0) {
445
447
  addSectionIssue(context.contentFile, section, {
446
448
  severity: 'error',
447
- message: `page-list on line ${block.line} has no listed direct child pages to display. Navigation categories are not pages.`,
449
+ message: `page-list on line ${block.line} has no listed direct child pages to display.`,
448
450
  fix: 'Add a listed direct child page or remove the block.',
449
451
  });
450
452
  continue;
@@ -33,7 +33,7 @@ const sourceEntries = await readDirectory(sitePublicDir);
33
33
  const generatedPublicFiles = [
34
34
  {
35
35
  filename: sitemapFilename,
36
- explanation: `Norna generates ${sitemapFilename} from the public page tree and the URL in site/config.yaml.`,
36
+ explanation: `Norna generates ${sitemapFilename} from the public page tree and the URL in site/site-config/settings.yaml.`,
37
37
  },
38
38
  {
39
39
  filename: '404.html',
@@ -0,0 +1,17 @@
1
+ import { siteDir } from './lib/site-paths.mjs';
2
+ import { planSiteUpgrade } from './lib/site-upgrade.mjs';
3
+
4
+ const usage = 'Usage: norna site:upgrade [--apply]\nCheck the root/ source layout. Former layouts require manual conversion; --apply is retained but does not convert old sources.';
5
+ const args = process.argv.slice(2);
6
+ try {
7
+ if (args.includes('--help') || args.includes('-h')) {
8
+ console.log(usage);
9
+ } else {
10
+ if (args.length > 1 || args.some((arg) => arg !== '--apply')) throw new Error(usage);
11
+ await planSiteUpgrade(siteDir);
12
+ console.log('The site already uses the current configuration and homepage layout. No files changed.');
13
+ }
14
+ } catch (error) {
15
+ console.error(error.message);
16
+ process.exitCode = 1;
17
+ }
@@ -0,0 +1,24 @@
1
+ ---
2
+ import projectConfig from '../../scripts/lib/project-config.mjs';
3
+ import type { CategoryDestinationMap, SiteNavigationNode } from '../lib/siteNavigation';
4
+ import type { SiteNode } from '../lib/sitePages';
5
+ import AreaNavigationPanel from './AreaNavigationPanel.astro';
6
+
7
+ interface Props {
8
+ node: SiteNavigationNode;
9
+ currentPage?: SiteNode;
10
+ categoryDestinations?: CategoryDestinationMap;
11
+ }
12
+ const { node, currentPage, categoryDestinations } = Astro.props;
13
+ const label = `${projectConfig.locale.labels.navigationMenu}: ${node.node.title}`;
14
+ const panelId = `area-menu-${encodeURIComponent(node.node.pagePath)}`;
15
+ ---
16
+
17
+ {node.children.length > 0 && (
18
+ <details class="top-page-menu area-navigation-menu" data-area-menu={node.node.pagePath}>
19
+ <summary aria-label={label} aria-controls={panelId}>
20
+ <span class="top-page-menu-title">{node.node.title}</span>
21
+ </summary>
22
+ <AreaNavigationPanel id={panelId} node={node} currentPage={currentPage} categoryDestinations={categoryDestinations} />
23
+ </details>
24
+ )}
@@ -0,0 +1,55 @@
1
+ ---
2
+ import { Check } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+ import { withBasePath } from '../lib/basePath';
5
+ import { getNavigationDestination, type CategoryDestinationMap, type SiteNavigationNode } from '../lib/siteNavigation';
6
+ import { getAreaMenuGroups, resolveAreaNavigation } from '../lib/areaNavigation';
7
+ import type { SiteNode } from '../lib/sitePages';
8
+
9
+ interface Props {
10
+ id: string;
11
+ node?: SiteNavigationNode;
12
+ currentPage?: SiteNode;
13
+ categoryDestinations?: CategoryDestinationMap;
14
+ otherRoots?: SiteNavigationNode[];
15
+ }
16
+ const { id, node, currentPage, categoryDestinations, otherRoots = [] } = Astro.props;
17
+ const { localRoot } = resolveAreaNavigation(node ? [node] : [], currentPage);
18
+ const { branches, pages } = node ? getAreaMenuGroups(node) : { branches: [], pages: [] };
19
+ const columnsFor = (count: number, maximum: number) => count === 0 ? 0 : Math.ceil(count / Math.ceil(count / maximum));
20
+ const branchColumns = columnsFor(branches.length, pages.length > 0 ? 2 : 3);
21
+ const pageColumns = branches.length === 0 ? columnsFor(pages.length, 3) : pages.length > 0 ? 1 : 0;
22
+ const groups = [
23
+ { kind: 'branches', choices: branches, columns: branchColumns },
24
+ { kind: 'pages', choices: pages, columns: pageColumns },
25
+ ].filter(({ choices }) => choices.length > 0);
26
+ ---
27
+
28
+ <nav id={id} class="top-page-panel area-navigation-panel"
29
+ style={`--area-menu-columns: ${branchColumns + pageColumns || 2}`}
30
+ aria-label={node ? `${node.node.title}: ${projectConfig.locale.labels.navigationChildren}` : projectConfig.locale.labels.siteNavigation}>
31
+ {groups.map((group) => <ul class="area-navigation-choices" data-area-choice-group={group.kind}
32
+ style={`--area-group-columns: ${group.columns}; --area-group-rows: ${Math.ceil(group.choices.length / group.columns)}`}>
33
+ {group.choices.map((child) => {
34
+ const destination = getNavigationDestination(child.node, categoryDestinations);
35
+ const selected = localRoot === child || currentPage?.pagePath === child.node.pagePath;
36
+ const description = child.node.kind === 'page'
37
+ ? child.node.entry.data.page?.description
38
+ : child.node.description;
39
+ return <li>
40
+ <a href={withBasePath(projectConfig.site.basePath, destination.pathname)}
41
+ aria-current={selected ? 'true' : undefined} data-area-choice={child.node.pagePath}>
42
+ <span class="area-navigation-title">{child.node.title}{selected && <Check aria-hidden="true" />}</span>
43
+ {group.kind === 'branches' && description && <span class="area-navigation-description">{description}</span>}
44
+ </a>
45
+ </li>;
46
+ })}
47
+ </ul>)}
48
+ {otherRoots.length > 0 && <ul class="area-navigation-other-roots" aria-label={projectConfig.locale.labels.siteNavigation}>
49
+ {otherRoots.map(({ node: root }) => <li>
50
+ <a href={withBasePath(projectConfig.site.basePath, getNavigationDestination(root, categoryDestinations).pathname)}>
51
+ {root.isHome ? projectConfig.locale.labels.returnHome : root.title}
52
+ </a>
53
+ </li>)}
54
+ </ul>}
55
+ </nav>
@@ -0,0 +1,30 @@
1
+ ---
2
+ import { Menu } from '@lucide/astro';
3
+ import projectConfig from '../../scripts/lib/project-config.mjs';
4
+ import { hasLargeAreaMenu, resolveAreaNavigation } from '../lib/areaNavigation';
5
+ import type { CategoryDestinationMap, SiteNavigationNode } from '../lib/siteNavigation';
6
+ import type { SiteNode } from '../lib/sitePages';
7
+ import AreaNavigationPanel from './AreaNavigationPanel.astro';
8
+
9
+ interface Props {
10
+ nodes: SiteNavigationNode[];
11
+ currentPage?: SiteNode;
12
+ categoryDestinations?: CategoryDestinationMap;
13
+ label?: string;
14
+ }
15
+
16
+ const { nodes, currentPage, categoryDestinations, label } = Astro.props;
17
+ const { globalRoot } = resolveAreaNavigation(nodes, currentPage);
18
+ const currentLabel = label ?? globalRoot?.node.title ?? projectConfig.locale.labels.navigationMenu;
19
+ const menuRoot = globalRoot && globalRoot.children.length > 0 && !hasLargeAreaMenu(globalRoot) ? globalRoot : undefined;
20
+ const otherRoots = nodes.filter((root) => root !== menuRoot);
21
+ const menuLabel = `${projectConfig.locale.labels.navigationMenu}: ${currentLabel}`;
22
+ ---
23
+
24
+ <details class="top-page-menu area-navigation-switcher" data-area-switcher>
25
+ <summary aria-label={menuLabel} title={menuLabel} aria-controls="area-switcher-panel">
26
+ <Menu aria-hidden="true" />
27
+ </summary>
28
+ <AreaNavigationPanel id="area-switcher-panel" node={menuRoot} currentPage={currentPage}
29
+ categoryDestinations={categoryDestinations} otherRoots={otherRoots} />
30
+ </details>
@@ -104,13 +104,6 @@ const labels = projectConfig.locale.labels;
104
104
  const imageBounds = image.getBoundingClientRect();
105
105
  if (imageBounds.height < usableViewportHeight * persistentCaptionHeightRatio) return;
106
106
 
107
- const visibleContentsRail = Array.from(
108
- layout.querySelectorAll<HTMLElement>('.page-contents-navigation-rail'),
109
- ).some(isVisible);
110
- const reservesContentsLane = layout.dataset.pageContentsPlacement === 'contents-rail'
111
- && document.documentElement.dataset.focusReading !== 'on';
112
- if (visibleContentsRail || reservesContentsLane) return;
113
-
114
107
  const frameBounds = frame.getBoundingClientRect();
115
108
  const figureBounds = figure.getBoundingClientRect();
116
109
  const captionBounds = caption.getBoundingClientRect();
@@ -0,0 +1,26 @@
1
+ ---
2
+ import styles from '../styles/navigation-continuity.css?inline';
3
+ ---
4
+
5
+ <style is:inline set:html={styles} />
6
+ <script is:inline>
7
+ // A cold history arrival can restore its position at pagereveal, then lose
8
+ // it to native fragment scrolling when deferred modules finish parsing.
9
+ // Keep the browser's restored position until that initial load completes.
10
+ window.addEventListener('pagereveal', () => {
11
+ if (!location.hash || document.readyState === 'complete'
12
+ || performance.getEntriesByType('navigation')[0]?.type !== 'back_forward'
13
+ || history.scrollRestoration !== 'auto') return;
14
+
15
+ history.scrollRestoration = 'manual';
16
+ const release = () => {
17
+ history.scrollRestoration = 'auto';
18
+ window.removeEventListener('load', release);
19
+ window.removeEventListener('pagehide', release);
20
+ };
21
+ window.addEventListener('load', release);
22
+ window.addEventListener('pagehide', release);
23
+ }, { once: true });
24
+ </script>
25
+ <!-- A streamed response must finish the page before its transition snapshot. -->
26
+ <link rel="expect" blocking="render" href="#norna-navigation-ready" />
@@ -1,19 +1,23 @@
1
1
  ---
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
3
  import { withBasePath } from '../lib/basePath';
4
- import type { SiteNavigationNode } from '../lib/siteNavigation';
4
+ import { getNavigationDestination, type CategoryDestinationMap, type SiteNavigationNode } from '../lib/siteNavigation';
5
5
  import type { SiteNode, SitePage } from '../lib/sitePages';
6
6
  import PageContentsLinks from './PageContentsLinks.astro';
7
7
 
8
8
  interface Props {
9
9
  nodes: SiteNavigationNode[];
10
10
  currentPage?: SiteNode;
11
+ categoryDestinations?: CategoryDestinationMap;
11
12
  variant: 'mobile' | 'sidebar' | 'submenu';
12
13
  integratePageSections?: boolean;
13
14
  }
14
15
 
15
- const { nodes, currentPage, variant, integratePageSections = false } = Astro.props;
16
+ const { nodes, currentPage, categoryDestinations, variant, integratePageSections = false } = Astro.props;
16
17
  const pageHref = (page: SitePage) => withBasePath(projectConfig.site.basePath, page.pathname);
18
+ const categoryHref = (node: SiteNavigationNode) => withBasePath(
19
+ projectConfig.site.basePath, getNavigationDestination(node.node, categoryDestinations).pathname,
20
+ );
17
21
  const hasBranch = (node: SiteNavigationNode) => node.children.length > 0;
18
22
  const isCurrentPage = (page: SitePage) => currentPage?.pathname === page.pathname;
19
23
  const isCurrentBranch = (node: SiteNode) => Boolean(currentPage && (
@@ -71,13 +75,17 @@ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' &
71
75
  class="navigation-page-summary"
72
76
  aria-controls={getBranchId(node.node)}
73
77
  >
74
- <span class="navigation-page-summary-title">{node.node.title}</span>
78
+ <a class:list={['navigation-page-summary-title', 'navigation-category-link', {
79
+ 'mobile-nav-destination': variant === 'mobile',
80
+ }]}
81
+ href={categoryHref(node)}>{node.node.title}</a>
75
82
  <span class="navigation-page-chevron" aria-hidden="true"></span>
76
83
  </summary>
77
84
  <div id={getBranchId(node.node)} class="navigation-page-branch-content">
78
85
  <Astro.self
79
86
  nodes={node.children}
80
87
  currentPage={currentPage}
88
+ categoryDestinations={categoryDestinations}
81
89
  variant={variant}
82
90
  integratePageSections={integratePageSections}
83
91
  />
@@ -123,6 +131,7 @@ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' &
123
131
  <Astro.self
124
132
  nodes={node.children}
125
133
  currentPage={currentPage}
134
+ categoryDestinations={categoryDestinations}
126
135
  variant={variant}
127
136
  integratePageSections={integratePageSections}
128
137
  />
@@ -174,7 +183,7 @@ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' &
174
183
  {node.node.title}
175
184
  </a>
176
185
  {node.children.length > 0 && (
177
- <Astro.self nodes={node.children} currentPage={currentPage} variant={variant} />
186
+ <Astro.self nodes={node.children} currentPage={currentPage} categoryDestinations={categoryDestinations} variant={variant} />
178
187
  )}
179
188
  </>
180
189
  )}
@@ -0,0 +1,11 @@
1
+ ---
2
+ import { getNavigationStateScript } from '../lib/navigationStateScript.mjs';
3
+
4
+ interface Props {
5
+ scope: 'desktop' | 'mobile';
6
+ }
7
+
8
+ const { scope } = Astro.props;
9
+ ---
10
+
11
+ <script is:inline data-navigation-state-scope={scope} set:html={getNavigationStateScript()} />