@janga/norna 0.7.22 → 0.7.24

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 (88) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +15 -0
  3. package/bin/norna-cli.mjs +8 -2
  4. package/package.json +28 -3
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +52 -24
  7. package/schemas/content-frontmatter.schema.json +25 -5
  8. package/schemas/page-theme.schema.json +53 -25
  9. package/schemas/sitewide-content.schema.json +19 -18
  10. package/schemas/theme.schema.json +385 -252
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +36 -5
  13. package/scripts/deploy-site.mjs +0 -1
  14. package/scripts/dev-local.mjs +175 -112
  15. package/scripts/generate-search-index.mjs +57 -0
  16. package/scripts/init-site.mjs +1 -0
  17. package/scripts/lib/code-fence-metadata.mjs +223 -0
  18. package/scripts/lib/content-sync-apply.mjs +34 -0
  19. package/scripts/lib/content-sync-plan.mjs +179 -0
  20. package/scripts/lib/edit-source-link.mjs +49 -0
  21. package/scripts/lib/editor-language-service.mjs +160 -12
  22. package/scripts/lib/heading-ids.mjs +1 -1
  23. package/scripts/lib/image-presentation.mjs +6 -0
  24. package/scripts/lib/markdown-links.mjs +182 -0
  25. package/scripts/lib/navigation-model.mjs +44 -12
  26. package/scripts/lib/navigation-review.mjs +396 -0
  27. package/scripts/lib/norna-markdown-blocks.mjs +74 -26
  28. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  29. package/scripts/lib/page-aliases.mjs +184 -0
  30. package/scripts/lib/page-markdown.mjs +88 -7
  31. package/scripts/lib/page-move-plan.mjs +659 -0
  32. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  33. package/scripts/lib/presentation.mjs +10 -37
  34. package/scripts/lib/project-config.mjs +146 -19
  35. package/scripts/lib/public-asset-conventions.mjs +36 -2
  36. package/scripts/lib/schema-definitions.mjs +43 -15
  37. package/scripts/lib/schema-editor-metadata.mjs +60 -32
  38. package/scripts/lib/schema-value-definitions.mjs +6 -2
  39. package/scripts/lib/semantic-callouts.mjs +128 -0
  40. package/scripts/lib/site-content.mjs +28 -5
  41. package/scripts/lib/site-link-graph.mjs +357 -0
  42. package/scripts/lib/site-navigation-tree.mjs +85 -0
  43. package/scripts/lib/site-page-urls.mjs +10 -0
  44. package/scripts/lib/sitemap.mjs +34 -0
  45. package/scripts/lib/social-image-assets.mjs +30 -0
  46. package/scripts/lib/theme-presets.mjs +53 -25
  47. package/scripts/lib/theme-profiles.mjs +6 -8
  48. package/scripts/lib/yaml-config.mjs +6 -2
  49. package/scripts/move-site-page.mjs +256 -0
  50. package/scripts/review-navigation.mjs +32 -0
  51. package/scripts/sync-content-sections.mjs +182 -216
  52. package/scripts/sync-site-public.mjs +43 -3
  53. package/src/components/CodeBlockCopyScript.astro +97 -0
  54. package/src/components/DisplaySettings.astro +4 -4
  55. package/src/components/EditSourceLink.astro +17 -0
  56. package/src/components/ImageCarousel.astro +6 -3
  57. package/src/components/NavigationPageTree.astro +81 -86
  58. package/src/components/NavigationTreeControls.astro +77 -0
  59. package/src/components/PageAliasRedirect.astro +49 -0
  60. package/src/components/PageContentsNavigation.astro +21 -0
  61. package/src/components/PageList.astro +33 -0
  62. package/src/components/PageSequenceNavigation.astro +37 -0
  63. package/src/components/SearchPage.astro +151 -0
  64. package/src/components/SectionNavigationScript.astro +62 -6
  65. package/src/components/SiteNavigation.astro +50 -11
  66. package/src/components/SitePage.astro +113 -20
  67. package/src/components/SiteSection.astro +16 -3
  68. package/src/components/SiteTreeNavigation.astro +8 -2
  69. package/src/components/TreeNavigationScript.astro +207 -26
  70. package/src/content.config.ts +5 -2
  71. package/src/layouts/BaseLayout.astro +71 -10
  72. package/src/lib/readerPreferencesScript.mjs +2 -2
  73. package/src/lib/sectionContent.ts +32 -3
  74. package/src/lib/siteNavigation.ts +31 -52
  75. package/src/lib/sitePages.ts +3 -2
  76. package/src/lib/sitePublicAssets.ts +1 -0
  77. package/src/pages/404.astro +110 -0
  78. package/src/pages/[...slug].astro +45 -8
  79. package/src/styles/content.css +718 -0
  80. package/src/styles/foundations.css +284 -0
  81. package/src/styles/global.css +6 -2510
  82. package/src/styles/media.css +518 -0
  83. package/src/styles/navigation.css +526 -0
  84. package/src/styles/page-layout.css +786 -0
  85. package/src/styles/responsive.css +594 -0
  86. package/starters/basic/README.md +6 -19
  87. package/starters/basic/package.json +1 -0
  88. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -14,3 +14,4 @@ await runScript('scripts/sync-content-sections.mjs', ['--check']);
14
14
  await runScript('scripts/sync-site-public.mjs');
15
15
  await runScript('scripts/generate-images.mjs');
16
16
  await runAstroInherit(['build']);
17
+ await runScript('scripts/generate-search-index.mjs');
@@ -1,9 +1,12 @@
1
- import { siteConfigLabel, sitePublicLabel, siteThemeLabel } from './lib/site-paths.mjs';
1
+ import path from 'node:path';
2
+ import { siteConfigLabel, sitePagesDir, sitePublicLabel, siteThemeLabel } from './lib/site-paths.mjs';
2
3
  import { getLogoAssets, getPublicAssetInspection } from './lib/logo-assets.mjs';
3
- import { logoAssetFilenames } from './lib/public-asset-conventions.mjs';
4
+ import { logoAssetFilenames, socialImageAssetFilenames } from './lib/public-asset-conventions.mjs';
5
+ import { getSocialImageAssets } from './lib/social-image-assets.mjs';
4
6
  import { readSitewideContent } from './lib/sitewide-content.mjs';
5
7
  import { assertSectionBackgroundPatternCompatibility } from './lib/presentation.mjs';
6
8
  import { readThemeConfig, validatePageThemeFiles } from './lib/theme-config.mjs';
9
+ import { mergePageThemeConfig } from './lib/theme-presets.mjs';
7
10
 
8
11
  const formatErrorMessage = (error) => {
9
12
  if (error instanceof Error) {
@@ -14,10 +17,26 @@ const formatErrorMessage = (error) => {
14
17
  };
15
18
 
16
19
  try {
17
- const { projectConfig } = await import('./lib/project-config.mjs');
20
+ const { projectConfig, resolveThemeVisualConfig } = await import('./lib/project-config.mjs');
18
21
  const themeConfig = await readThemeConfig();
19
22
  const sitewideContent = await readSitewideContent();
20
23
  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
+ }
21
40
  if (projectConfig.navigation.mode === 'tree') {
22
41
  assertSectionBackgroundPatternCompatibility(themeConfig, siteThemeLabel, 'tree');
23
42
  for (const pageThemeFile of pageThemeFiles) {
@@ -25,6 +44,7 @@ try {
25
44
  }
26
45
  }
27
46
  const logoAssets = getLogoAssets();
47
+ const socialImageAssets = getSocialImageAssets();
28
48
  const publicAssetInspection = getPublicAssetInspection();
29
49
  const logoAssetPaths = logoAssetFilenames.map((filename) => `${sitePublicLabel}/${filename}`);
30
50
  for (const issue of publicAssetInspection.suspicious) {
@@ -38,6 +58,13 @@ try {
38
58
  ].join('\n'));
39
59
  }
40
60
 
61
+ if (socialImageAssets.length > 1) {
62
+ throw new Error([
63
+ `Found multiple social sharing images in ${sitePublicLabel}. Keep exactly one of ${socialImageAssetFilenames.join(', ')}.`,
64
+ ...socialImageAssets.map(({ filename }) => `- ${sitePublicLabel}/${filename}`),
65
+ ].join('\n'));
66
+ }
67
+
41
68
  if (logoAssets.length === 0) {
42
69
  if (sitewideContent.logo) {
43
70
  throw new Error(`Site-wide logo is configured, but no logo file was found. Add exactly one of ${logoAssetPaths.join(', ')}, or remove logo.`);
@@ -49,18 +76,22 @@ try {
49
76
  console.log('Config check passed.');
50
77
  console.log(`Site URL: ${projectConfig.site.url}`);
51
78
  console.log(`Base path: ${projectConfig.site.basePath}`);
79
+ console.log(`Edit links: ${projectConfig.editLink?.baseUrl ?? '(disabled)'}`);
52
80
  console.log(`Theme preset: ${themeConfig.preset ?? '(none)'}`);
53
81
  console.log(`Page width: ${projectConfig.layout.pageWidth}`);
54
82
  console.log(`Gutter: desktop ${projectConfig.layout.gutter.desktop}, mobile ${projectConfig.layout.gutter.mobile}`);
55
83
  console.log(`Content spacing: ${projectConfig.layout.contentSpacing}`);
56
84
  console.log(`Text width: ${projectConfig.layout.textWidth}`);
85
+ console.log(`Image presentation: ${projectConfig.images.presentation}`);
57
86
  console.log(`Image area width: ${projectConfig.images.width}`);
58
87
  console.log(`Image max available width: desktop ${projectConfig.images.maxAvailableWidthPercent.desktop}%, mobile ${projectConfig.images.maxAvailableWidthPercent.mobile}%`);
59
- console.log(`Image max available height: desktop ${projectConfig.images.maxAvailableHeightPercent.desktop}%, mobile ${projectConfig.images.maxAvailableHeightPercent.mobile}%`);
88
+ if (projectConfig.images.maxAvailableHeightPercent) {
89
+ console.log(`Image max available height: desktop ${projectConfig.images.maxAvailableHeightPercent.desktop}%, mobile ${projectConfig.images.maxAvailableHeightPercent.mobile}%`);
90
+ }
60
91
  console.log(`Font family: ${projectConfig.typography.fontFamily}`);
61
92
  console.log(`Language: ${projectConfig.locale.lang}`);
62
93
  console.log(`Navigation mode: ${projectConfig.navigation.mode}`);
63
- console.log(`Section tracking: ${projectConfig.navigation.sectionTracking ? 'enabled' : 'disabled'}`);
94
+ console.log(`Static search: ${projectConfig.search.enabled ? 'enabled' : 'disabled'}`);
64
95
  console.log(`Scroll behavior: ${projectConfig.navigation.scrollBehavior}`);
65
96
  } catch (error) {
66
97
  console.error('Config check failed.');
@@ -41,7 +41,6 @@ const allowedExactPaths = new Set([
41
41
  `${sitePublicLabel}/favicon.ico`,
42
42
  `${sitePublicLabel}/favicon.svg`,
43
43
  `${sitePublicLabel}/robots.txt`,
44
- `${sitePublicLabel}/sitemap.xml`,
45
44
  'tsconfig.json',
46
45
  ]);
47
46
  const failedConclusions = new Set(['action_required', 'cancelled', 'failure', 'startup_failure', 'timed_out']);
@@ -1,23 +1,32 @@
1
- import { execFile, spawn } from 'node:child_process';
1
+ import { execFile } from 'node:child_process';
2
2
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import net from 'node:net';
4
4
  import { networkInterfaces } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import { getAstroArgs, runAstroInherit } from './lib/astro-command.mjs';
8
- import projectConfig from './lib/project-config.mjs';
9
8
  import { astroCacheDir, engineRoot, siteProjectRoot } from './lib/site-paths.mjs';
10
9
 
11
10
  const execFileAsync = promisify(execFile);
12
11
 
13
- const port = 4321;
12
+ const port = Number.parseInt(process.env.NORNA_DEV_PORT ?? '4321', 10);
13
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
14
+ throw new Error('NORNA_DEV_PORT must be an integer from 1 through 65535.');
15
+ }
16
+ let projectConfig;
17
+ try {
18
+ ({ default: projectConfig } = await import('./lib/project-config.mjs'));
19
+ } catch (error) {
20
+ console.error(error instanceof Error ? error.message : String(error));
21
+ process.exit(1);
22
+ }
14
23
  const localHost = '127.0.0.1';
15
24
  const localUrl = `http://${localHost}:${port}${projectConfig.site.basePath}`;
16
25
  const probeUrls = [
17
26
  localUrl,
18
27
  `http://localhost:${port}${projectConfig.site.basePath}`,
19
28
  ];
20
- const skipOpen = process.env.WALDE_NO_OPEN === '1';
29
+ const skipOpen = process.env.NORNA_NO_OPEN === '1';
21
30
  const statePath = path.join(astroCacheDir, 'dev-local.json');
22
31
  const logPath = path.join(astroCacheDir, 'dev.log');
23
32
  const args = process.argv.slice(2);
@@ -42,19 +51,6 @@ const generateImages = async () => execFileAsync(process.execPath, [path.join(en
42
51
  maxBuffer: 1024 * 1024 * 10,
43
52
  });
44
53
 
45
- const getPortPids = async () => {
46
- try {
47
- const { stdout } = await execFileAsync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
48
- return [...new Set(stdout.trim().split(/\s+/).map(Number).filter(Number.isInteger))];
49
- } catch (error) {
50
- if (error.code === 1 || error.code === 'ENOENT') {
51
- return [];
52
- }
53
-
54
- throw error;
55
- }
56
- };
57
-
58
54
  const readState = async () => {
59
55
  try {
60
56
  return JSON.parse(await readFile(statePath, 'utf8'));
@@ -72,10 +68,9 @@ const getLanUrls = () => Object.values(networkInterfaces())
72
68
  .filter((network) => network?.family === 'IPv4' && !network.internal)
73
69
  .map((network) => `http://${network.address}:${port}${projectConfig.site.basePath}`);
74
70
 
75
- const writeState = async (pid, host) => {
71
+ const writeState = async (host) => {
76
72
  await mkdir(path.dirname(statePath), { recursive: true });
77
73
  await writeFile(statePath, `${JSON.stringify({
78
- pid,
79
74
  port,
80
75
  host,
81
76
  mode: host === '0.0.0.0' ? 'lan' : 'local',
@@ -88,6 +83,26 @@ const removeState = async () => {
88
83
  await rm(statePath, { force: true });
89
84
  };
90
85
 
86
+ const readDevLog = async () => {
87
+ try {
88
+ return await readFile(logPath, 'utf8');
89
+ } catch (error) {
90
+ if (error.code === 'ENOENT') return '';
91
+ throw error;
92
+ }
93
+ };
94
+
95
+ const getLogExcerpt = (source) => {
96
+ const lines = source.trim().split('\n');
97
+ if (lines.length === 1 && lines[0] === '') return '';
98
+
99
+ const structuredErrorIndex = lines.findLastIndex((line) => /^\[[^\]]*Error\]/u.test(line.trim()));
100
+ const genericErrorIndex = lines.findLastIndex((line) => /^Error(?::|\s+(?!reference\b))/u.test(line.trim()));
101
+ const errorIndex = structuredErrorIndex >= 0 ? structuredErrorIndex : genericErrorIndex;
102
+ const firstLine = errorIndex >= 0 ? errorIndex : Math.max(0, lines.length - 30);
103
+ return lines.slice(firstLine, firstLine + 30).join('\n');
104
+ };
105
+
91
106
  const isPortFreeOnHost = (loopbackHost) => new Promise((resolve, reject) => {
92
107
  const server = net.createServer();
93
108
 
@@ -110,14 +125,36 @@ const isPortFreeOnHost = (loopbackHost) => new Promise((resolve, reject) => {
110
125
  server.listen({ port, host: loopbackHost, ipv6Only: loopbackHost === '::1' });
111
126
  });
112
127
 
113
- const isPortFree = async () => {
114
- for (const loopbackHost of ['127.0.0.1']) {
115
- if (!(await isPortFreeOnHost(loopbackHost))) {
128
+ const isPortListeningOnHost = (host) => new Promise((resolve, reject) => {
129
+ const socket = net.createConnection({ port, host });
130
+ let settled = false;
131
+ const settle = (result) => {
132
+ if (settled) return;
133
+ settled = true;
134
+ socket.destroy();
135
+ resolve(result);
136
+ };
137
+
138
+ socket.once('connect', () => settle(true));
139
+ socket.once('error', (error) => {
140
+ if (['ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'EADDRNOTAVAIL'].includes(error.code)) {
141
+ settle(false);
142
+ return;
143
+ }
144
+
145
+ if (!settled) reject(error);
146
+ });
147
+ socket.setTimeout(500, () => settle(false));
148
+ });
149
+
150
+ const isPortFree = async (host = localHost) => {
151
+ for (const probeHost of ['127.0.0.1', '::1']) {
152
+ if (await isPortListeningOnHost(probeHost)) {
116
153
  return false;
117
154
  }
118
155
  }
119
156
 
120
- return true;
157
+ return isPortFreeOnHost(host);
121
158
  };
122
159
 
123
160
  const sleep = (milliseconds) => new Promise((resolve) => {
@@ -153,12 +190,12 @@ const waitForServer = async () => {
153
190
  throw new Error(`Timed out waiting for ${localUrl}`);
154
191
  };
155
192
 
156
- const waitForPidToStopListening = async (pid) => {
193
+ const waitForPortToBeFree = async (host = localHost) => {
157
194
  const startedAt = Date.now();
158
195
  const timeoutMs = 5_000;
159
196
 
160
197
  while (Date.now() - startedAt < timeoutMs) {
161
- if (!(await getPortPids()).includes(pid)) {
198
+ if (await isPortFree(host)) {
162
199
  return true;
163
200
  }
164
201
 
@@ -168,32 +205,71 @@ const waitForPidToStopListening = async (pid) => {
168
205
  return false;
169
206
  };
170
207
 
171
- const killPortPids = async (pids) => {
208
+ const parseWindowsPortPids = (stdout) => [...new Set(
209
+ stdout
210
+ .trim()
211
+ .split(/\r?\n/u)
212
+ .map((line) => Number.parseInt(line.trim(), 10))
213
+ .filter(Number.isInteger),
214
+ )];
215
+
216
+ const getPortPids = async () => {
217
+ try {
218
+ if (process.platform === 'win32') {
219
+ const { stdout } = await execFileAsync('powershell.exe', [
220
+ '-NoProfile',
221
+ '-Command',
222
+ `(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue).OwningProcess`,
223
+ ]);
224
+ return parseWindowsPortPids(stdout);
225
+ }
226
+
227
+ const { stdout } = await execFileAsync('lsof', [
228
+ '-nP',
229
+ `-iTCP:${port}`,
230
+ '-sTCP:LISTEN',
231
+ '-t',
232
+ ]);
233
+ return [...new Set(stdout.trim().split(/\s+/u).map(Number).filter(Number.isInteger))];
234
+ } catch (error) {
235
+ if (error.code === 1) return [];
236
+ if (error.code === 'ENOENT') {
237
+ const commandName = process.platform === 'win32' ? 'PowerShell' : 'lsof';
238
+ throw new Error(`Cannot use --kill because ${commandName} is not available. Stop the process on port ${port} manually, then rerun the command.`);
239
+ }
240
+
241
+ throw error;
242
+ }
243
+ };
244
+
245
+ const terminatePortProcesses = async (host) => {
246
+ const pids = await getPortPids();
247
+ if (pids.length === 0) {
248
+ throw new Error(`Port ${port} is occupied, but Norna could not identify its listening process. Stop that process manually, then rerun the command.`);
249
+ }
250
+
172
251
  for (const pid of pids) {
173
252
  console.log(`Stopping process ${pid} on port ${port}.`);
174
253
  try {
175
254
  process.kill(pid, 'SIGTERM');
176
255
  } catch (error) {
177
- if (error.code !== 'ESRCH') {
178
- throw error;
179
- }
256
+ if (error.code !== 'ESRCH') throw error;
180
257
  }
181
258
  }
182
259
 
183
- for (const pid of pids) {
184
- if (await waitForPidToStopListening(pid)) {
185
- continue;
186
- }
260
+ if (await waitForPortToBeFree(host)) return;
187
261
 
262
+ for (const pid of pids) {
188
263
  console.log(`Process ${pid} did not stop; sending SIGKILL.`);
189
264
  try {
190
265
  process.kill(pid, 'SIGKILL');
191
266
  } catch (error) {
192
- if (error.code !== 'ESRCH') {
193
- throw error;
194
- }
267
+ if (error.code !== 'ESRCH') throw error;
195
268
  }
196
- await waitForPidToStopListening(pid);
269
+ }
270
+
271
+ if (!(await waitForPortToBeFree(host))) {
272
+ throw new Error(`Port ${port} is still in use after stopping ${pids.join(', ')}.`);
197
273
  }
198
274
  };
199
275
 
@@ -218,65 +294,49 @@ const openBrowser = async () => {
218
294
 
219
295
  const stopServer = async ({ quiet = false } = {}) => {
220
296
  const state = await readState();
221
-
222
- await runAstro(['dev', 'stop']).catch(() => {});
223
-
224
- if (!state?.pid) {
225
- if (!quiet) {
226
- console.log('No dev:local server is tracked.');
227
- }
228
- return;
229
- }
230
-
231
- const listeningPids = await getPortPids();
232
-
233
- if (!listeningPids.includes(state.pid)) {
234
- await removeState();
235
- if (!quiet) {
236
- console.log('No tracked dev:local server is running.');
237
- }
238
- return;
239
- }
240
-
241
- process.kill(state.pid, 'SIGTERM');
242
-
243
- if (!(await waitForPidToStopListening(state.pid))) {
244
- process.kill(state.pid, 'SIGKILL');
245
- await waitForPidToStopListening(state.pid);
246
- }
247
-
297
+ const { stdout = '', stderr = '' } = await runAstro(['dev', 'stop']);
298
+ const stopped = `${stdout}\n${stderr}`.includes('Stopped dev server');
299
+ if (stopped) await waitForPortToBeFree(state?.host ?? localHost);
248
300
  await removeState();
249
301
 
250
302
  if (!quiet) {
251
- console.log(`Stopped dev server on ${localUrl}.`);
303
+ console.log(stopped
304
+ ? `Stopped dev server on ${localUrl}.`
305
+ : 'No dev server is tracked for this site.');
252
306
  }
253
307
  };
254
308
 
255
309
  const startServer = async ({ host = localHost, open = true, killBlockingPort = false } = {}) => {
256
310
  await stopServer({ quiet: true });
257
311
 
258
- let existingPids = await getPortPids();
259
- if (existingPids.length > 0 || !(await isPortFree())) {
260
- if (!killBlockingPort || existingPids.length === 0) {
261
- throw new Error(`Port ${port} is already in use. Stop the process using it, then rerun the dev command, or pass --kill.`);
312
+ if (!(await isPortFree(host))) {
313
+ if (!killBlockingPort) {
314
+ throw new Error(`Port ${port} is already in use. Stop the process using it, choose another port with NORNA_DEV_PORT, or rerun the command with --kill.`);
262
315
  }
263
316
 
264
- await killPortPids(existingPids);
265
- await removeState();
266
- existingPids = await getPortPids();
267
-
268
- if (existingPids.length > 0 || !(await isPortFree())) {
269
- throw new Error(`Port ${port} is still in use after trying to stop ${existingPids.join(', ')}.`);
270
- }
317
+ await terminatePortProcesses(host);
271
318
  }
272
319
 
273
320
  await syncSitePublic();
274
321
  await generateImages();
275
- await runAstroInherit(['dev', '--background', '--host', host, '--port', String(port)]);
322
+ const previousLog = await readDevLog();
323
+ try {
324
+ await runAstroInherit(['dev', '--background', '--host', host, '--port', String(port)]);
325
+ } catch (error) {
326
+ const currentLog = await readDevLog();
327
+ const newLog = currentLog.startsWith(previousLog)
328
+ ? currentLog.slice(previousLog.length)
329
+ : currentLog;
330
+ const excerpt = getLogExcerpt(newLog || currentLog);
331
+ throw new Error([
332
+ `Could not start the dev server at ${localUrl}.`,
333
+ excerpt ? `Astro reported:\n${excerpt}` : 'Astro did not write a detailed startup error.',
334
+ `Full log: ${logPath}`,
335
+ ].join('\n\n'), { cause: error });
336
+ }
276
337
  await waitForServer();
277
338
 
278
- const startedPids = await getPortPids();
279
- await writeState(startedPids[0] ?? null, host);
339
+ await writeState(host);
280
340
  if (open) {
281
341
  await openBrowser();
282
342
  } else {
@@ -295,13 +355,14 @@ const startServer = async ({ host = localHost, open = true, killBlockingPort = f
295
355
 
296
356
  const showStatus = async () => {
297
357
  const state = await readState();
298
- const listeningPids = await getPortPids();
358
+ const { stdout = '', stderr = '' } = await runAstro(['dev', 'status']);
359
+ const tracked = `${stdout}\n${stderr}`.includes('Dev server running at');
299
360
  const reachable = await isServerReachable();
300
361
 
301
- if (state?.pid && listeningPids.includes(state.pid)) {
302
- const reachability = reachable ? '' : ' The listener is active, but the URL probe did not respond.';
303
- console.log(`dev:${state.mode ?? 'local'} is running at ${localUrl} (pid ${state.pid}).${reachability}`);
304
- if (state.host === '0.0.0.0') {
362
+ if (tracked) {
363
+ const reachability = reachable ? '' : ' The server is tracked, but the site URL probe did not respond.';
364
+ console.log(`dev:${state?.mode ?? 'local'} is running at ${localUrl}.${reachability}`);
365
+ if (state?.host === '0.0.0.0') {
305
366
  const lanUrls = getLanUrls();
306
367
  if (lanUrls.length > 0) console.log(`On this local network: ${lanUrls.join(', ')}`);
307
368
  }
@@ -309,10 +370,7 @@ const showStatus = async () => {
309
370
  }
310
371
 
311
372
  if (reachable) {
312
- const detail = state?.pid
313
- ? `the tracked pid ${state.pid} is not listening`
314
- : 'no dev:local state file was found';
315
- console.log(`A server is responding at ${localUrl}, but ${detail}.`);
373
+ console.log(`A server is responding at ${localUrl}, but Astro does not track it for this site.`);
316
374
  return;
317
375
  }
318
376
 
@@ -325,11 +383,7 @@ const showStatus = async () => {
325
383
 
326
384
  const showLogs = async () => {
327
385
  if (shouldFollowLogs) {
328
- const tail = spawn('tail', ['-n', '80', '-f', logPath], { stdio: 'inherit' });
329
- await new Promise((resolve, reject) => {
330
- tail.once('exit', resolve);
331
- tail.once('error', reject);
332
- });
386
+ await runAstroInherit(['dev', 'logs', '--follow']);
333
387
  return;
334
388
  }
335
389
 
@@ -346,23 +400,32 @@ const showLogs = async () => {
346
400
  }
347
401
  };
348
402
 
349
- if (command === 'start') {
350
- await startServer({ open: !skipOpen, killBlockingPort: shouldKillBlockingPort });
351
- } else if (command === 'lan') {
352
- await startServer({ host: '0.0.0.0', open: !skipOpen, killBlockingPort: shouldKillBlockingPort });
353
- } else if (command === 'status') {
354
- await showStatus();
355
- } else if (command === 'logs') {
356
- await showLogs();
357
- } else if (command === 'restart') {
358
- const state = await readState();
359
- await startServer({
360
- host: state?.host === '0.0.0.0' ? '0.0.0.0' : localHost,
361
- open: false,
362
- killBlockingPort: shouldKillBlockingPort,
363
- });
364
- } else if (command === 'stop') {
365
- await stopServer();
366
- } else {
367
- throw new Error(`Unknown dev-local command: ${command}`);
403
+ const runCommand = async () => {
404
+ if (command === 'start') {
405
+ await startServer({ open: !skipOpen, killBlockingPort: shouldKillBlockingPort });
406
+ } else if (command === 'lan') {
407
+ await startServer({ host: '0.0.0.0', open: !skipOpen, killBlockingPort: shouldKillBlockingPort });
408
+ } else if (command === 'status') {
409
+ await showStatus();
410
+ } else if (command === 'logs') {
411
+ await showLogs();
412
+ } else if (command === 'restart') {
413
+ const state = await readState();
414
+ await startServer({
415
+ host: state?.host === '0.0.0.0' ? '0.0.0.0' : localHost,
416
+ open: false,
417
+ killBlockingPort: shouldKillBlockingPort,
418
+ });
419
+ } else if (command === 'stop') {
420
+ await stopServer();
421
+ } else {
422
+ throw new Error(`Unknown dev-local command: ${command}`);
423
+ }
424
+ };
425
+
426
+ try {
427
+ await runCommand();
428
+ } catch (error) {
429
+ console.error(error instanceof Error ? error.message : String(error));
430
+ process.exit(1);
368
431
  }
@@ -0,0 +1,57 @@
1
+ import { cp, mkdir, readdir, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import * as pagefind from 'pagefind';
4
+ import projectConfig from './lib/project-config.mjs';
5
+ import {
6
+ astroDistDir,
7
+ astroPublicDir,
8
+ } from './lib/site-paths.mjs';
9
+
10
+ const searchDirectoryName = 'pagefind';
11
+ const distSearchDirectory = path.join(astroDistDir, searchDirectoryName);
12
+ const localSearchDirectory = path.join(astroPublicDir, searchDirectoryName);
13
+
14
+ if (!projectConfig.search.enabled) {
15
+ await rm(localSearchDirectory, { force: true, recursive: true });
16
+ console.log('Static search is disabled.');
17
+ } else {
18
+ await rm(distSearchDirectory, { force: true, recursive: true });
19
+
20
+ try {
21
+ const { errors: createErrors, index } = await pagefind.createIndex();
22
+ if (!index || createErrors.length > 0) {
23
+ throw new Error(createErrors.join('\n') || 'Pagefind did not create an index.');
24
+ }
25
+
26
+ const { errors: indexingErrors, page_count: scannedPageCount } = await index.addDirectory({
27
+ path: astroDistDir,
28
+ });
29
+ if (indexingErrors.length > 0) throw new Error(indexingErrors.join('\n'));
30
+ if (scannedPageCount === 0) {
31
+ throw new Error('No rendered pages contained searchable editorial content.');
32
+ }
33
+
34
+ const { errors: writeErrors } = await index.writeFiles({
35
+ outputPath: distSearchDirectory,
36
+ });
37
+ if (writeErrors.length > 0) throw new Error(writeErrors.join('\n'));
38
+ const fragmentDirectory = path.join(distSearchDirectory, 'fragment');
39
+ const indexedPageCount = (await readdir(fragmentDirectory))
40
+ .filter((filename) => filename.endsWith('.pf_fragment'))
41
+ .length;
42
+ if (indexedPageCount === 0) {
43
+ throw new Error('No rendered pages contained searchable editorial content.');
44
+ }
45
+
46
+ await rm(localSearchDirectory, { force: true, recursive: true });
47
+ await mkdir(astroPublicDir, { recursive: true });
48
+ await cp(distSearchDirectory, localSearchDirectory, { recursive: true });
49
+
50
+ console.log(`Generated static search index for ${indexedPageCount} page${indexedPageCount === 1 ? '' : 's'}.`);
51
+ } catch (error) {
52
+ const message = error instanceof Error ? error.message : String(error);
53
+ throw new Error(`Could not generate the static search index. ${message}`);
54
+ } finally {
55
+ await pagefind.close();
56
+ }
57
+ }
@@ -165,6 +165,7 @@ const nornaScripts = {
165
165
  'norna:config:check': cliCommand('config:check'),
166
166
  'norna:content:check': cliCommand('content:check'),
167
167
  'norna:sync': cliCommand('content:sync'),
168
+ 'norna:navigation:review': cliCommand('navigation:review'),
168
169
  'norna:theme:presets': cliCommand('theme:presets'),
169
170
  'norna:theme:export': cliCommand('theme:export'),
170
171
  'norna:typography:profiles': cliCommand('typography profiles'),