@janga/norna 0.7.0
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/LICENSE +674 -0
- package/README.md +109 -0
- package/astro.config.mjs +17 -0
- package/bin/norna.mjs +170 -0
- package/docs/README.md +48 -0
- package/docs/command-organization.md +402 -0
- package/docs/commands.md +152 -0
- package/docs/configuration.md +376 -0
- package/docs/content.md +384 -0
- package/docs/engine-development.md +164 -0
- package/docs/getting-started.md +96 -0
- package/docs/images-and-metadata.md +88 -0
- package/docs/local-development.md +61 -0
- package/docs/publishing.md +81 -0
- package/docs/site-examples-structure-note.md +105 -0
- package/docs/site-structure.md +81 -0
- package/fixtures/basic/site/.norna/generated-images.json +1 -0
- package/fixtures/basic/site/config.mjs +59 -0
- package/fixtures/basic/site/content.md +17 -0
- package/fixtures/basic/site/images/work/.gitkeep +1 -0
- package/fixtures/basic/site/public/robots.txt +2 -0
- package/fixtures/basic/site/theme.md +7 -0
- package/package.json +90 -0
- package/scripts/build-site.mjs +16 -0
- package/scripts/check-config.mjs +37 -0
- package/scripts/deploy-site.mjs +389 -0
- package/scripts/dev-local.mjs +313 -0
- package/scripts/doctor.mjs +38 -0
- package/scripts/engine-version.mjs +137 -0
- package/scripts/generate-images.mjs +369 -0
- package/scripts/init-site.mjs +249 -0
- package/scripts/lib/astro-command.mjs +34 -0
- package/scripts/lib/ci-lockfile.mjs +34 -0
- package/scripts/lib/image-dimensions.mjs +78 -0
- package/scripts/lib/presentation.mjs +72 -0
- package/scripts/lib/project-config.mjs +322 -0
- package/scripts/lib/run-command.mjs +21 -0
- package/scripts/lib/site-content.mjs +392 -0
- package/scripts/lib/site-paths.mjs +127 -0
- package/scripts/lib/typography.mjs +166 -0
- package/scripts/release.mjs +77 -0
- package/scripts/show-typography.mjs +210 -0
- package/scripts/sync-content-sections.mjs +610 -0
- package/scripts/sync-site-public.mjs +42 -0
- package/scripts/test-ci-lockfile.mjs +65 -0
- package/scripts/test-content-check.mjs +364 -0
- package/scripts/test-engine-commands.mjs +128 -0
- package/scripts/test-navigation-preview.mjs +108 -0
- package/scripts/test-navigation.mjs +116 -0
- package/scripts/test-package-check.mjs +394 -0
- package/scripts/test-site-public.mjs +85 -0
- package/scripts/test-temporary-visibility.mjs +99 -0
- package/scripts/update-engine.mjs +127 -0
- package/scripts/watch-pages-deploy.mjs +430 -0
- package/src/components/GalleryGrid.astro +221 -0
- package/src/components/SiteNavigation.astro +410 -0
- package/src/components/SitePage.astro +69 -0
- package/src/components/SiteSection.astro +174 -0
- package/src/content.config.ts +171 -0
- package/src/layouts/BaseLayout.astro +90 -0
- package/src/lib/generatedImages.ts +63 -0
- package/src/lib/sectionContent.ts +125 -0
- package/src/lib/sitePages.ts +80 -0
- package/src/lib/sitePublicAssets.ts +39 -0
- package/src/lib/visibility.ts +35 -0
- package/src/pages/[slug].astro +31 -0
- package/src/pages/index.astro +16 -0
- package/src/styles/global.css +872 -0
- package/starters/basic/.github/workflows/deploy.yml +65 -0
- package/starters/basic/README.md +55 -0
- package/starters/basic/package.json +35 -0
- package/starters/basic/site/config.mjs +60 -0
- package/starters/basic/site/content.md +21 -0
- package/starters/basic/site/images/work/.gitkeep +1 -0
- package/starters/basic/site/public/robots.txt +2 -0
- package/starters/basic/site/theme.md +53 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { normalizeCiLockfile, verifyCiLockfile } from './lib/ci-lockfile.mjs';
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const tempRoot = await mkdtemp(path.join(tmpdir(), 'norna-ci-lockfile-'));
|
|
13
|
+
const npmCachePath = path.join(repoRoot, 'node_modules', '.cache', 'norna-npm');
|
|
14
|
+
const npmEnv = {
|
|
15
|
+
...process.env,
|
|
16
|
+
npm_config_cache: npmCachePath,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function escapeRegExp(value) {
|
|
20
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await writeFile(path.join(tempRoot, 'package.json'), `${JSON.stringify({
|
|
25
|
+
name: 'norna-ci-lockfile-test',
|
|
26
|
+
private: true,
|
|
27
|
+
dependencies: {
|
|
28
|
+
astro: '^7.1.3',
|
|
29
|
+
},
|
|
30
|
+
}, null, 2)}\n`);
|
|
31
|
+
|
|
32
|
+
const quietOptions = { env: npmEnv, stdio: 'ignore' };
|
|
33
|
+
await normalizeCiLockfile(tempRoot, quietOptions);
|
|
34
|
+
const lockfilePath = path.join(tempRoot, 'package-lock.json');
|
|
35
|
+
const corruptedLockfile = JSON.parse(await readFile(lockfilePath, 'utf8'));
|
|
36
|
+
const emnapiCoreVersion = corruptedLockfile.packages['node_modules/@emnapi/core']?.version;
|
|
37
|
+
assert.ok(emnapiCoreVersion, 'expected npm to include @emnapi/core in the normalized lockfile');
|
|
38
|
+
delete corruptedLockfile.packages['node_modules/@emnapi/core'];
|
|
39
|
+
await writeFile(lockfilePath, `${JSON.stringify(corruptedLockfile, null, 2)}\n`);
|
|
40
|
+
|
|
41
|
+
await assert.rejects(
|
|
42
|
+
execFileAsync(process.platform === 'win32' ? 'npx.cmd' : 'npx', [
|
|
43
|
+
'--yes',
|
|
44
|
+
'npm@11.16.0',
|
|
45
|
+
'ci',
|
|
46
|
+
'--dry-run',
|
|
47
|
+
'--ignore-scripts',
|
|
48
|
+
'--no-audit',
|
|
49
|
+
'--no-fund',
|
|
50
|
+
'--os=linux',
|
|
51
|
+
'--cpu=x64',
|
|
52
|
+
'--libc=glibc',
|
|
53
|
+
], { cwd: tempRoot, env: npmEnv }),
|
|
54
|
+
new RegExp(`Missing: @emnapi\\/core@${escapeRegExp(emnapiCoreVersion)} from lock file`),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
await normalizeCiLockfile(tempRoot, quietOptions);
|
|
58
|
+
await verifyCiLockfile(tempRoot, quietOptions);
|
|
59
|
+
|
|
60
|
+
const repairedLockfile = JSON.parse(await readFile(lockfilePath, 'utf8'));
|
|
61
|
+
assert.equal(repairedLockfile.packages['node_modules/@emnapi/core']?.version, emnapiCoreVersion);
|
|
62
|
+
console.log('ok - CI lockfile normalization repairs missing optional npm peers');
|
|
63
|
+
} finally {
|
|
64
|
+
await rm(tempRoot, { force: true, recursive: true });
|
|
65
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const contentScript = path.join(repoRoot, 'scripts', 'sync-content-sections.mjs');
|
|
10
|
+
const tests = [];
|
|
11
|
+
|
|
12
|
+
const test = (name, run) => {
|
|
13
|
+
tests.push({ name, run });
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const fileExists = async (filePath) => access(filePath).then(() => true, () => false);
|
|
17
|
+
|
|
18
|
+
const runContentScript = (root, args, env = {}) => spawnSync(process.execPath, [contentScript, ...args], {
|
|
19
|
+
cwd: root,
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
env: {
|
|
22
|
+
...process.env,
|
|
23
|
+
...env,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const getOutput = (result) => `${result.stdout}${result.stderr}`;
|
|
28
|
+
|
|
29
|
+
const writeFixtureFile = async (root, relativePath, contents = 'fixture image') => {
|
|
30
|
+
const filePath = path.join(root, relativePath);
|
|
31
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
32
|
+
await writeFile(filePath, contents);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const makePngHeader = ({ width, height }) => {
|
|
36
|
+
const buffer = Buffer.alloc(24);
|
|
37
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buffer, 0);
|
|
38
|
+
buffer.writeUInt32BE(13, 8);
|
|
39
|
+
buffer.write('IHDR', 12, 'ascii');
|
|
40
|
+
buffer.writeUInt32BE(width, 16);
|
|
41
|
+
buffer.writeUInt32BE(height, 20);
|
|
42
|
+
return buffer;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const defaultTheme = `---
|
|
46
|
+
presentation:
|
|
47
|
+
typography:
|
|
48
|
+
preset: quiet-gallery
|
|
49
|
+
frame:
|
|
50
|
+
colors: presentation
|
|
51
|
+
---
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
const withTempProject = async ({ site, theme = defaultTheme, files, siteDirectory = 'site' }, run) => {
|
|
55
|
+
const root = await mkdtemp(path.join(tmpdir(), 'walde-content-check-'));
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await writeFixtureFile(root, `${siteDirectory}/content.md`, site);
|
|
59
|
+
await writeFixtureFile(root, `${siteDirectory}/theme.md`, theme);
|
|
60
|
+
|
|
61
|
+
for (const file of files) {
|
|
62
|
+
if (typeof file === 'string') {
|
|
63
|
+
await writeFixtureFile(root, file);
|
|
64
|
+
} else {
|
|
65
|
+
await writeFixtureFile(root, file.path, file.contents);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await run(root);
|
|
70
|
+
} finally {
|
|
71
|
+
await rm(root, { force: true, recursive: true });
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const brokenSite = `---
|
|
76
|
+
sections:
|
|
77
|
+
- id: karin-walde
|
|
78
|
+
gallery:
|
|
79
|
+
- image: karin.jpg
|
|
80
|
+
- id: min-konst
|
|
81
|
+
gallery:
|
|
82
|
+
- image: vav.jpeg
|
|
83
|
+
- image: missing.jpeg
|
|
84
|
+
- image: duplicate.jpg
|
|
85
|
+
- image: duplicate.jpg
|
|
86
|
+
- id: mitt-hem
|
|
87
|
+
gallery:
|
|
88
|
+
- image: home.jpg
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
## Karin Walde {#karin-walde}
|
|
92
|
+
Text.
|
|
93
|
+
## Min konst {#min-konst}
|
|
94
|
+
Text.
|
|
95
|
+
## Extra {#extra}
|
|
96
|
+
Text.
|
|
97
|
+
## Mitt hem {#mitt-hem}
|
|
98
|
+
Text.
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
test('content:check groups section issues, global issues, and unreferenced images', async () => {
|
|
102
|
+
await withTempProject({
|
|
103
|
+
site: brokenSite,
|
|
104
|
+
files: [
|
|
105
|
+
'site/images/karin-walde/karin.jpg',
|
|
106
|
+
'site/images/karin-walde/unreferenced.jpg',
|
|
107
|
+
'site/images/min-konst/duplicate.jpg',
|
|
108
|
+
'site/images/mitt-hem/home.jpg',
|
|
109
|
+
'site/images/mitt-hem/vav.jpeg',
|
|
110
|
+
],
|
|
111
|
+
}, async (root) => {
|
|
112
|
+
const result = runContentScript(root, ['--check']);
|
|
113
|
+
const output = getOutput(result);
|
|
114
|
+
|
|
115
|
+
assert.equal(result.status, 1, output);
|
|
116
|
+
assert.match(output, /^Content check failed\./m);
|
|
117
|
+
assert.match(output, /Section and Gallery Issues\n\n\[min-konst\]\n Errors:/);
|
|
118
|
+
assert.match(output, /Image "vav\.jpeg" is used here but is located in site\/images\/mitt-hem\/\./);
|
|
119
|
+
assert.match(output, /Image "missing\.jpeg" does not exist anywhere under site\/images\/\./);
|
|
120
|
+
assert.match(output, /Image "duplicate\.jpg" is referenced more than once in this section\./);
|
|
121
|
+
assert.match(output, /\[extra\]\n Warnings:/);
|
|
122
|
+
assert.match(output, /Global Content Issues\n\nWarnings:\n- Markdown section order differs from frontmatter\./);
|
|
123
|
+
assert.match(output, /Unreferenced Images\nThese files are kept in site\/images\/ but are not mounted on the site:/);
|
|
124
|
+
assert.match(output, /site\/images\/karin-walde\/unreferenced\.jpg/);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const carouselAspectRatioSite = `---
|
|
129
|
+
sections:
|
|
130
|
+
- id: puppies
|
|
131
|
+
gallery:
|
|
132
|
+
- carousel:
|
|
133
|
+
- image: wide.png
|
|
134
|
+
- image: wider.png
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
## Puppies {#puppies}
|
|
138
|
+
Text.
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
test('content:check warns when carousel images use different aspect ratios', async () => {
|
|
142
|
+
await withTempProject({
|
|
143
|
+
site: carouselAspectRatioSite,
|
|
144
|
+
files: [
|
|
145
|
+
{
|
|
146
|
+
path: 'site/images/puppies/wide.png',
|
|
147
|
+
contents: makePngHeader({ width: 400, height: 300 }),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
path: 'site/images/puppies/wider.png',
|
|
151
|
+
contents: makePngHeader({ width: 600, height: 300 }),
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
}, async (root) => {
|
|
155
|
+
const result = runContentScript(root, ['--check']);
|
|
156
|
+
const output = getOutput(result);
|
|
157
|
+
|
|
158
|
+
assert.equal(result.status, 0, output);
|
|
159
|
+
assert.match(output, /^Content check completed with warnings\./m);
|
|
160
|
+
assert.match(output, /Carousel on line 5 uses images with different aspect ratios: wide\.png \(4:3\), wider\.png \(2:1\)\./);
|
|
161
|
+
assert.match(output, /Use images with exactly matching proportions in the same carousel/);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const inlineStyleTheme = `---
|
|
166
|
+
presentation:
|
|
167
|
+
inlineStyles:
|
|
168
|
+
highlight:
|
|
169
|
+
color: "#ffd84d"
|
|
170
|
+
---
|
|
171
|
+
`;
|
|
172
|
+
|
|
173
|
+
const inlineStyleSite = `---
|
|
174
|
+
sections:
|
|
175
|
+
- id: intro
|
|
176
|
+
gallery: []
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
## Intro {#intro}
|
|
180
|
+
This has [known text]{.highlight} and [unknown text]{.missing}.
|
|
181
|
+
`;
|
|
182
|
+
|
|
183
|
+
test('content:check fails when Markdown uses undefined inline styles', async () => {
|
|
184
|
+
await withTempProject({
|
|
185
|
+
site: inlineStyleSite,
|
|
186
|
+
theme: inlineStyleTheme,
|
|
187
|
+
files: [],
|
|
188
|
+
}, async (root) => {
|
|
189
|
+
const result = runContentScript(root, ['--check']);
|
|
190
|
+
const output = getOutput(result);
|
|
191
|
+
|
|
192
|
+
assert.equal(result.status, 1, output);
|
|
193
|
+
assert.match(output, /^Content check failed\./m);
|
|
194
|
+
assert.match(output, /Inline style "\.missing" is used in Markdown but is not defined in site\/theme\.md presentation\.inlineStyles\./);
|
|
195
|
+
assert.doesNotMatch(output, /"\.highlight" is used in Markdown/);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const badWhitespaceSite = `---
|
|
200
|
+
presentation:
|
|
201
|
+
typography:
|
|
202
|
+
preset: quiet-gallery
|
|
203
|
+
\u00a0\u00a0\u00a0\u00a0overrides:
|
|
204
|
+
body:
|
|
205
|
+
paragraphSpacing: 0.3em
|
|
206
|
+
sections:
|
|
207
|
+
- id: intro
|
|
208
|
+
gallery: []
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
## Intro {#intro}
|
|
212
|
+
Text.
|
|
213
|
+
`;
|
|
214
|
+
|
|
215
|
+
test('content:check explains invalid frontmatter indentation whitespace', async () => {
|
|
216
|
+
await withTempProject({
|
|
217
|
+
site: badWhitespaceSite,
|
|
218
|
+
files: [],
|
|
219
|
+
}, async (root) => {
|
|
220
|
+
const result = runContentScript(root, ['--check']);
|
|
221
|
+
const output = getOutput(result);
|
|
222
|
+
|
|
223
|
+
assert.equal(result.status, 1, output);
|
|
224
|
+
assert.match(output, /^Content check failed\./m);
|
|
225
|
+
assert.match(output, /Frontmatter line 5 uses tabs, non-breaking spaces, or invalid whitespace for indentation\./);
|
|
226
|
+
assert.match(output, /Replace the indentation on that line with ordinary spaces\./);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const badNestedValueSite = `---
|
|
231
|
+
presentation:
|
|
232
|
+
typography:
|
|
233
|
+
preset: quiet-gallery
|
|
234
|
+
overrides:
|
|
235
|
+
body:
|
|
236
|
+
paragraphSpacing: 0.3em
|
|
237
|
+
sections:
|
|
238
|
+
- id: intro
|
|
239
|
+
gallery: []
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
## Intro {#intro}
|
|
243
|
+
Text.
|
|
244
|
+
`;
|
|
245
|
+
|
|
246
|
+
test('content:check explains frontmatter indentation below scalar values', async () => {
|
|
247
|
+
await withTempProject({
|
|
248
|
+
site: badNestedValueSite,
|
|
249
|
+
files: [],
|
|
250
|
+
}, async (root) => {
|
|
251
|
+
const result = runContentScript(root, ['--check']);
|
|
252
|
+
const output = getOutput(result);
|
|
253
|
+
|
|
254
|
+
assert.equal(result.status, 1, output);
|
|
255
|
+
assert.match(output, /^Content check failed\./m);
|
|
256
|
+
assert.match(output, /Frontmatter line 5 is indented under line 4, but line 4 already has a value\./);
|
|
257
|
+
assert.match(output, /Move the later line to the same indentation level as its sibling/);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const topLevelGallerySite = `---
|
|
262
|
+
title: Example
|
|
263
|
+
description: Example site.
|
|
264
|
+
sections:
|
|
265
|
+
- id: intro
|
|
266
|
+
gallery:
|
|
267
|
+
- image: intro.jpg
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
## Intro {#intro}
|
|
271
|
+
Text.
|
|
272
|
+
`;
|
|
273
|
+
|
|
274
|
+
test('content:check explains likely misindented frontmatter keys', async () => {
|
|
275
|
+
await withTempProject({
|
|
276
|
+
site: topLevelGallerySite,
|
|
277
|
+
files: ['site/images/intro/intro.jpg'],
|
|
278
|
+
}, async (root) => {
|
|
279
|
+
const result = runContentScript(root, ['--check']);
|
|
280
|
+
const output = getOutput(result);
|
|
281
|
+
|
|
282
|
+
assert.equal(result.status, 1, output);
|
|
283
|
+
assert.match(output, /^Content check failed\./m);
|
|
284
|
+
assert.match(output, /Frontmatter line 6 defines "gallery" at the top level, but it is not a valid top-level content field\./);
|
|
285
|
+
assert.match(output, /Indent "gallery:" under the section or object it belongs to\./);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('content:check respects NORNA_SITE_DIR', async () => {
|
|
290
|
+
await withTempProject({
|
|
291
|
+
site: movableSite,
|
|
292
|
+
siteDirectory: 'custom-site',
|
|
293
|
+
files: [
|
|
294
|
+
'custom-site/images/min-konst/move-me.jpg',
|
|
295
|
+
'custom-site/images/mitt-hem/home.jpg',
|
|
296
|
+
],
|
|
297
|
+
}, async (root) => {
|
|
298
|
+
const result = runContentScript(root, ['--check'], { NORNA_SITE_DIR: 'custom-site' });
|
|
299
|
+
const output = getOutput(result);
|
|
300
|
+
|
|
301
|
+
assert.equal(result.status, 0, output);
|
|
302
|
+
assert.match(output, /Content check passed\./);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const movableSite = `---
|
|
307
|
+
sections:
|
|
308
|
+
- id: min-konst
|
|
309
|
+
gallery:
|
|
310
|
+
- image: move-me.jpg
|
|
311
|
+
- id: mitt-hem
|
|
312
|
+
gallery:
|
|
313
|
+
- image: home.jpg
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
## Min konst {#min-konst}
|
|
317
|
+
Text.
|
|
318
|
+
## Mitt hem {#mitt-hem}
|
|
319
|
+
Text.
|
|
320
|
+
`;
|
|
321
|
+
|
|
322
|
+
test('content:sync moves referenced images and keeps unreferenced images in place', async () => {
|
|
323
|
+
await withTempProject({
|
|
324
|
+
site: movableSite,
|
|
325
|
+
files: [
|
|
326
|
+
'site/images/mitt-hem/home.jpg',
|
|
327
|
+
'site/images/mitt-hem/move-me.jpg',
|
|
328
|
+
'site/images/mitt-hem/unreferenced.jpg',
|
|
329
|
+
],
|
|
330
|
+
}, async (root) => {
|
|
331
|
+
const syncResult = runContentScript(root, ['--write', '--yes']);
|
|
332
|
+
const syncOutput = getOutput(syncResult);
|
|
333
|
+
|
|
334
|
+
assert.equal(syncResult.status, 0, syncOutput);
|
|
335
|
+
assert.match(syncOutput, /Moved image "move-me\.jpg" to site\/images\/min-konst\/\./);
|
|
336
|
+
assert.equal(await fileExists(path.join(root, 'site/images/min-konst/move-me.jpg')), true);
|
|
337
|
+
assert.equal(await fileExists(path.join(root, 'site/images/mitt-hem/move-me.jpg')), false);
|
|
338
|
+
assert.equal(await fileExists(path.join(root, 'site/images/mitt-hem/unreferenced.jpg')), true);
|
|
339
|
+
|
|
340
|
+
const checkResult = runContentScript(root, ['--check']);
|
|
341
|
+
const checkOutput = getOutput(checkResult);
|
|
342
|
+
|
|
343
|
+
assert.equal(checkResult.status, 0, checkOutput);
|
|
344
|
+
assert.match(checkOutput, /Content check passed\./);
|
|
345
|
+
assert.match(checkOutput, /site\/images\/mitt-hem\/unreferenced\.jpg/);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
let failed = 0;
|
|
350
|
+
|
|
351
|
+
for (const { name, run } of tests) {
|
|
352
|
+
try {
|
|
353
|
+
await run();
|
|
354
|
+
console.log(`ok - ${name}`);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
failed += 1;
|
|
357
|
+
console.error(`not ok - ${name}`);
|
|
358
|
+
console.error(error);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (failed > 0) {
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const cliPath = path.join(repoRoot, 'bin', 'norna.mjs');
|
|
10
|
+
const packageJson = JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8'));
|
|
11
|
+
const tempRoot = await mkdtemp(path.join(tmpdir(), 'norna-engine-commands-'));
|
|
12
|
+
|
|
13
|
+
const runCli = (args, options = {}) => spawnSync(process.execPath, [cliPath, ...args], {
|
|
14
|
+
cwd: repoRoot,
|
|
15
|
+
encoding: 'utf8',
|
|
16
|
+
...options,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const versionResult = runCli(['engine:version']);
|
|
21
|
+
assert.equal(versionResult.status, 0, versionResult.stderr || versionResult.stdout);
|
|
22
|
+
assert.match(versionResult.stdout, /norna engine:version/);
|
|
23
|
+
assert.match(versionResult.stdout, new RegExp(`Installed norna: ${packageJson.version.replaceAll('.', '\\.')}`));
|
|
24
|
+
assert.match(versionResult.stdout, /Installed Astro: /);
|
|
25
|
+
|
|
26
|
+
const initializedSiteRoot = path.join(tempRoot, 'initialized-site');
|
|
27
|
+
const initResult = runCli(['init', initializedSiteRoot]);
|
|
28
|
+
assert.equal(initResult.status, 0, initResult.stderr || initResult.stdout);
|
|
29
|
+
assert.match(initResult.stdout, /Created norna site at /);
|
|
30
|
+
|
|
31
|
+
const initializedPackageJson = JSON.parse(await readFile(path.join(initializedSiteRoot, 'package.json'), 'utf8'));
|
|
32
|
+
assert.equal(initializedPackageJson.dependencies['@janga/norna'], packageJson.version);
|
|
33
|
+
assert.equal(initializedPackageJson.scripts['norna:engine:update'], 'norna engine:update');
|
|
34
|
+
assert.equal(initializedPackageJson.scripts['norna:engine:version'], 'norna engine:version');
|
|
35
|
+
assert.equal(initializedPackageJson.scripts['engine:update'], undefined);
|
|
36
|
+
assert.equal(initializedPackageJson.scripts['engine:version'], undefined);
|
|
37
|
+
|
|
38
|
+
const presetsResult = runCli(['typography:presets']);
|
|
39
|
+
assert.equal(presetsResult.status, 0, presetsResult.stderr || presetsResult.stdout);
|
|
40
|
+
assert.match(presetsResult.stdout, /quiet-gallery:/);
|
|
41
|
+
assert.match(presetsResult.stdout, /text-forward:/);
|
|
42
|
+
|
|
43
|
+
const showResult = runCli(['--site-dir', path.join(initializedSiteRoot, 'site'), 'typography:show']);
|
|
44
|
+
assert.equal(showResult.status, 0, showResult.stderr || showResult.stdout);
|
|
45
|
+
assert.match(showResult.stdout, /theme:/);
|
|
46
|
+
assert.match(showResult.stdout, /page:/);
|
|
47
|
+
assert.match(showResult.stdout, /preset: quiet-gallery/);
|
|
48
|
+
assert.match(showResult.stdout, /intro:/);
|
|
49
|
+
|
|
50
|
+
const initAgainResult = runCli(['init', initializedSiteRoot]);
|
|
51
|
+
assert.notEqual(initAgainResult.status, 0);
|
|
52
|
+
assert.match(initAgainResult.stderr, /Target directory must be empty/);
|
|
53
|
+
|
|
54
|
+
const customPureSiteRoot = path.join(tempRoot, 'custom-pure-site');
|
|
55
|
+
const customPureInitResult = runCli(['init', customPureSiteRoot, '--type', 'pure', '--site-dir', 'presentation']);
|
|
56
|
+
assert.equal(customPureInitResult.status, 0, customPureInitResult.stderr || customPureInitResult.stdout);
|
|
57
|
+
const customPurePackageJson = JSON.parse(await readFile(path.join(customPureSiteRoot, 'package.json'), 'utf8'));
|
|
58
|
+
assert.equal(customPurePackageJson.scripts.dev, 'npm run norna:dev');
|
|
59
|
+
assert.equal(customPurePackageJson.scripts.build, 'npm run norna:build');
|
|
60
|
+
assert.equal(customPurePackageJson.scripts['norna:dev'], 'norna --site-dir presentation dev:local');
|
|
61
|
+
assert.equal(customPurePackageJson.scripts['norna:build'], 'norna --site-dir presentation build');
|
|
62
|
+
await readFile(path.join(customPureSiteRoot, 'presentation', 'content.md'));
|
|
63
|
+
await readFile(path.join(customPureSiteRoot, 'presentation', 'config.mjs'));
|
|
64
|
+
await readFile(path.join(customPureSiteRoot, 'presentation', 'theme.md'));
|
|
65
|
+
|
|
66
|
+
const mixedProjectRoot = path.join(tempRoot, 'mixed-project');
|
|
67
|
+
await mkdir(mixedProjectRoot, { recursive: true });
|
|
68
|
+
await writeFile(path.join(mixedProjectRoot, 'package.json'), `${JSON.stringify({
|
|
69
|
+
name: 'mixed-project',
|
|
70
|
+
private: true,
|
|
71
|
+
type: 'module',
|
|
72
|
+
scripts: {
|
|
73
|
+
build: 'node build-app.mjs',
|
|
74
|
+
},
|
|
75
|
+
}, null, 2)}\n`);
|
|
76
|
+
const embeddedInitResult = runCli(['init', '.', '--type', 'embedded', '--site-dir', 'presentation'], {
|
|
77
|
+
cwd: mixedProjectRoot,
|
|
78
|
+
});
|
|
79
|
+
assert.equal(embeddedInitResult.status, 0, embeddedInitResult.stderr || embeddedInitResult.stdout);
|
|
80
|
+
assert.match(embeddedInitResult.stdout, /Added norna site directory at /);
|
|
81
|
+
const mixedPackageJson = JSON.parse(await readFile(path.join(mixedProjectRoot, 'package.json'), 'utf8'));
|
|
82
|
+
assert.equal(mixedPackageJson.dependencies['@janga/norna'], packageJson.version);
|
|
83
|
+
assert.equal(mixedPackageJson.scripts.build, 'node build-app.mjs');
|
|
84
|
+
assert.equal(mixedPackageJson.scripts.dev, undefined);
|
|
85
|
+
assert.equal(mixedPackageJson.scripts['norna:dev'], 'norna --site-dir presentation dev:local');
|
|
86
|
+
assert.equal(mixedPackageJson.scripts['norna:engine:update'], 'norna --site-dir presentation engine:update');
|
|
87
|
+
assert.equal(mixedPackageJson.scripts['norna:engine:version'], 'norna --site-dir presentation engine:version');
|
|
88
|
+
await readFile(path.join(mixedProjectRoot, 'presentation', 'content.md'));
|
|
89
|
+
await readFile(path.join(mixedProjectRoot, 'presentation', 'config.mjs'));
|
|
90
|
+
await readFile(path.join(mixedProjectRoot, 'presentation', 'theme.md'));
|
|
91
|
+
|
|
92
|
+
const conflictProjectRoot = path.join(tempRoot, 'conflict-project');
|
|
93
|
+
await mkdir(conflictProjectRoot, { recursive: true });
|
|
94
|
+
await writeFile(path.join(conflictProjectRoot, 'package.json'), `${JSON.stringify({
|
|
95
|
+
name: 'conflict-project',
|
|
96
|
+
private: true,
|
|
97
|
+
scripts: {
|
|
98
|
+
'norna:dev': 'vite --host 0.0.0.0',
|
|
99
|
+
},
|
|
100
|
+
}, null, 2)}\n`);
|
|
101
|
+
const conflictInitResult = runCli(['init', '.', '--type', 'embedded', '--site-dir', 'presentation'], {
|
|
102
|
+
cwd: conflictProjectRoot,
|
|
103
|
+
});
|
|
104
|
+
assert.notEqual(conflictInitResult.status, 0);
|
|
105
|
+
assert.match(conflictInitResult.stderr, /Refusing to overwrite existing npm scripts/);
|
|
106
|
+
assert.match(conflictInitResult.stderr, /norna:dev/);
|
|
107
|
+
|
|
108
|
+
const pureIntoExistingProjectResult = runCli(['init', '.', '--type', 'pure'], {
|
|
109
|
+
cwd: mixedProjectRoot,
|
|
110
|
+
});
|
|
111
|
+
assert.notEqual(pureIntoExistingProjectResult.status, 0);
|
|
112
|
+
assert.match(pureIntoExistingProjectResult.stderr, /use --type embedded/);
|
|
113
|
+
|
|
114
|
+
const updateFromEngineResult = runCli(['engine:update', '--skip-checks']);
|
|
115
|
+
assert.notEqual(updateFromEngineResult.status, 0);
|
|
116
|
+
assert.match(updateFromEngineResult.stderr, /must be run from a site repository/);
|
|
117
|
+
|
|
118
|
+
const releaseHelpResult = spawnSync(process.execPath, [path.join(repoRoot, 'scripts', 'release.mjs'), 'patch', '--help'], {
|
|
119
|
+
cwd: repoRoot,
|
|
120
|
+
encoding: 'utf8',
|
|
121
|
+
});
|
|
122
|
+
assert.equal(releaseHelpResult.status, 0, releaseHelpResult.stderr || releaseHelpResult.stdout);
|
|
123
|
+
assert.match(releaseHelpResult.stdout, /Usage: node scripts\/release\.mjs/);
|
|
124
|
+
|
|
125
|
+
console.log('ok - engine commands report versions, initialize sites, guard engine self-updates, and document release usage');
|
|
126
|
+
} finally {
|
|
127
|
+
await rm(tempRoot, { force: true, recursive: true });
|
|
128
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const root = process.cwd();
|
|
4
|
+
const host = 'localhost';
|
|
5
|
+
const port = 4322;
|
|
6
|
+
const url = `http://${host}:${port}/`;
|
|
7
|
+
const probeUrls = [url, `http://127.0.0.1:${port}/`, `http://[::1]:${port}/`];
|
|
8
|
+
const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
9
|
+
|
|
10
|
+
const sleep = (milliseconds) => new Promise((resolve) => {
|
|
11
|
+
setTimeout(resolve, milliseconds);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const runInherit = (command, args, options = {}) => new Promise((resolve, reject) => {
|
|
15
|
+
const child = spawn(command, args, {
|
|
16
|
+
cwd: root,
|
|
17
|
+
stdio: 'inherit',
|
|
18
|
+
...options,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
child.once('error', reject);
|
|
22
|
+
child.once('exit', (code, signal) => {
|
|
23
|
+
if (code === 0) {
|
|
24
|
+
resolve();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const commandText = [command, ...args].join(' ');
|
|
29
|
+
reject(new Error(signal
|
|
30
|
+
? `${commandText} exited with signal ${signal}.`
|
|
31
|
+
: `${commandText} exited with code ${code}.`));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const isReachable = async () => {
|
|
36
|
+
for (const probeUrl of probeUrls) {
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetch(probeUrl, { signal: AbortSignal.timeout(1_000) });
|
|
39
|
+
await response.arrayBuffer();
|
|
40
|
+
return response.ok;
|
|
41
|
+
} catch {
|
|
42
|
+
// Try the next loopback address.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return false;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const waitForPreview = async (previewProcess) => {
|
|
50
|
+
const startedAt = Date.now();
|
|
51
|
+
const timeoutMs = 30_000;
|
|
52
|
+
|
|
53
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
54
|
+
if (previewProcess.exitCode !== null) {
|
|
55
|
+
throw new Error(`astro preview exited before ${url} became reachable.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (await isReachable()) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await sleep(500);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
throw new Error(`Timed out waiting for ${url}`);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const stopPreview = async (previewProcess) => {
|
|
69
|
+
if (!previewProcess || previewProcess.exitCode !== null) return;
|
|
70
|
+
|
|
71
|
+
previewProcess.kill('SIGTERM');
|
|
72
|
+
|
|
73
|
+
const stopped = await Promise.race([
|
|
74
|
+
new Promise((resolve) => {
|
|
75
|
+
previewProcess.once('exit', () => resolve(true));
|
|
76
|
+
}),
|
|
77
|
+
sleep(5_000).then(() => false),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
if (!stopped && previewProcess.exitCode === null) {
|
|
81
|
+
previewProcess.kill('SIGKILL');
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
let previewProcess;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await runInherit(npmBin, ['run', 'build']);
|
|
89
|
+
|
|
90
|
+
previewProcess = spawn(npmBin, ['run', 'astro', '--', 'preview', '--host', host, '--port', String(port)], {
|
|
91
|
+
cwd: root,
|
|
92
|
+
stdio: 'inherit',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
previewProcess.once('error', (error) => {
|
|
96
|
+
throw error;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await waitForPreview(previewProcess);
|
|
100
|
+
await runInherit(npmBin, ['exec', '--', 'playwright', 'test', 'tests/navigation-preview.spec.ts'], {
|
|
101
|
+
env: {
|
|
102
|
+
...process.env,
|
|
103
|
+
PLAYWRIGHT_BASE_URL: url,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
} finally {
|
|
107
|
+
await stopPreview(previewProcess);
|
|
108
|
+
}
|