@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,116 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const url = 'http://localhost:4321/';
|
|
10
|
+
const probeUrls = [url, 'http://127.0.0.1:4321/', 'http://[::1]:4321/'];
|
|
11
|
+
const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
12
|
+
const testTargets = process.argv.slice(2);
|
|
13
|
+
const playwrightTargets = testTargets.length > 0 ? testTargets : ['tests/navigation.spec.ts'];
|
|
14
|
+
|
|
15
|
+
const sleep = (milliseconds) => new Promise((resolve) => {
|
|
16
|
+
setTimeout(resolve, milliseconds);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const isReachable = async () => {
|
|
20
|
+
for (const probeUrl of probeUrls) {
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(probeUrl, { signal: AbortSignal.timeout(1_000) });
|
|
23
|
+
await response.arrayBuffer();
|
|
24
|
+
return response.ok;
|
|
25
|
+
} catch {
|
|
26
|
+
// Try the next loopback address.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await execFileAsync('curl', ['-fsI', url], {
|
|
32
|
+
cwd: root,
|
|
33
|
+
maxBuffer: 1024 * 1024,
|
|
34
|
+
});
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const waitForServer = async () => {
|
|
42
|
+
const startedAt = Date.now();
|
|
43
|
+
const timeoutMs = 30_000;
|
|
44
|
+
|
|
45
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
46
|
+
if (await isReachable()) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await sleep(500);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
throw new Error(`Timed out waiting for ${url}`);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const runCapture = async (command, args) => execFileAsync(command, args, {
|
|
57
|
+
cwd: root,
|
|
58
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const runInherit = (command, args, options = {}) => new Promise((resolve, reject) => {
|
|
62
|
+
const child = spawn(command, args, {
|
|
63
|
+
cwd: root,
|
|
64
|
+
stdio: 'inherit',
|
|
65
|
+
...options,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
child.once('error', reject);
|
|
69
|
+
child.once('exit', (code, signal) => {
|
|
70
|
+
if (code === 0) {
|
|
71
|
+
resolve();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const commandText = [command, ...args].join(' ');
|
|
76
|
+
reject(new Error(signal
|
|
77
|
+
? `${commandText} exited with signal ${signal}.`
|
|
78
|
+
: `${commandText} exited with code ${code}.`));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const ensureServer = async () => {
|
|
83
|
+
if (await isReachable()) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await runInherit(npmBin, ['run', 'dev:local'], {
|
|
88
|
+
env: {
|
|
89
|
+
...process.env,
|
|
90
|
+
WALDE_NO_OPEN: '1',
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
await waitForServer();
|
|
94
|
+
|
|
95
|
+
return true;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const stopServer = async () => {
|
|
99
|
+
await runCapture(npmBin, ['run', 'dev:stop']).catch(() => {});
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
let startedServer = false;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
startedServer = await ensureServer();
|
|
106
|
+
await runInherit(npmBin, ['exec', '--', 'playwright', 'test', ...playwrightTargets], {
|
|
107
|
+
env: {
|
|
108
|
+
...process.env,
|
|
109
|
+
PLAYWRIGHT_BASE_URL: url,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
} finally {
|
|
113
|
+
if (startedServer) {
|
|
114
|
+
await stopServer();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
|
|
9
|
+
const run = (command, args, options = {}) => new Promise((resolve, reject) => {
|
|
10
|
+
const child = spawn(command, args, {
|
|
11
|
+
cwd: repoRoot,
|
|
12
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
13
|
+
...options,
|
|
14
|
+
});
|
|
15
|
+
let stdout = '';
|
|
16
|
+
let stderr = '';
|
|
17
|
+
|
|
18
|
+
child.stdout?.on('data', (chunk) => {
|
|
19
|
+
stdout += chunk;
|
|
20
|
+
});
|
|
21
|
+
child.stderr?.on('data', (chunk) => {
|
|
22
|
+
stderr += chunk;
|
|
23
|
+
});
|
|
24
|
+
child.once('error', reject);
|
|
25
|
+
child.once('exit', (code, signal) => {
|
|
26
|
+
if (code === 0) {
|
|
27
|
+
resolve({ stdout, stderr });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const commandText = [command, ...args].join(' ');
|
|
32
|
+
reject(new Error([
|
|
33
|
+
signal
|
|
34
|
+
? `${commandText} exited with signal ${signal}.`
|
|
35
|
+
: `${commandText} exited with code ${code}.`,
|
|
36
|
+
stdout.trim(),
|
|
37
|
+
stderr.trim(),
|
|
38
|
+
].filter(Boolean).join('\n')));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const runInherit = (command, args, options = {}) => new Promise((resolve, reject) => {
|
|
43
|
+
const child = spawn(command, args, {
|
|
44
|
+
cwd: repoRoot,
|
|
45
|
+
stdio: 'inherit',
|
|
46
|
+
...options,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
child.once('error', reject);
|
|
50
|
+
child.once('exit', (code, signal) => {
|
|
51
|
+
if (code === 0) {
|
|
52
|
+
resolve();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const commandText = [command, ...args].join(' ');
|
|
57
|
+
reject(new Error(signal
|
|
58
|
+
? `${commandText} exited with signal ${signal}.`
|
|
59
|
+
: `${commandText} exited with code ${code}.`));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const runExpectFailure = async (command, args, options = {}, expectedText) => {
|
|
64
|
+
try {
|
|
65
|
+
await run(command, args, options);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
68
|
+
|
|
69
|
+
if (message.includes(expectedText)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
throw new Error(`Expected failing command output to include "${expectedText}".\n${message}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
throw new Error(`Expected command to fail: ${[command, ...args].join(' ')}`);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
80
|
+
const npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
81
|
+
const tempRoot = await mkdtemp(path.join(tmpdir(), 'norna-package-check-'));
|
|
82
|
+
const packDir = path.join(tempRoot, 'pack');
|
|
83
|
+
const unpackDir = path.join(tempRoot, 'unpack');
|
|
84
|
+
const siteProjectRoot = path.join(tempRoot, 'site-project');
|
|
85
|
+
const initializedSiteRoot = path.join(tempRoot, 'initialized-site');
|
|
86
|
+
const npmCachePath = path.resolve(
|
|
87
|
+
repoRoot,
|
|
88
|
+
process.env.NORNA_PACKAGE_CHECK_CACHE
|
|
89
|
+
?? path.join('node_modules', '.cache', 'norna-package-check-npm'),
|
|
90
|
+
);
|
|
91
|
+
const npmEnv = {
|
|
92
|
+
...process.env,
|
|
93
|
+
npm_config_cache: npmCachePath,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const assertFileExists = async (filePath) => {
|
|
97
|
+
try {
|
|
98
|
+
await readFile(filePath);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error?.code === 'ENOENT') {
|
|
101
|
+
throw new Error(`Packed package is missing ${path.relative(unpackDir, filePath)}.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const assertFileMissing = async (filePath) => {
|
|
109
|
+
try {
|
|
110
|
+
await readFile(filePath);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error?.code === 'ENOENT') {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
throw new Error(`Unexpected file exists: ${filePath}.`);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const assertFileIncludes = async (filePath, expectedText) => {
|
|
123
|
+
const fileContent = await readFile(filePath, 'utf8');
|
|
124
|
+
|
|
125
|
+
if (!fileContent.includes(expectedText)) {
|
|
126
|
+
throw new Error(`Expected ${filePath} to include: ${expectedText}`);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const assertFileExcludes = async (filePath, unexpectedText) => {
|
|
131
|
+
const fileContent = await readFile(filePath, 'utf8');
|
|
132
|
+
|
|
133
|
+
if (fileContent.includes(unexpectedText)) {
|
|
134
|
+
throw new Error(`Expected ${filePath} not to include: ${unexpectedText}`);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await mkdir(packDir, { recursive: true });
|
|
140
|
+
await mkdir(npmCachePath, { recursive: true });
|
|
141
|
+
const packResult = await run(npmBin, ['pack', '--pack-destination', packDir], {
|
|
142
|
+
env: npmEnv,
|
|
143
|
+
});
|
|
144
|
+
const tarballName = packResult.stdout.trim().split('\n').at(-1);
|
|
145
|
+
|
|
146
|
+
if (!tarballName?.endsWith('.tgz')) {
|
|
147
|
+
throw new Error(`npm pack did not report a tarball name.\n${packResult.stdout}${packResult.stderr}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const tarballPath = path.join(packDir, tarballName);
|
|
151
|
+
await mkdir(unpackDir, { recursive: true });
|
|
152
|
+
await runInherit('tar', ['-xzf', tarballPath, '-C', unpackDir]);
|
|
153
|
+
const packagedStarterRoot = path.join(unpackDir, 'package', 'starters', 'basic');
|
|
154
|
+
await Promise.all([
|
|
155
|
+
assertFileExists(path.join(unpackDir, 'package', 'docs', 'README.md')),
|
|
156
|
+
assertFileExists(path.join(unpackDir, 'package', 'docs', 'configuration.md')),
|
|
157
|
+
assertFileExists(path.join(unpackDir, 'package', 'docs', 'images-and-metadata.md')),
|
|
158
|
+
assertFileExists(path.join(packagedStarterRoot, '.github', 'workflows', 'deploy.yml')),
|
|
159
|
+
assertFileExists(path.join(packagedStarterRoot, 'package.json')),
|
|
160
|
+
assertFileExists(path.join(packagedStarterRoot, 'README.md')),
|
|
161
|
+
assertFileExists(path.join(packagedStarterRoot, 'site', 'config.mjs')),
|
|
162
|
+
assertFileExists(path.join(packagedStarterRoot, 'site', 'theme.md')),
|
|
163
|
+
assertFileExists(path.join(packagedStarterRoot, 'site', 'content.md')),
|
|
164
|
+
assertFileExists(path.join(packagedStarterRoot, 'site', 'images', 'work', '.gitkeep')),
|
|
165
|
+
assertFileExists(path.join(packagedStarterRoot, 'site', 'public', 'robots.txt')),
|
|
166
|
+
]);
|
|
167
|
+
await assertFileIncludes(
|
|
168
|
+
path.join(packagedStarterRoot, '.github', 'workflows', 'deploy.yml'),
|
|
169
|
+
'node-version: 24.18.0',
|
|
170
|
+
);
|
|
171
|
+
await cp(packagedStarterRoot, siteProjectRoot, {
|
|
172
|
+
recursive: true,
|
|
173
|
+
});
|
|
174
|
+
const packageJsonPath = path.join(siteProjectRoot, 'package.json');
|
|
175
|
+
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
|
176
|
+
packageJson.name = 'norna-package-check-site';
|
|
177
|
+
packageJson.dependencies['@janga/norna'] = tarballPath;
|
|
178
|
+
await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
179
|
+
await mkdir(path.join(siteProjectRoot, 'site', 'routes', 'about'), { recursive: true });
|
|
180
|
+
await writeFile(path.join(siteProjectRoot, 'site', 'routes', 'about', 'route-content.md'), `---
|
|
181
|
+
title: About the gallery
|
|
182
|
+
description: Route used by package checks.
|
|
183
|
+
navigation:
|
|
184
|
+
label: About
|
|
185
|
+
order: 20
|
|
186
|
+
sections:
|
|
187
|
+
- id: about
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## About {#about}
|
|
191
|
+
|
|
192
|
+
This route verifies that packaged norna sites can build route pages.
|
|
193
|
+
`);
|
|
194
|
+
|
|
195
|
+
await runInherit(npmBin, ['install', '--no-audit', '--no-fund', '--prefer-offline', '--fetch-retries=0'], { cwd: siteProjectRoot, env: npmEnv });
|
|
196
|
+
const nornaBinPath = path.join(siteProjectRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'norna.cmd' : 'norna');
|
|
197
|
+
await runInherit(nornaBinPath, ['init', initializedSiteRoot], { cwd: tempRoot, env: npmEnv });
|
|
198
|
+
await Promise.all([
|
|
199
|
+
assertFileExists(path.join(initializedSiteRoot, 'package.json')),
|
|
200
|
+
assertFileExists(path.join(initializedSiteRoot, 'site', 'config.mjs')),
|
|
201
|
+
assertFileExists(path.join(initializedSiteRoot, 'site', 'theme.md')),
|
|
202
|
+
assertFileMissing(path.join(initializedSiteRoot, '.DS_Store')),
|
|
203
|
+
assertFileMissing(path.join(initializedSiteRoot, 'site', '.DS_Store')),
|
|
204
|
+
]);
|
|
205
|
+
await runInherit(npxBin, ['norna', 'engine:version'], { cwd: path.join(siteProjectRoot, 'site', 'images', 'work'), env: npmEnv });
|
|
206
|
+
await runInherit(npxBin, ['norna', 'doctor'], { cwd: path.join(siteProjectRoot, 'site', 'images', 'work'), env: npmEnv });
|
|
207
|
+
await runInherit(npxBin, ['norna', 'config:check'], { cwd: path.join(siteProjectRoot, 'site', 'images', 'work'), env: npmEnv });
|
|
208
|
+
await runInherit(npxBin, ['norna', 'content:check'], { cwd: siteProjectRoot, env: npmEnv });
|
|
209
|
+
await runInherit(npxBin, ['norna', 'build'], { cwd: siteProjectRoot, env: npmEnv });
|
|
210
|
+
await assertFileExists(path.join(siteProjectRoot, 'site', '.norna', 'public', 'robots.txt'));
|
|
211
|
+
await assertFileExists(path.join(siteProjectRoot, 'dist', 'robots.txt'));
|
|
212
|
+
await assertFileExists(path.join(siteProjectRoot, 'dist', 'about', 'index.html'));
|
|
213
|
+
await assertFileMissing(path.join(siteProjectRoot, 'public', 'robots.txt'));
|
|
214
|
+
await assertFileExcludes(
|
|
215
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
216
|
+
'href="/favicon.svg"',
|
|
217
|
+
);
|
|
218
|
+
await assertFileExcludes(
|
|
219
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
220
|
+
'href="/favicon.ico"',
|
|
221
|
+
);
|
|
222
|
+
await assertFileIncludes(
|
|
223
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
224
|
+
'--section-heading-font-size-desktop: clamp(1.65rem, 4.6vw, 5.65rem)',
|
|
225
|
+
);
|
|
226
|
+
await assertFileIncludes(
|
|
227
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
228
|
+
"--font-sans: Arial, 'Helvetica Neue', Helvetica, sans-serif",
|
|
229
|
+
);
|
|
230
|
+
await assertFileIncludes(
|
|
231
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
232
|
+
'<meta name="format-detection" content="telephone=no">',
|
|
233
|
+
);
|
|
234
|
+
await assertFileIncludes(
|
|
235
|
+
path.join(siteProjectRoot, 'dist', 'about', 'index.html'),
|
|
236
|
+
'<link rel="canonical" href="https://example.com/about/">',
|
|
237
|
+
);
|
|
238
|
+
await assertFileIncludes(
|
|
239
|
+
path.join(siteProjectRoot, 'dist', 'about', 'index.html'),
|
|
240
|
+
'href="/about/" aria-current="page"',
|
|
241
|
+
);
|
|
242
|
+
await assertFileIncludes(
|
|
243
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
244
|
+
'--section-body-align-mobile: left',
|
|
245
|
+
);
|
|
246
|
+
await assertFileIncludes(
|
|
247
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
248
|
+
'--section-body-line-height: 1.5',
|
|
249
|
+
);
|
|
250
|
+
await assertFileIncludes(
|
|
251
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
252
|
+
'--section-body-paragraph-spacing: 0.85em',
|
|
253
|
+
);
|
|
254
|
+
await assertFileIncludes(
|
|
255
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
256
|
+
'--section-caption-line-height: 1.35',
|
|
257
|
+
);
|
|
258
|
+
await assertFileIncludes(
|
|
259
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
260
|
+
'class="inline-style inline-style-highlight" style="--inline-style-color: #ffd84d"',
|
|
261
|
+
);
|
|
262
|
+
await assertFileIncludes(
|
|
263
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
264
|
+
'--section-background-color: #000000',
|
|
265
|
+
);
|
|
266
|
+
await assertFileIncludes(
|
|
267
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
268
|
+
'--site-top-background-color: #000000',
|
|
269
|
+
);
|
|
270
|
+
await assertFileIncludes(
|
|
271
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
272
|
+
'--site-top-text-color: #f7f4ee',
|
|
273
|
+
);
|
|
274
|
+
await assertFileIncludes(
|
|
275
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
276
|
+
'--site-footer-background-color: #000000',
|
|
277
|
+
);
|
|
278
|
+
await assertFileIncludes(
|
|
279
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
280
|
+
'--site-footer-text-color: #f7f4ee',
|
|
281
|
+
);
|
|
282
|
+
await assertFileIncludes(
|
|
283
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
284
|
+
'--section-text-color: #f7f4ee',
|
|
285
|
+
);
|
|
286
|
+
await writeFile(path.join(siteProjectRoot, 'site', 'public', 'favicon.ico'), 'fake icon');
|
|
287
|
+
await runInherit(npxBin, ['norna', 'build'], { cwd: siteProjectRoot, env: npmEnv });
|
|
288
|
+
await assertFileExists(path.join(siteProjectRoot, 'dist', 'favicon.ico'));
|
|
289
|
+
await assertFileIncludes(
|
|
290
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
291
|
+
'<link rel="icon" sizes="any" href="/favicon.ico">',
|
|
292
|
+
);
|
|
293
|
+
await assertFileExcludes(
|
|
294
|
+
path.join(siteProjectRoot, 'dist', 'index.html'),
|
|
295
|
+
'href="/favicon.svg"',
|
|
296
|
+
);
|
|
297
|
+
const siteContentPath = path.join(siteProjectRoot, 'site', 'content.md');
|
|
298
|
+
const siteContent = await readFile(siteContentPath, 'utf8');
|
|
299
|
+
const siteThemePath = path.join(siteProjectRoot, 'site', 'theme.md');
|
|
300
|
+
const siteTheme = await readFile(siteThemePath, 'utf8');
|
|
301
|
+
await writeFile(siteThemePath, siteTheme.replace('\n preset: quiet-gallery', '\n preset: noisy-gallery'));
|
|
302
|
+
await runExpectFailure(
|
|
303
|
+
npxBin,
|
|
304
|
+
['norna', 'build'],
|
|
305
|
+
{ cwd: siteProjectRoot, env: npmEnv },
|
|
306
|
+
'presentation.typography.preset',
|
|
307
|
+
);
|
|
308
|
+
await writeFile(
|
|
309
|
+
siteThemePath,
|
|
310
|
+
siteTheme.replace('backgroundColor: "#000000"', 'backgroundColor: blue'),
|
|
311
|
+
);
|
|
312
|
+
await runExpectFailure(
|
|
313
|
+
npxBin,
|
|
314
|
+
['norna', 'build'],
|
|
315
|
+
{ cwd: siteProjectRoot, env: npmEnv },
|
|
316
|
+
'presentation.backgroundColor',
|
|
317
|
+
);
|
|
318
|
+
await writeFile(
|
|
319
|
+
siteThemePath,
|
|
320
|
+
siteTheme.replace('textColor: "#f7f4ee"', 'textColor: white'),
|
|
321
|
+
);
|
|
322
|
+
await runExpectFailure(
|
|
323
|
+
npxBin,
|
|
324
|
+
['norna', 'build'],
|
|
325
|
+
{ cwd: siteProjectRoot, env: npmEnv },
|
|
326
|
+
'presentation.textColor',
|
|
327
|
+
);
|
|
328
|
+
await writeFile(
|
|
329
|
+
siteThemePath,
|
|
330
|
+
siteTheme.replace(' color: "#ffd84d"', ' color: yellow'),
|
|
331
|
+
);
|
|
332
|
+
await runExpectFailure(
|
|
333
|
+
npxBin,
|
|
334
|
+
['norna', 'build'],
|
|
335
|
+
{ cwd: siteProjectRoot, env: npmEnv },
|
|
336
|
+
'presentation.inlineStyles.highlight.color',
|
|
337
|
+
);
|
|
338
|
+
await writeFile(siteThemePath, siteTheme);
|
|
339
|
+
await writeFile(
|
|
340
|
+
siteContentPath,
|
|
341
|
+
siteContent.replace(
|
|
342
|
+
'description: Minimal norna starter site.',
|
|
343
|
+
'description: Minimal norna starter site.\npresentation:\n typography:\n overrides:\n body:\n lineHeight: tight',
|
|
344
|
+
),
|
|
345
|
+
);
|
|
346
|
+
await runExpectFailure(
|
|
347
|
+
npxBin,
|
|
348
|
+
['norna', 'build'],
|
|
349
|
+
{ cwd: siteProjectRoot, env: npmEnv },
|
|
350
|
+
'presentation.typography.overrides.body.lineHeight',
|
|
351
|
+
);
|
|
352
|
+
await writeFile(
|
|
353
|
+
siteContentPath,
|
|
354
|
+
siteContent.replace(
|
|
355
|
+
' typography:\n preset: statement',
|
|
356
|
+
' typography:\n overrides:\n caption:\n spacing: wide',
|
|
357
|
+
),
|
|
358
|
+
);
|
|
359
|
+
await runExpectFailure(
|
|
360
|
+
npxBin,
|
|
361
|
+
['norna', 'build'],
|
|
362
|
+
{ cwd: siteProjectRoot, env: npmEnv },
|
|
363
|
+
'sections.0.presentation.typography.overrides.caption.spacing',
|
|
364
|
+
);
|
|
365
|
+
await writeFile(
|
|
366
|
+
siteContentPath,
|
|
367
|
+
siteContent.replace(' preset: statement', ' preset: dramatic'),
|
|
368
|
+
);
|
|
369
|
+
await runExpectFailure(
|
|
370
|
+
npxBin,
|
|
371
|
+
['norna', 'build'],
|
|
372
|
+
{ cwd: siteProjectRoot, env: npmEnv },
|
|
373
|
+
'sections.0.presentation.typography.preset',
|
|
374
|
+
);
|
|
375
|
+
const siteConfigPath = path.join(siteProjectRoot, 'site', 'config.mjs');
|
|
376
|
+
const siteConfig = await readFile(siteConfigPath, 'utf8');
|
|
377
|
+
await writeFile(
|
|
378
|
+
siteConfigPath,
|
|
379
|
+
siteConfig.replace(
|
|
380
|
+
'fontFamily: "Arial, \'Helvetica Neue\', Helvetica, sans-serif"',
|
|
381
|
+
'fontFamily: "Arial; color: red"',
|
|
382
|
+
),
|
|
383
|
+
);
|
|
384
|
+
await runExpectFailure(
|
|
385
|
+
npxBin,
|
|
386
|
+
['norna', 'config:check'],
|
|
387
|
+
{ cwd: siteProjectRoot, env: npmEnv },
|
|
388
|
+
'typography.fontFamily',
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
console.log('Package check passed.');
|
|
392
|
+
} finally {
|
|
393
|
+
await rm(tempRoot, { force: true, recursive: true });
|
|
394
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { access, mkdir, mkdtemp, readFile, 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 syncScript = path.join(repoRoot, 'scripts', 'sync-site-public.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 runSyncScript = (root, env = {}) => spawnSync(process.execPath, [syncScript], {
|
|
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) => {
|
|
30
|
+
const filePath = path.join(root, relativePath);
|
|
31
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
32
|
+
await writeFile(filePath, contents);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const readFixtureFile = (root, relativePath) => readFile(path.join(root, relativePath), 'utf8');
|
|
36
|
+
|
|
37
|
+
const withTempProject = async (run) => {
|
|
38
|
+
const root = await mkdtemp(path.join(tmpdir(), 'walde-site-public-'));
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await run(root);
|
|
42
|
+
} finally {
|
|
43
|
+
await rm(root, { force: true, recursive: true });
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
test('site:public copies configured public files, removes stale generated public files, and keeps images output', async () => {
|
|
48
|
+
await withTempProject(async (root) => {
|
|
49
|
+
await writeFixtureFile(root, 'custom-site/public/CNAME', 'example.com\n');
|
|
50
|
+
await writeFixtureFile(root, 'custom-site/public/robots.txt', 'User-agent: *\nAllow: /\n');
|
|
51
|
+
await writeFixtureFile(root, 'custom-site/public/nested/source.txt', 'source file\n');
|
|
52
|
+
await writeFixtureFile(root, 'custom-site/.norna/public/stale.txt', 'stale generated file\n');
|
|
53
|
+
await writeFixtureFile(root, 'custom-site/.norna/public/nested/old.txt', 'stale nested file\n');
|
|
54
|
+
await writeFixtureFile(root, 'custom-site/.norna/public/images/generated/keep.webp', 'generated image\n');
|
|
55
|
+
|
|
56
|
+
const result = runSyncScript(root, { NORNA_SITE_DIR: 'custom-site' });
|
|
57
|
+
const output = getOutput(result);
|
|
58
|
+
|
|
59
|
+
assert.equal(result.status, 0, output);
|
|
60
|
+
assert.match(output, /Synced custom-site\/public\/ to custom-site\/\.norna\/public\/\./);
|
|
61
|
+
assert.equal(await readFixtureFile(root, 'custom-site/.norna/public/CNAME'), 'example.com\n');
|
|
62
|
+
assert.equal(await readFixtureFile(root, 'custom-site/.norna/public/robots.txt'), 'User-agent: *\nAllow: /\n');
|
|
63
|
+
assert.equal(await readFixtureFile(root, 'custom-site/.norna/public/nested/source.txt'), 'source file\n');
|
|
64
|
+
assert.equal(await fileExists(path.join(root, 'custom-site/.norna/public/stale.txt')), false);
|
|
65
|
+
assert.equal(await fileExists(path.join(root, 'custom-site/.norna/public/nested/old.txt')), false);
|
|
66
|
+
assert.equal(await readFixtureFile(root, 'custom-site/.norna/public/images/generated/keep.webp'), 'generated image\n');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let failed = 0;
|
|
71
|
+
|
|
72
|
+
for (const { name, run } of tests) {
|
|
73
|
+
try {
|
|
74
|
+
await run();
|
|
75
|
+
console.log(`ok - ${name}`);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
failed += 1;
|
|
78
|
+
console.error(`not ok - ${name}`);
|
|
79
|
+
console.error(error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (failed > 0) {
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
const nornaBin = path.join(repoRoot, 'bin', 'norna.mjs');
|
|
9
|
+
const tempParent = path.join(repoRoot, 'node_modules', '.cache');
|
|
10
|
+
await mkdir(tempParent, { recursive: true });
|
|
11
|
+
const tempRoot = await mkdtemp(path.join(tempParent, 'norna-temporary-visibility-'));
|
|
12
|
+
const siteDir = path.join(tempRoot, 'site');
|
|
13
|
+
|
|
14
|
+
const runCli = (args, env = {}) => {
|
|
15
|
+
const result = spawnSync(process.execPath, [nornaBin, ...args], {
|
|
16
|
+
cwd: tempRoot,
|
|
17
|
+
encoding: 'utf8',
|
|
18
|
+
env: {
|
|
19
|
+
...process.env,
|
|
20
|
+
NORNA_SITE_DIR: siteDir,
|
|
21
|
+
...env,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (result.status !== 0) {
|
|
26
|
+
throw new Error([
|
|
27
|
+
`norna ${args.join(' ')} exited with code ${result.status}.`,
|
|
28
|
+
result.stdout.trim(),
|
|
29
|
+
result.stderr.trim(),
|
|
30
|
+
].filter(Boolean).join('\n'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await mkdir(path.join(siteDir, 'images', 'work'), { recursive: true });
|
|
38
|
+
await mkdir(path.join(siteDir, 'public'), { recursive: true });
|
|
39
|
+
await writeFile(path.join(siteDir, 'public', 'robots.txt'), 'User-agent: *\nAllow: /\n');
|
|
40
|
+
await writeFile(path.join(siteDir, 'config.mjs'), `export default {
|
|
41
|
+
site: {
|
|
42
|
+
url: 'https://example.com/',
|
|
43
|
+
},
|
|
44
|
+
github: {
|
|
45
|
+
repo: 'owner/example',
|
|
46
|
+
branch: 'main',
|
|
47
|
+
pagesWorkflow: 'Deploy to GitHub Pages',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
`);
|
|
51
|
+
await writeFile(path.join(siteDir, 'theme.md'), `---
|
|
52
|
+
presentation:
|
|
53
|
+
typography:
|
|
54
|
+
preset: quiet-gallery
|
|
55
|
+
frame:
|
|
56
|
+
colors: presentation
|
|
57
|
+
---
|
|
58
|
+
`);
|
|
59
|
+
await writeFile(path.join(siteDir, 'content.md'), `---
|
|
60
|
+
title: Temporary Visibility Test
|
|
61
|
+
description: Test site for temporary sections.
|
|
62
|
+
sections:
|
|
63
|
+
- id: expired
|
|
64
|
+
visible:
|
|
65
|
+
until: "2026-01-01"
|
|
66
|
+
gallery: []
|
|
67
|
+
- id: active
|
|
68
|
+
visible:
|
|
69
|
+
from: "2026-01-01"
|
|
70
|
+
until: "2026-12-31"
|
|
71
|
+
gallery: []
|
|
72
|
+
- id: always
|
|
73
|
+
gallery: []
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
## Expired {#expired}
|
|
77
|
+
|
|
78
|
+
Expired section text.
|
|
79
|
+
|
|
80
|
+
## Active {#active}
|
|
81
|
+
|
|
82
|
+
Active section text.
|
|
83
|
+
|
|
84
|
+
## Always {#always}
|
|
85
|
+
|
|
86
|
+
Always visible section text.
|
|
87
|
+
`);
|
|
88
|
+
|
|
89
|
+
runCli(['build'], { NORNA_TODAY: '2026-06-15' });
|
|
90
|
+
|
|
91
|
+
const html = await readFile(path.join(tempRoot, 'dist', 'index.html'), 'utf8');
|
|
92
|
+
assert.match(html, /Active section text/);
|
|
93
|
+
assert.match(html, /Always visible section text/);
|
|
94
|
+
assert.doesNotMatch(html, /Expired section text/);
|
|
95
|
+
|
|
96
|
+
console.log('Temporary visibility test passed.');
|
|
97
|
+
} finally {
|
|
98
|
+
await rm(tempRoot, { force: true, recursive: true });
|
|
99
|
+
}
|