@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.
- package/README.md +14 -18
- package/astro.config.mjs +15 -0
- package/bin/norna-cli.mjs +8 -2
- package/package.json +28 -3
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +52 -24
- package/schemas/content-frontmatter.schema.json +25 -5
- package/schemas/page-theme.schema.json +53 -25
- package/schemas/sitewide-content.schema.json +19 -18
- package/schemas/theme.schema.json +385 -252
- package/scripts/build-site.mjs +1 -0
- package/scripts/check-config.mjs +36 -5
- package/scripts/deploy-site.mjs +0 -1
- package/scripts/dev-local.mjs +175 -112
- package/scripts/generate-search-index.mjs +57 -0
- package/scripts/init-site.mjs +1 -0
- package/scripts/lib/code-fence-metadata.mjs +223 -0
- package/scripts/lib/content-sync-apply.mjs +34 -0
- package/scripts/lib/content-sync-plan.mjs +179 -0
- package/scripts/lib/edit-source-link.mjs +49 -0
- package/scripts/lib/editor-language-service.mjs +160 -12
- package/scripts/lib/heading-ids.mjs +1 -1
- package/scripts/lib/image-presentation.mjs +6 -0
- package/scripts/lib/markdown-links.mjs +182 -0
- package/scripts/lib/navigation-model.mjs +44 -12
- package/scripts/lib/navigation-review.mjs +396 -0
- package/scripts/lib/norna-markdown-blocks.mjs +74 -26
- package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
- package/scripts/lib/page-aliases.mjs +184 -0
- package/scripts/lib/page-markdown.mjs +88 -7
- package/scripts/lib/page-move-plan.mjs +659 -0
- package/scripts/lib/presentation-palette-metadata.mjs +1 -1
- package/scripts/lib/presentation.mjs +10 -37
- package/scripts/lib/project-config.mjs +146 -19
- package/scripts/lib/public-asset-conventions.mjs +36 -2
- package/scripts/lib/schema-definitions.mjs +43 -15
- package/scripts/lib/schema-editor-metadata.mjs +60 -32
- package/scripts/lib/schema-value-definitions.mjs +6 -2
- package/scripts/lib/semantic-callouts.mjs +128 -0
- package/scripts/lib/site-content.mjs +28 -5
- package/scripts/lib/site-link-graph.mjs +357 -0
- package/scripts/lib/site-navigation-tree.mjs +85 -0
- package/scripts/lib/site-page-urls.mjs +10 -0
- package/scripts/lib/sitemap.mjs +34 -0
- package/scripts/lib/social-image-assets.mjs +30 -0
- package/scripts/lib/theme-presets.mjs +53 -25
- package/scripts/lib/theme-profiles.mjs +6 -8
- package/scripts/lib/yaml-config.mjs +6 -2
- package/scripts/move-site-page.mjs +256 -0
- package/scripts/review-navigation.mjs +32 -0
- package/scripts/sync-content-sections.mjs +182 -216
- package/scripts/sync-site-public.mjs +43 -3
- package/src/components/CodeBlockCopyScript.astro +97 -0
- package/src/components/DisplaySettings.astro +4 -4
- package/src/components/EditSourceLink.astro +17 -0
- package/src/components/ImageCarousel.astro +6 -3
- package/src/components/NavigationPageTree.astro +81 -86
- package/src/components/NavigationTreeControls.astro +77 -0
- package/src/components/PageAliasRedirect.astro +49 -0
- package/src/components/PageContentsNavigation.astro +21 -0
- package/src/components/PageList.astro +33 -0
- package/src/components/PageSequenceNavigation.astro +37 -0
- package/src/components/SearchPage.astro +151 -0
- package/src/components/SectionNavigationScript.astro +62 -6
- package/src/components/SiteNavigation.astro +50 -11
- package/src/components/SitePage.astro +113 -20
- package/src/components/SiteSection.astro +16 -3
- package/src/components/SiteTreeNavigation.astro +8 -2
- package/src/components/TreeNavigationScript.astro +207 -26
- package/src/content.config.ts +5 -2
- package/src/layouts/BaseLayout.astro +71 -10
- package/src/lib/readerPreferencesScript.mjs +2 -2
- package/src/lib/sectionContent.ts +32 -3
- package/src/lib/siteNavigation.ts +31 -52
- package/src/lib/sitePages.ts +3 -2
- package/src/lib/sitePublicAssets.ts +1 -0
- package/src/pages/404.astro +110 -0
- package/src/pages/[...slug].astro +45 -8
- package/src/styles/content.css +718 -0
- package/src/styles/foundations.css +284 -0
- package/src/styles/global.css +6 -2510
- package/src/styles/media.css +518 -0
- package/src/styles/navigation.css +526 -0
- package/src/styles/page-layout.css +786 -0
- package/src/styles/responsive.css +594 -0
- package/starters/basic/README.md +6 -19
- package/starters/basic/package.json +1 -0
- package/starters/basic/site/pages/000-home/content.md +13 -50
package/scripts/build-site.mjs
CHANGED
package/scripts/check-config.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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(`
|
|
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.');
|
package/scripts/deploy-site.mjs
CHANGED
|
@@ -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']);
|
package/scripts/dev-local.mjs
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
|
-
import { execFile
|
|
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.
|
|
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 (
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
302
|
-
const reachability = reachable ? '' : ' The
|
|
303
|
-
console.log(`dev:${state
|
|
304
|
-
if (state
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
+
}
|
package/scripts/init-site.mjs
CHANGED
|
@@ -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'),
|