@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
@@ -1,251 +1,32 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { mkdir, realpath, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
3
1
  import path from 'node:path';
4
- import { dump as dumpYaml } from 'js-yaml';
5
- import { slugifyAsciiIdentifier } from './lib/heading-ids.mjs';
6
- import { pageDirectoryPattern } from './lib/page-model.mjs';
7
- import {
8
- invocationRoot,
9
- siteDir,
10
- sitePagesDir,
11
- siteProjectRoot,
12
- } from './lib/site-paths.mjs';
13
- import { getSiteStructure } from './lib/site-structure.mjs';
14
-
15
- const usage = `
16
- Usage:
17
- norna page:add <title> [--parent <path>] [--slug <slug>] [--order <NNN>] [--dry-run]
18
- norna category:add <label> [--parent <path>] [--slug <slug>] [--order <NNN>] [--dry-run]
19
-
20
- Parent selection:
21
- --parent / Add a top-level node
22
- --parent /guides/ Add a child below the guides node
23
- no --parent Use the current node directory, or site/pages/
24
- `.trim();
25
-
26
- const parseValueOption = (rawArgs, index, name) => {
27
- const arg = rawArgs[index];
28
- if (arg === name) {
29
- const value = rawArgs[index + 1];
30
- if (!value || value.startsWith('-')) throw new Error(`${name} requires a value.\n${usage}`);
31
- return { consumed: 2, value };
32
- }
33
- if (arg.startsWith(`${name}=`)) {
34
- const value = arg.slice(name.length + 1);
35
- if (!value) throw new Error(`${name} requires a value.\n${usage}`);
36
- return { consumed: 1, value };
37
- }
38
- return null;
39
- };
40
-
41
- const parseArgs = (rawArgs) => {
42
- const options = {
43
- dryRun: false,
44
- help: false,
45
- order: null,
46
- parent: null,
47
- slug: null,
48
- titleParts: [],
49
- };
50
- const seen = new Set();
51
-
52
- for (let index = 0; index < rawArgs.length;) {
53
- const arg = rawArgs[index];
54
- if (arg === '-h' || arg === '--help') {
55
- options.help = true;
56
- index += 1;
57
- continue;
58
- }
59
- if (arg === '--dry-run') {
60
- if (seen.has('dryRun')) throw new Error(`--dry-run may be specified only once.\n${usage}`);
61
- seen.add('dryRun');
62
- options.dryRun = true;
63
- index += 1;
64
- continue;
65
- }
66
-
67
- let matched = false;
68
- for (const [name, key] of [['--order', 'order'], ['--parent', 'parent'], ['--slug', 'slug']]) {
69
- const parsed = parseValueOption(rawArgs, index, name);
70
- if (!parsed) continue;
71
- if (seen.has(key)) throw new Error(`${name} may be specified only once.\n${usage}`);
72
- seen.add(key);
73
- options[key] = parsed.value;
74
- index += parsed.consumed;
75
- matched = true;
76
- break;
77
- }
78
- if (matched) continue;
79
-
80
- if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}\n${usage}`);
81
- options.titleParts.push(arg);
82
- index += 1;
83
- }
84
-
85
- return options;
86
- };
87
-
88
- const parseOrder = (value) => {
89
- if (!/^\d{1,3}$/.test(value)) {
90
- throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.`);
91
- }
92
- const order = Number.parseInt(value, 10);
93
- if (order < 1 || order > 999) {
94
- throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.`);
95
- }
96
- return order;
97
- };
98
-
99
- const normalizeParentPath = (value) => {
100
- if (value === '/') return '';
101
- if (value.includes('\\') || value.includes('?') || value.includes('#')) {
102
- throw new Error(`Invalid --parent "${value}". Use a logical path such as /guides/installation/.`);
103
- }
104
- const normalized = value.replace(/^\/+|\/+$/g, '');
105
- if (!normalized || normalized.split('/').some((segment) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(segment))) {
106
- throw new Error(`Invalid --parent "${value}". Use / for the top level or a logical path such as /guides/installation/.`);
107
- }
108
- return normalized;
109
- };
110
-
111
- const getNextOrder = (siblings) => {
112
- const highestOrder = siblings.reduce((highest, node) => Math.max(highest, node.pageOrder), 0);
113
- const nextOrder = Math.floor(highestOrder / 10) * 10 + 10;
114
- if (nextOrder > 999) {
115
- throw new Error('No automatic page order remains below 1000. Reorder the sibling nodes before adding another one.');
116
- }
117
- return nextOrder;
118
- };
119
-
120
- const toDisplayPath = (filePath) => {
121
- const relativePath = path.relative(siteProjectRoot, filePath);
122
- return (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
123
- ? relativePath
124
- : filePath).split(path.sep).join('/');
125
- };
126
-
127
- const escapeMarkdownHeading = (value) => value.replace(/([\\`*_[\]{}<>#])/g, '\\$1');
128
-
129
- const canonicalDirectory = async (directory) => realpath(directory).catch((error) => {
130
- if (error?.code === 'ENOENT') return path.resolve(directory);
131
- throw error;
132
- });
133
-
134
- const resolveParent = async ({ nodes, parent }) => {
135
- if (parent !== null) {
136
- const parentPath = normalizeParentPath(parent);
137
- if (!parentPath) {
138
- return { collectionDir: sitePagesDir, node: null, pagePath: '' };
139
- }
140
- const node = nodes.find((candidate) => candidate.pagePath === parentPath);
141
- if (!node) {
142
- throw new Error(`Cannot find parent "${parent}". Use an existing logical page/category path, or / for the top level.`);
143
- }
144
- if (node.isHome) throw new Error('The homepage cannot contain child pages or categories.');
145
- return { collectionDir: path.join(node.nodeDir, 'pages'), node, pagePath: node.pagePath };
146
- }
147
-
148
- const currentDirectory = await canonicalDirectory(invocationRoot);
149
- if (currentDirectory === await canonicalDirectory(sitePagesDir)) {
150
- return { collectionDir: sitePagesDir, node: null, pagePath: '' };
151
- }
152
- let node = null;
153
- for (const candidate of nodes) {
154
- if (await canonicalDirectory(candidate.nodeDir) === currentDirectory) {
155
- node = candidate;
156
- break;
157
- }
158
- }
159
- if (!node) {
160
- throw new Error([
161
- 'Cannot infer where to add the node from the current directory.',
162
- `Run the command from ${toDisplayPath(sitePagesDir)} for a top-level node, from an existing page/category directory for a child, or pass --parent.`,
163
- ].join('\n'));
164
- }
165
- if (node.isHome) throw new Error('The homepage cannot contain child pages or categories.');
166
- return { collectionDir: path.join(node.nodeDir, 'pages'), node, pagePath: node.pagePath };
167
- };
168
-
169
- const assertCollectionDirectory = async (collectionDir) => {
170
- try {
171
- const info = await stat(collectionDir);
172
- if (!info.isDirectory()) throw new Error(`${toDisplayPath(collectionDir)} exists but is not a directory.`);
173
- return false;
174
- } catch (error) {
175
- if (error?.code !== 'ENOENT') throw error;
176
- await mkdir(collectionDir);
177
- return true;
178
- }
179
- };
180
-
181
- const writeNode = async ({ destination, kind, title }) => {
182
- const stagingRoot = path.join(siteDir, '.norna', 'create');
183
- await mkdir(stagingRoot, { recursive: true });
184
- const stagingDirectory = path.join(stagingRoot, `node-${process.pid}-${randomUUID()}`);
185
- await mkdir(stagingDirectory);
186
-
187
- try {
188
- if (kind === 'page') {
189
- await writeFile(path.join(stagingDirectory, 'content.md'), `# ${escapeMarkdownHeading(title)}\n\n## Introduction\n\nStart writing here.\n`);
190
- await mkdir(path.join(stagingDirectory, 'images'));
191
- } else {
192
- await writeFile(path.join(stagingDirectory, 'category.yaml'), dumpYaml({ label: title }, {
193
- lineWidth: -1,
194
- noRefs: true,
195
- }));
196
- await mkdir(path.join(stagingDirectory, 'pages'));
197
- }
198
- await rename(stagingDirectory, destination);
199
- } finally {
200
- await rm(stagingDirectory, { force: true, recursive: true });
201
- }
202
- };
203
-
204
- const [kind, ...rawArgs] = process.argv.slice(2);
205
- if (!['category', 'page'].includes(kind)) throw new Error(usage);
206
- const options = parseArgs(rawArgs);
207
- if (options.help) {
208
- console.log(usage);
209
- process.exit(0);
210
- }
211
-
212
- const title = options.titleParts.join(' ').trim();
213
- if (!title || /[\r\n]/.test(title)) throw new Error(`A one-line ${kind === 'page' ? 'page title' : 'category label'} is required.\n${usage}`);
214
- const slug = options.slug ?? slugifyAsciiIdentifier(title);
215
- if (!slug || !pageDirectoryPattern.test(`010-${slug}`)) {
216
- throw new Error(`Invalid ${options.slug === null ? 'generated ' : ''}slug "${slug}". Use lowercase ASCII letters, numbers, and single hyphens; pass --slug when automatic transliteration is unsuitable.`);
217
- }
218
-
219
- const structure = await getSiteStructure();
220
- const parent = await resolveParent({ nodes: structure.nodes, parent: options.parent });
221
- const siblings = structure.nodes.filter((node) => node.parentPagePath === (parent.pagePath || null) && !node.isHome);
222
- if (siblings.some((node) => node.pageId === slug)) {
223
- throw new Error(`Cannot create "${slug}" below ${parent.pagePath ? `/${parent.pagePath}/` : '/'}. A sibling with that slug already exists.`);
224
- }
225
- const order = options.order === null ? getNextOrder(siblings) : parseOrder(options.order);
226
- if (siblings.some((node) => node.pageOrder === order)) {
227
- throw new Error(`Cannot use order ${String(order).padStart(3, '0')} below ${parent.pagePath ? `/${parent.pagePath}/` : '/'}. A sibling already uses it.`);
228
- }
229
-
230
- const directoryName = `${String(order).padStart(3, '0')}-${slug}`;
231
- const destination = path.join(parent.collectionDir, directoryName);
232
- const pagePath = [parent.pagePath, slug].filter(Boolean).join('/');
233
- const action = options.dryRun ? 'Would create' : 'Created';
234
-
235
- if (!options.dryRun) {
236
- const createdCollection = await assertCollectionDirectory(parent.collectionDir);
237
- try {
238
- await writeNode({ destination, kind, title });
239
- } catch (error) {
240
- if (createdCollection) await rmdir(parent.collectionDir).catch(() => {});
241
- throw error;
242
- }
243
- }
244
-
245
- console.log(`${action} ${kind} "${title}" at ${toDisplayPath(destination)}.`);
246
- if (kind === 'page') {
247
- console.log(`URL: /${pagePath}/`);
248
- } else {
249
- console.log('This navigation category has no authored content page. Norna generates its destination from the child pages.');
250
- console.log(`Child URL prefix: /${pagePath}/`);
251
- }
2
+ import { invocationRoot, siteDir, siteProjectRoot } from './lib/site-paths.mjs';
3
+ import { createSiteNode, planSiteNodeCreation } from './lib/site-node-create.mjs';
4
+ import { parsePlacementArguments, confirmCliRenumbering, describeRenumbering } from './lib/page-placement-cli.mjs';
5
+
6
+ const usage = `Usage:
7
+ norna page:add <title> [--parent <path>] [--first | --last] [--slug <slug>]
8
+ norna page:add <title> --before <page-url> | --after <page-url>
9
+
10
+ Options: --order <integer>, --dry-run, --allow-renumber
11
+ Without a placement, append under --parent or the current page/site directory.
12
+ Before/after infer the parent. --order cannot accompany a relative placement.`;
13
+ const display = filename => path.relative(siteProjectRoot, filename) || filename;
14
+ try {
15
+ const [kind, ...args] = process.argv.slice(2);
16
+ if (kind !== 'page') throw new Error(usage);
17
+ const options = parsePlacementArguments(args);
18
+ if (options.help) { console.log(usage); process.exit(0); }
19
+ if (options.write || options.noAliases) throw new Error('page:add uses --dry-run; --write and --no-aliases are page:move options.');
20
+ const title = options.positionals.join(' ').trim();
21
+ if (!title) throw new Error(`A one-line page title is required.\n${usage}`);
22
+ const plan = await planSiteNodeCreation({ siteRoot: siteDir, kind, title, slug: options.slug, order: options.order,
23
+ parentPath: options.parent, invocationDirectory: invocationRoot,
24
+ placement: options.order !== null ? null : options.placement, targetPath: options.target });
25
+ console.log(`Page: ${title}\nSource: ${display(plan.destination)}\nURL: ${plan.url}\nOrder: ${plan.order}`);
26
+ const renumbering = describeRenumbering(plan.orderChanges, display);
27
+ if (renumbering) console.log(renumbering);
28
+ if (options.dryRun) { console.log(`Would create page "${title}". Dry run only. No files changed.`); process.exit(0); }
29
+ if (!await confirmCliRenumbering(plan.orderChanges, options)) { console.log('Cancelled. No files changed.'); process.exit(0); }
30
+ const result = await createSiteNode(plan, { allowRenumber: true });
31
+ console.log(`Created page "${title}" at ${display(plan.destination)}. ${result.renumberedPages} existing sibling page(s) renumbered.`);
32
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
@@ -9,9 +9,14 @@ const runScript = (relativePath, args = []) => runInherit(
9
9
  { cwd: siteProjectRoot },
10
10
  );
11
11
 
12
- await runScript('scripts/check-config.mjs');
13
- await runScript('scripts/sync-content-sections.mjs', ['--check']);
14
- await runScript('scripts/sync-site-public.mjs');
15
- await runScript('scripts/generate-images.mjs');
16
- await runAstroInherit(['build']);
17
- await runScript('scripts/generate-search-index.mjs');
12
+ try {
13
+ await runScript('scripts/check-config.mjs');
14
+ await runScript('scripts/sync-content-sections.mjs', ['--check']);
15
+ await runScript('scripts/sync-site-public.mjs');
16
+ await runScript('scripts/generate-images.mjs');
17
+ await runAstroInherit(['build']);
18
+ await runScript('scripts/generate-search-index.mjs');
19
+ } catch (error) {
20
+ console.error(error.message);
21
+ process.exitCode = 1;
22
+ }
@@ -1,12 +1,11 @@
1
- import path from 'node:path';
2
- import { siteConfigLabel, sitePagesDir, sitePublicLabel, siteThemeLabel } from './lib/site-paths.mjs';
1
+ import { siteConfigLabel, siteDir, sitePublicLabel } from './lib/site-paths.mjs';
3
2
  import { getLogoAssets, getPublicAssetInspection } from './lib/logo-assets.mjs';
4
3
  import { logoAssetFilenames, socialImageAssetFilenames } from './lib/public-asset-conventions.mjs';
5
4
  import { getSocialImageAssets } from './lib/social-image-assets.mjs';
6
5
  import { readSitewideContent } from './lib/sitewide-content.mjs';
7
6
  import { assertSectionBackgroundPatternCompatibility } from './lib/presentation.mjs';
8
7
  import { readThemeConfig, validatePageThemeFiles } from './lib/theme-config.mjs';
9
- import { mergePageThemeConfig } from './lib/theme-presets.mjs';
8
+ import { selectPageTheme } from './lib/theme-packages.mjs';
10
9
 
11
10
  const formatErrorMessage = (error) => {
12
11
  if (error instanceof Error) {
@@ -21,26 +20,11 @@ try {
21
20
  const themeConfig = await readThemeConfig();
22
21
  const sitewideContent = await readSitewideContent();
23
22
  const pageThemeFiles = await validatePageThemeFiles();
24
- const pageThemesByDirectory = new Map(pageThemeFiles.map((file) => [path.dirname(file.path), file]));
25
- for (const pageThemeFile of pageThemeFiles) {
26
- const ancestorThemes = [];
27
- let directory = path.dirname(pageThemeFile.path);
28
- while (directory.startsWith(`${sitePagesDir}${path.sep}`)) {
29
- const ancestorTheme = pageThemesByDirectory.get(directory);
30
- if (ancestorTheme) ancestorThemes.unshift(ancestorTheme);
31
- directory = path.dirname(directory);
32
- }
33
-
34
- const resolvedPageTheme = ancestorThemes.reduce(
35
- (current, ancestor) => mergePageThemeConfig(current, ancestor.config),
36
- themeConfig,
37
- );
38
- resolveThemeVisualConfig(resolvedPageTheme, pageThemeFile.label);
39
- }
40
- if (projectConfig.navigation.mode === 'tree') {
41
- assertSectionBackgroundPatternCompatibility(themeConfig, siteThemeLabel, 'tree');
42
- for (const pageThemeFile of pageThemeFiles) {
43
- assertSectionBackgroundPatternCompatibility(pageThemeFile.config, pageThemeFile.label, 'tree');
23
+ for (const pageDirectory of new Set(['.', ...pageThemeFiles.map((file) => file.pageDirectory)])) {
24
+ const { treeTheme, selected } = await selectPageTheme({ siteRoot: siteDir, pageDirectory });
25
+ for (const theme of [treeTheme, selected]) {
26
+ resolveThemeVisualConfig(theme.config, theme.label);
27
+ if (projectConfig.navigation.mode === 'tree') assertSectionBackgroundPatternCompatibility(theme.config, theme.label, 'tree');
44
28
  }
45
29
  }
46
30
  const logoAssets = getLogoAssets();
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readDevServerIdentity, verifyDevServer } from './lib/dev-server-identity.mjs';
1
3
  import { execFile } from 'node:child_process';
2
4
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
5
  import net from 'node:net';
@@ -10,20 +12,35 @@ import {
10
12
  engineRoot,
11
13
  siteProjectRoot,
12
14
  siteStateDir,
15
+ siteDir,
13
16
  } from './lib/site-paths.mjs';
14
17
 
15
18
  const execFileAsync = promisify(execFile);
16
19
 
17
- const port = Number.parseInt(process.env.NORNA_DEV_PORT ?? '4321', 10);
20
+ const portValue = process.env.NORNA_DEV_PORT ?? '4321';
21
+ const port = /^\d+$/.test(portValue) ? Number(portValue) : NaN;
22
+ const serverToken = process.env.NORNA_DEV_START_TOKEN || randomUUID();
18
23
  if (!Number.isInteger(port) || port < 1 || port > 65_535) {
19
24
  throw new Error('NORNA_DEV_PORT must be an integer from 1 through 65535.');
20
25
  }
26
+ const args = process.argv.slice(2);
27
+ const knownCommands = new Set(['start', 'lan', 'status', 'logs', 'restart', 'stop']);
28
+ const command = args.find((arg) => knownCommands.has(arg)) ?? args.find((arg) => !arg.startsWith('-')) ?? 'start';
29
+ const shouldKillBlockingPort = args.includes('--kill');
30
+ const shouldFollowLogs = args.includes('--follow');
31
+
21
32
  let projectConfig;
22
- try {
23
- ({ default: projectConfig } = await import('./lib/project-config.mjs'));
24
- } catch (error) {
25
- console.error(error instanceof Error ? error.message : String(error));
26
- process.exit(1);
33
+ if (['stop', 'status', 'logs'].includes(command)) {
34
+ // Recovery commands use the recorded server even while sources need conversion.
35
+ const recorded = await readFile(path.join(siteStateDir, 'dev', 'state.json'), 'utf8').then(JSON.parse).catch(() => null);
36
+ projectConfig = { site: { basePath: recorded?.url ? new URL(recorded.url).pathname : '/' } };
37
+ } else {
38
+ try {
39
+ ({ default: projectConfig } = await import('./lib/project-config.mjs'));
40
+ } catch (error) {
41
+ console.error(error instanceof Error ? error.message : String(error));
42
+ process.exit(1);
43
+ }
27
44
  }
28
45
  const localHost = '127.0.0.1';
29
46
  const localUrl = `http://${localHost}:${port}${projectConfig.site.basePath}`;
@@ -38,11 +55,6 @@ const legacyStatePath = path.join(astroCacheDir, 'dev-local.json');
38
55
  const astroStatePath = path.join(astroCacheDir, 'dev.json');
39
56
  const logPath = path.join(astroCacheDir, 'dev.log');
40
57
  const preparationLogPath = path.join(stateDirectory, 'preparation.log');
41
- const args = process.argv.slice(2);
42
- const knownCommands = new Set(['start', 'lan', 'status', 'logs', 'restart', 'stop']);
43
- const command = args.find((arg) => knownCommands.has(arg)) ?? args.find((arg) => !arg.startsWith('-')) ?? 'start';
44
- const shouldKillBlockingPort = args.includes('--kill');
45
- const shouldFollowLogs = args.includes('--follow');
46
58
 
47
59
  const runAstro = async (args, options = {}) => execFileAsync(process.execPath, getAstroArgs(args), {
48
60
  cwd: siteProjectRoot,
@@ -100,6 +112,8 @@ const writeState = async (host) => {
100
112
  await writeFile(statePath, `${JSON.stringify({
101
113
  port,
102
114
  pid,
115
+ token: serverToken,
116
+ siteRoot: siteDir,
103
117
  host,
104
118
  mode: host === '0.0.0.0' ? 'lan' : 'local',
105
119
  url: localUrl,
@@ -360,6 +374,12 @@ const openBrowser = async () => {
360
374
 
361
375
  const stopServer = async ({ quiet = false } = {}) => {
362
376
  const state = await readState();
377
+ if (process.env.NORNA_DEV_EXPECT_TOKEN) {
378
+ if (state?.token !== process.env.NORNA_DEV_EXPECT_TOKEN || state.port !== port || !(await verifyDevServer(state, siteDir))) throw new Error('Cannot verify this site’s server. No process was stopped.');
379
+ if (!(await terminateTrackedProcess(state, state.host ?? localHost))) throw new Error('The verified preview server did not stop. Try Stop Preview Server again.');
380
+ await removeState();
381
+ return;
382
+ }
363
383
  let astroError;
364
384
  let astroOutput = '';
365
385
  try {
@@ -390,7 +410,7 @@ const stopServer = async ({ quiet = false } = {}) => {
390
410
  };
391
411
 
392
412
  const startServer = async ({ host = localHost, open = true, killBlockingPort = false } = {}) => {
393
- await stopServer({ quiet: true });
413
+ if (process.env.NORNA_DEV_NO_RESTART !== '1') await stopServer({ quiet: true });
394
414
 
395
415
  if (!(await isPortFree(host))) {
396
416
  if (!killBlockingPort) {
@@ -425,8 +445,24 @@ const startServer = async ({ host = localHost, open = true, killBlockingPort = f
425
445
  }
426
446
  const previousLog = await readDevLog();
427
447
  try {
428
- await runAstroInherit(['dev', '--background', '--host', host, '--port', String(port)]);
448
+ await runAstroInherit(['dev', '--background', '--host', host, '--port', String(port)], { env: { ...process.env, NORNA_DEV_TOKEN: serverToken } });
449
+ // Record the process before probing rendered pages, so failed readiness
450
+ // checks can clean up only the server started by this request.
451
+ await writeState(host);
452
+ await waitForServer();
429
453
  } catch (error) {
454
+ let cleanupHint = '';
455
+ const identity = await readDevServerIdentity({ port, token: serverToken }, siteDir);
456
+ if (identity) {
457
+ const candidate = { port, ...identity };
458
+ try {
459
+ if (!(await terminateTrackedProcess(candidate, host))) throw new Error('The process did not stop.');
460
+ const recorded = await readState();
461
+ if (recorded?.token === serverToken) await removeState();
462
+ } catch (cleanup) {
463
+ cleanupHint = `Cleanup failed: ${cleanup.message} Use the site's dev:stop command after inspecting the log.`;
464
+ }
465
+ }
430
466
  const currentLog = await readDevLog();
431
467
  const newLog = currentLog.startsWith(previousLog)
432
468
  ? currentLog.slice(previousLog.length)
@@ -436,11 +472,9 @@ const startServer = async ({ host = localHost, open = true, killBlockingPort = f
436
472
  `Could not start the dev server at ${localUrl}.`,
437
473
  excerpt ? `Astro reported:\n${excerpt}` : 'Astro did not write a detailed startup error.',
438
474
  `Full log: ${logPath}`,
439
- ].join('\n\n'), { cause: error });
475
+ cleanupHint,
476
+ ].filter(Boolean).join('\n\n'), { cause: error });
440
477
  }
441
- await waitForServer();
442
-
443
- await writeState(host);
444
478
  if (open) {
445
479
  await openBrowser();
446
480
  } else {
@@ -4,7 +4,7 @@ import {
4
4
  renderThemePresetReference,
5
5
  themePresetNames,
6
6
  } from './lib/theme-presets.mjs';
7
- import { siteDir, siteThemeLabel } from './lib/site-paths.mjs';
7
+ import { siteDir, siteDirLabel, siteThemeLabel } from './lib/site-paths.mjs';
8
8
 
9
9
  const [presetName, ...extraArgs] = process.argv.slice(2);
10
10
 
@@ -14,7 +14,7 @@ if (!presetName || extraArgs.length > 0) {
14
14
 
15
15
  const filename = `orig-${presetName}-theme.yaml`;
16
16
  const outputPath = path.join(siteDir, filename);
17
- const outputLabel = path.posix.join(path.posix.dirname(siteThemeLabel), filename);
17
+ const outputLabel = path.posix.join(siteDirLabel, filename);
18
18
  const source = renderThemePresetReference(presetName, siteThemeLabel);
19
19
 
20
20
  try {