@janga/norna 0.7.27 → 0.7.29

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 (136) hide show
  1. package/README.md +41 -29
  2. package/astro.config.mjs +33 -3
  3. package/bin/norna-cli.mjs +5 -3
  4. package/package.json +18 -12
  5. package/schemas/card-list.schema.json +1 -1
  6. package/schemas/config.schema.json +81 -66
  7. package/schemas/content-frontmatter.schema.json +23 -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 +31 -250
  14. package/scripts/build-site.mjs +11 -6
  15. package/scripts/check-config.mjs +7 -23
  16. package/scripts/dev-local.mjs +51 -17
  17. package/scripts/export-theme-preset.mjs +2 -2
  18. package/scripts/generate-images.mjs +89 -70
  19. package/scripts/generate-schemas.mjs +9 -6
  20. package/scripts/generate-search-index.mjs +3 -2
  21. package/scripts/lib/attachment-render.mjs +23 -0
  22. package/scripts/lib/category-destinations.mjs +13 -12
  23. package/scripts/lib/dev-server-identity.mjs +30 -0
  24. package/scripts/lib/documentation-links.mjs +1 -1
  25. package/scripts/lib/documentation-routes.json +70 -62
  26. package/scripts/lib/docusaurus-migration-audit.mjs +15 -12
  27. package/scripts/lib/editor-attachments.mjs +69 -0
  28. package/scripts/lib/editor-file-policy.mjs +20 -0
  29. package/scripts/lib/editor-language-service.mjs +74 -73
  30. package/scripts/lib/editor-page-addresses.mjs +57 -0
  31. package/scripts/lib/editor-page-placement.mjs +102 -0
  32. package/scripts/lib/editor-resource-actions.mjs +216 -0
  33. package/scripts/lib/editor-site-editing-tree.mjs +181 -0
  34. package/scripts/lib/editor-site-files.mjs +154 -0
  35. package/scripts/lib/editor-site-links.mjs +81 -0
  36. package/scripts/lib/editor-site-preview.mjs +149 -0
  37. package/scripts/lib/editor-site-tree.mjs +158 -0
  38. package/scripts/lib/editor-source-files.mjs +87 -0
  39. package/scripts/lib/editor-theme-help.mjs +43 -0
  40. package/scripts/lib/locale-registry.mjs +1 -1
  41. package/scripts/lib/locales/en.mjs +1 -1
  42. package/scripts/lib/logo-assets.mjs +2 -1
  43. package/scripts/lib/markdown-images.mjs +59 -0
  44. package/scripts/lib/markdown-links.mjs +29 -9
  45. package/scripts/lib/navigation-model.mjs +2 -44
  46. package/scripts/lib/navigation-review.mjs +14 -50
  47. package/scripts/lib/norna-markdown-blocks.mjs +1 -58
  48. package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
  49. package/scripts/lib/page-aliases.mjs +8 -1
  50. package/scripts/lib/page-attachments.mjs +59 -0
  51. package/scripts/lib/page-markdown.mjs +11 -7
  52. package/scripts/lib/page-model.mjs +19 -36
  53. package/scripts/lib/page-move-apply.mjs +119 -0
  54. package/scripts/lib/page-move-plan.mjs +64 -78
  55. package/scripts/lib/page-order-source.mjs +22 -0
  56. package/scripts/lib/page-order.mjs +45 -0
  57. package/scripts/lib/page-placement-cli.mjs +50 -0
  58. package/scripts/lib/presentation.mjs +3 -3
  59. package/scripts/lib/project-config.mjs +10 -8
  60. package/scripts/lib/publication-paths.mjs +72 -0
  61. package/scripts/lib/schema-definitions.mjs +15 -38
  62. package/scripts/lib/schema-editor-metadata.mjs +16 -60
  63. package/scripts/lib/schema-value-definitions.mjs +1 -1
  64. package/scripts/lib/site-content.mjs +8 -25
  65. package/scripts/lib/site-conventions.mjs +19 -1
  66. package/scripts/lib/site-link-graph.mjs +88 -18
  67. package/scripts/lib/site-navigation-tree.mjs +2 -7
  68. package/scripts/lib/site-node-create.mjs +182 -0
  69. package/scripts/lib/site-paths.mjs +24 -12
  70. package/scripts/lib/site-source-format.mjs +12 -0
  71. package/scripts/lib/site-structure.mjs +86 -95
  72. package/scripts/lib/site-upgrade.mjs +32 -0
  73. package/scripts/lib/social-image-assets.mjs +2 -1
  74. package/scripts/lib/source-files.mjs +34 -0
  75. package/scripts/lib/structured-blocks.mjs +3 -1
  76. package/scripts/lib/theme-config.mjs +26 -68
  77. package/scripts/lib/theme-packages.mjs +47 -0
  78. package/scripts/lib/theme-presets.mjs +20 -53
  79. package/scripts/lib/typography.mjs +2 -2
  80. package/scripts/lib/yaml-config.mjs +1 -1
  81. package/scripts/lib/yaml-source-edit.mjs +94 -0
  82. package/scripts/move-site-page.mjs +46 -254
  83. package/scripts/show-typography.mjs +32 -17
  84. package/scripts/sync-content-sections.mjs +17 -17
  85. package/scripts/sync-site-public.mjs +44 -66
  86. package/scripts/upgrade-site.mjs +17 -0
  87. package/src/components/AreaNavigationMenu.astro +24 -0
  88. package/src/components/AreaNavigationPanel.astro +55 -0
  89. package/src/components/AreaNavigationSwitcher.astro +30 -0
  90. package/src/components/ContentBlocks.astro +2 -1
  91. package/src/components/ImageStack.astro +7 -5
  92. package/src/components/ImageStackEnhancement.astro +0 -7
  93. package/src/components/NavigationContinuity.astro +26 -0
  94. package/src/components/NavigationPageTree.astro +13 -4
  95. package/src/components/NavigationState.astro +11 -0
  96. package/src/components/NavigationTreeControls.astro +3 -2
  97. package/src/components/SearchPage.astro +11 -2
  98. package/src/components/SectionNavigationScript.astro +106 -26
  99. package/src/components/SiteNavigation.astro +45 -20
  100. package/src/components/SitePage.astro +38 -61
  101. package/src/components/SiteSection.astro +1 -0
  102. package/src/components/SiteTreeNavigation.astro +38 -18
  103. package/src/components/TopPageMenu.astro +4 -3
  104. package/src/components/TreeNavigationScript.astro +50 -57
  105. package/src/content.config.ts +11 -12
  106. package/src/layouts/BaseLayout.astro +15 -39
  107. package/src/lib/areaNavigation.ts +42 -0
  108. package/src/lib/imagePresentation.ts +3 -0
  109. package/src/lib/markdownImages.ts +101 -0
  110. package/src/lib/navigationContinuity.ts +212 -0
  111. package/src/lib/navigationFollowing.ts +28 -18
  112. package/src/lib/navigationState.mjs +88 -0
  113. package/src/lib/navigationStateScript.mjs +76 -0
  114. package/src/lib/navigationVisibility.ts +9 -0
  115. package/src/lib/pageThemes.ts +5 -42
  116. package/src/lib/sectionContent.ts +67 -21
  117. package/src/lib/siteNavigation.ts +6 -0
  118. package/src/lib/sitePages.ts +13 -36
  119. package/src/lib/sitePublicAssets.ts +2 -1
  120. package/src/pages/404.astro +1 -1
  121. package/src/pages/[...slug].astro +7 -16
  122. package/src/pages/index.astro +1 -3
  123. package/src/styles/area-navigation.css +408 -0
  124. package/src/styles/content.css +8 -29
  125. package/src/styles/media.css +24 -0
  126. package/src/styles/navigation-continuity.css +163 -0
  127. package/src/styles/page-layout.css +57 -68
  128. package/src/styles/responsive.css +0 -4
  129. package/starters/basic/README.md +9 -8
  130. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  131. package/starters/basic/site/{config.yaml → site-config/settings.yaml} +1 -0
  132. package/schemas/category.schema.json +0 -20
  133. package/src/components/PageContentsNavigation.astro +0 -20
  134. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  135. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  136. /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,256 +1,48 @@
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';
11
1
  import path from 'node:path';
12
- import {
13
- createPageMovePlan,
14
- formatPageMovePlan,
15
- } from './lib/page-move-plan.mjs';
16
- import {
17
- getSiteLinkGraph,
18
- getSitePublicFiles,
19
- } from './lib/site-link-graph.mjs';
20
- import {
21
- sitePagesDir,
22
- sitePagesLabel,
23
- siteProjectRoot,
24
- } from './lib/site-paths.mjs';
25
- import { getSiteStructure } from './lib/site-structure.mjs';
26
-
27
- const usage = `
28
- Usage:
29
- norna page:move <old-url> <new-url> [--order <NNN>] [--no-aliases] [--write]
30
-
31
- Examples:
32
- norna page:move /guides/install/ /guides/installation/
33
- norna page:move /guides/install/ /reference/install/ --write
34
-
35
- The command is a dry run unless --write is present. If the old page has
36
- already been moved by hand, the same command reconciles its links and aliases.
37
- `.trim();
38
-
39
- const parseOrder = (value) => {
40
- if (!/^\d{1,3}$/.test(value ?? '')) {
41
- throw new Error(`Invalid --order "${value ?? ''}". Use an integer from 1 to 999.\n${usage}`);
42
- }
43
- const order = Number.parseInt(value, 10);
44
- if (order < 1 || order > 999) {
45
- throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.\n${usage}`);
46
- }
47
- return order;
48
- };
49
-
50
- const parseArgs = (rawArgs) => {
51
- const options = {
52
- help: false,
53
- order: null,
54
- preserveAliases: true,
55
- urls: [],
56
- write: false,
57
- };
58
- const seen = new Set();
59
-
60
- for (let index = 0; index < rawArgs.length; index += 1) {
61
- const arg = rawArgs[index];
62
- if (arg === '-h' || arg === '--help') {
63
- options.help = true;
64
- continue;
65
- }
66
- if (arg === '--write') {
67
- if (seen.has('write')) throw new Error(`--write may be specified only once.\n${usage}`);
68
- seen.add('write');
69
- options.write = true;
70
- continue;
71
- }
72
- if (arg === '--no-aliases') {
73
- if (seen.has('aliases')) throw new Error(`--no-aliases may be specified only once.\n${usage}`);
74
- seen.add('aliases');
75
- options.preserveAliases = false;
76
- continue;
77
- }
78
- if (arg === '--order' || arg.startsWith('--order=')) {
79
- if (seen.has('order')) throw new Error(`--order may be specified only once.\n${usage}`);
80
- seen.add('order');
81
- const inlineValue = arg.startsWith('--order=') ? arg.slice('--order='.length) : null;
82
- const value = inlineValue ?? rawArgs[index + 1];
83
- if (inlineValue === null) index += 1;
84
- options.order = parseOrder(value);
85
- continue;
86
- }
87
- if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}\n${usage}`);
88
- options.urls.push(arg);
89
- }
90
-
91
- if (!options.help && options.urls.length !== 2) {
92
- throw new Error(`page:move requires an old URL and a new URL.\n${usage}`);
93
- }
94
- return options;
95
- };
96
-
97
- const toDisplayPath = (filePath) => {
98
- const relativePath = path.relative(siteProjectRoot, filePath);
99
- return (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
100
- ? relativePath
101
- : filePath).split(path.sep).join('/');
102
- };
103
-
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
- const main = async () => {
218
- const options = parseArgs(process.argv.slice(2));
219
- if (options.help) {
220
- console.log(usage);
221
- return;
222
- }
223
-
224
- const siteStructure = await getSiteStructure();
225
- const [graph, publicFiles] = await Promise.all([
226
- getSiteLinkGraph({ siteStructure }),
227
- getSitePublicFiles(),
228
- ]);
229
- const plan = await createPageMovePlan({
230
- from: options.urls[0],
231
- graph,
232
- order: options.order,
233
- preserveAliases: options.preserveAliases,
234
- publicFiles,
235
- sitePagesDir,
236
- sitePagesLabel,
237
- siteStructure,
238
- to: options.urls[1],
239
- });
240
-
241
- console.log(formatPageMovePlan(plan, toDisplayPath).join('\n'));
242
- if (!options.write) {
243
- console.log('\nDry run only. Run the same command with --write to apply this plan.');
244
- return;
245
- }
246
-
247
- await applyPlan(plan);
248
- console.log(`\nPage move completed: ${plan.from} -> ${plan.to}`);
249
- };
250
-
2
+ import { applyPageMovePlan } from './lib/page-move-apply.mjs';
3
+ import { createPageMovePlan, formatPageMovePlan } from './lib/page-move-plan.mjs';
4
+ import { planEditorPagePlacement, applyEditorPagePlacement } from './lib/editor-page-placement.mjs';
5
+ import { parsePlacementArguments, confirmCliRenumbering, describeRenumbering } from './lib/page-placement-cli.mjs';
6
+ import { readEditorLinkState } from './lib/editor-site-links.mjs';
7
+ import { sitePagesDir, sitePagesLabel, siteProjectRoot, siteDir } from './lib/site-paths.mjs';
8
+
9
+ const usage = `Usage:
10
+ norna page:move <old-url> <new-url> [--order <integer>]
11
+ norna page:move <page-url> --before <target-url> | --after <target-url>
12
+ norna page:move <page-url> --parent <parent-url> [--first | --last]
13
+
14
+ Options: --no-aliases, --write, --dry-run, --allow-renumber
15
+ Dry run unless --write is present. The URL-to-URL form also reconciles a page
16
+ already moved by hand. Renumbering other pages requires confirmation.`;
17
+ const display = filename => path.relative(siteProjectRoot, filename) || filename;
251
18
  try {
252
- await main();
253
- } catch (error) {
254
- console.error(error instanceof Error ? error.message : String(error));
255
- process.exit(1);
256
- }
19
+ const options = parsePlacementArguments(process.argv.slice(2));
20
+ if (options.help) { console.log(usage); process.exit(0); }
21
+ if (options.slug !== null) throw new Error('Use the new URL to change a slug with page:move.');
22
+ const relative = options.placement !== null;
23
+ if (options.positionals.length !== (relative ? 1 : 2)) throw new Error(usage);
24
+ if (relative && options.order !== null) throw new Error('Do not combine --order with a placement selector.');
25
+ const state = await readEditorLinkState({ siteRoot: siteDir });
26
+ if (state.incomplete.length) throw new Error(state.incomplete.join('\n'));
27
+ let plan;
28
+ if (relative) {
29
+ const find = url => state.structure.nodes.find(node => (node.isHome ? '/' : `/${node.pagePath}/`) === url);
30
+ const source = find(options.positionals[0]), target = find(options.target);
31
+ if (!source || !target) throw new Error('Choose existing source and target page URLs.');
32
+ plan = await planEditorPagePlacement({ siteRoot: siteDir, sourcePath: source.contentPath, targetPath: target.contentPath,
33
+ placement: options.placement, preserveAliases: options.preserveAliases });
34
+ console.log(`Page placement: ${plan.sourceUrl} ${options.placement} ${options.target}\nOrder: ${plan.sourceOrder}`);
35
+ if (plan.movePreview) console.log(formatPageMovePlan(plan.movePreview, display).join('\n'));
36
+ else if (plan.orderChanges.length) console.log(describeRenumbering(plan.orderChanges, display));
37
+ } else {
38
+ plan = await createPageMovePlan({ from: options.positionals[0], to: options.positionals[1], order: options.order,
39
+ preserveAliases: options.preserveAliases, graph: state.graph, publicFiles: state.publicFiles,
40
+ siteStructure: state.structure, sitePagesDir, sitePagesLabel, generatedRoutes: state.generatedRoutes });
41
+ console.log(formatPageMovePlan(plan, display).join('\n'));
42
+ }
43
+ if (!options.write) { console.log('\nDry run only. Run with --write to apply this plan.'); process.exit(0); }
44
+ if (!await confirmCliRenumbering(plan.orderChanges, options)) { console.log('Cancelled. No files changed.'); process.exit(0); }
45
+ if (relative) await applyEditorPagePlacement(plan, { allowRenumber: true });
46
+ else await applyPageMovePlan(plan, { siteRoot: siteDir, generatedRoutes: state.generatedRoutes, allowRenumber: true });
47
+ console.log(`Page move completed: ${plan.sourceUrl ?? plan.from} -> ${plan.destinationUrl ?? plan.to}. ${plan.orderChanges.length} existing sibling page(s) renumbered.`);
48
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
@@ -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,
@@ -1,3 +1,4 @@
1
+ import { getSiteAttachments } from './lib/page-attachments.mjs';
1
2
  import { createInterface } from 'node:readline/promises';
2
3
  import { stdin as input, stdout as output } from 'node:process';
3
4
  import path from 'node:path';
@@ -233,7 +234,6 @@ try {
233
234
  const siteStructure = await getSiteStructure();
234
235
  const contentFiles = siteStructure.contentFiles;
235
236
  for (const warning of siteStructure.warnings) {
236
- if (warning.code === 'empty-category') continue;
237
237
  addIssue({
238
238
  severity: 'warning',
239
239
  message: warning.message,
@@ -273,7 +273,7 @@ for (const contentFile of contentFiles) {
273
273
  prelude,
274
274
  regions: sections,
275
275
  } = page;
276
- for (const issue of page.diagnostics.filter((candidate) => candidate.code === 'heading-inside-details')) {
276
+ for (const issue of page.diagnostics.filter((candidate) => ['heading-inside-details', 'invalid-markdown-image'].includes(candidate.code))) {
277
277
  addContentIssue(contentFile, issue);
278
278
  }
279
279
  for (const issue of page.noteDiagnostics) {
@@ -424,17 +424,20 @@ const listedNavigationTree = getListedSiteNavigationTree(siteStructure.nodes.map
424
424
  return {
425
425
  node: {
426
426
  ...node,
427
- navigation: {
428
- listed: node.kind === 'category'
429
- ? true
430
- : context?.frontmatterData.navigation?.listed ?? true,
431
- },
427
+ navigation: { listed: context?.frontmatterData.navigation?.listed ?? true },
432
428
  },
433
429
  };
434
430
  }));
435
431
 
436
432
  for (const context of contentFileContexts) {
437
433
  const childPages = getDirectChildPages(listedNavigationTree, context.contentFile.pagePath);
434
+ if (context.frontmatterData.page?.listChildren && childPages.length === 0) {
435
+ addContentIssue(context.contentFile, {
436
+ severity: 'warning',
437
+ message: 'page.listChildren has no listed direct child pages to display.',
438
+ fix: 'Add a listed direct child page, or remove page.listChildren while this page has no children.',
439
+ });
440
+ }
438
441
  const warnedChildren = new Set();
439
442
 
440
443
  for (const section of context.sections) {
@@ -444,7 +447,7 @@ for (const context of contentFileContexts) {
444
447
  if (childPages.length === 0) {
445
448
  addSectionIssue(context.contentFile, section, {
446
449
  severity: 'error',
447
- message: `page-list on line ${block.line} has no listed direct child pages to display. Navigation categories are not pages.`,
450
+ message: `page-list on line ${block.line} has no listed direct child pages to display.`,
448
451
  fix: 'Add a listed direct child page or remove the block.',
449
452
  });
450
453
  continue;
@@ -466,12 +469,17 @@ for (const context of contentFileContexts) {
466
469
  }
467
470
 
468
471
  const siteLinkGraph = createSiteLinkGraph({
472
+ siteUrl: projectConfig.site.url,
473
+ attachments: await getSiteAttachments(siteStructure),
469
474
  pageDocuments: contentFileContexts.map(({ contentFile, frontmatterData, page }) => ({
470
475
  contentFile,
471
476
  data: frontmatterData,
472
477
  document: page,
473
478
  })),
474
- publicFiles: await getSitePublicFiles(),
479
+ publicFiles: await getSitePublicFiles(undefined, projectConfig).catch(error => {
480
+ addIssue({ severity: 'error', message: error.message, fix: 'Use the supported public-file locations and verificationFiles settings.' });
481
+ return [];
482
+ }),
475
483
  siteStructure,
476
484
  generatedRoutes: getGeneratedSiteRoutes({ searchEnabled: projectConfig.search.enabled }),
477
485
  });
@@ -529,14 +537,6 @@ for (const context of contentFileContexts) {
529
537
  for (const section of sections) {
530
538
  if (!validSections.has(section)) continue;
531
539
 
532
- for (const reference of section.markdownImages) {
533
- addSectionIssue(contentFile, section, {
534
- severity: 'warning',
535
- message: `Markdown image "${reference.target}" references a local image that is not managed by Norna.`,
536
- fix: 'Use an image-stack, image-carousel, or card-list block for site images that should be validated, processed and synced.',
537
- });
538
- }
539
-
540
540
  const { blocks, errors: blockErrors } = blockResultsBySection.get(section) ?? { blocks: [], errors: [] };
541
541
  for (const error of blockErrors) {
542
542
  addSectionIssue(contentFile, section, {