edgepress 2026.10.12 → 2026.10.13
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 +12 -2
- package/config.yml +171 -171
- package/content/assets/edgepress/brand/sources/export-manifest.json +181 -181
- package/content/assets/edgepress-markdown-guide.svg +21 -21
- package/content/guides/markdown-media.md +38 -0
- package/content/guides/product-demos.md +30 -30
- package/content/pages/deployment/en.json +1 -1
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +1 -1
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +1 -1
- package/languages/base/en.json +98 -98
- package/languages/packs/zh-CN.json +98 -98
- package/package.json +90 -90
- package/src/assets.js +187 -186
- package/src/browser-audit.js +158 -158
- package/src/build.js +293 -291
- package/src/cli.js +364 -364
- package/src/content.js +233 -233
- package/src/data-saver.js +3 -3
- package/src/generators.js +414 -420
- package/src/i18n.js +86 -86
- package/src/markdown.js +195 -160
- package/src/page-check.js +1 -1
- package/src/plugin-api.js +77 -77
- package/src/report-visuals.js +642 -642
- package/src/revision-cards.js +30 -30
- package/src/rss.js +100 -0
- package/src/theme.js +4 -4
- package/src/video-metadata.js +36 -0
- package/static/edgepress/fonts/faces.css +2217 -2217
- package/static/edgepress/media-viewer.js +67 -10
- package/static/edgepress/project-demo.js +83 -83
- package/static/edgepress/search.js +84 -84
- package/tests/assets-runtime.test.mjs +26 -26
- package/tests/audit.test.mjs +142 -142
- package/tests/brand-regression.test.mjs +110 -110
- package/tests/document-package.test.mjs +2 -2
- package/tests/embed-syntax.test.mjs +68 -68
- package/tests/image-viewer.test.mjs +36 -36
- package/tests/markdown-media.test.mjs +119 -0
- package/tests/oembed-layout.test.mjs +6 -1
- package/tests/project-demo.test.mjs +73 -73
- package/tests/release-version.test.mjs +28 -0
- package/tests/rss.test.mjs +54 -0
- package/tests/video-metadata.test.mjs +28 -0
- package/tests/writing.test.mjs +83 -83
- package/themes/atelier/assets/style.css +340 -340
- package/themes/default/assets/style.css +371 -371
- package/themes/default/layouts/partials/head.html +1 -1
- package/themes/folio/assets/style.css +515 -515
- package/themes/folio/theme.json +5 -5
- package/themes/signal/assets/style.css +332 -332
- package/tools/README.md +10 -10
- package/tools/assets/generate-brand-assets.py +160 -160
- package/tools/assets/video-preview.mjs +15 -0
- package/tools/recordings/demo-workbench.mjs +53 -53
- package/tools/recordings/desktop-recording.mjs +70 -70
- package/tools/recordings/vm-desktop.mjs +59 -59
package/src/cli.js
CHANGED
|
@@ -1,369 +1,369 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import {reportDirectory} from './report-path.js';
|
|
3
|
-
import {publicationTimestamp} from './publication-time.js';
|
|
4
|
-
import { access, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
5
|
-
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
|
-
import { tmpdir } from 'node:os';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { spawn } from 'node:child_process';
|
|
9
|
-
import { acquireBuildLock, buildSite } from './build.js';
|
|
10
|
-
import { loadConfig } from './config.js';
|
|
11
|
-
import { slugify } from './content.js';
|
|
12
|
-
import { checkPages } from './page-check.js';
|
|
13
|
-
import { checkCompatibility, writeIterationPlan } from './maintenance.js';
|
|
14
|
-
import { loadLanguagePacks, translate } from './i18n.js';
|
|
15
|
-
import { postTemplate } from './post-template.js';
|
|
16
|
-
import {deploySite} from './deploy.js';
|
|
17
|
-
import {formatPages} from '../tools/assets/format-pages.mjs';
|
|
18
|
-
|
|
19
|
-
const siteGitignore = ['node_modules/', 'dist/', '.edgepress/', '.wrangler/', '.dev.vars', '.vercel/', '.netlify/', '.edgeone/', '.esa/', '.env', '.env.*', '!.env.example', '*.tgz', ''].join('\n');
|
|
20
|
-
const siteReadme = [
|
|
21
|
-
'# My EdgePress site',
|
|
22
|
-
'',
|
|
23
|
-
'Static site source built with EdgePress. Use Node.js 22.12 or newer.',
|
|
24
|
-
'',
|
|
25
|
-
'## Start locally',
|
|
26
|
-
'',
|
|
27
|
-
' npm install',
|
|
28
|
-
' npm run dev',
|
|
29
|
-
'',
|
|
30
|
-
'Create an article with `npm run new -- post "My first article"`. Open the printed file path and write Markdown below the second `---` line. The command fills the title, date, and language. Run `npm run dev` to preview and `npm run build` before publishing.',
|
|
31
|
-
'',
|
|
32
|
-
'Pages use localized JSON files such as `content/pages/home/en.json`; VS Code can fold each row, cell and element and read the local schema. Articles stay in Markdown. Page layouts and theme customization are optional for everyday writing.',
|
|
33
|
-
'',
|
|
34
|
-
'## Deploy a static website',
|
|
35
|
-
'',
|
|
36
|
-
'Push this source repository to GitHub, then connect the repository to Cloudflare Workers Builds. The `build` and `deploy` scripts in `package.json` generate and deploy the static files. Set a unique Worker name in `wrangler.jsonc` first.',
|
|
37
|
-
'',
|
|
38
|
-
'Authenticate with the chosen platform, then run `edgepress deploy cloudflare`, `edgepress deploy vercel`, `edgepress deploy edgeone -n my-site`, `edgepress deploy netlify`, or `edgepress deploy esa --name my-site`.',
|
|
39
|
-
'',
|
|
40
|
-
'## Use another static web server',
|
|
41
|
-
'',
|
|
42
|
-
'Run `edgepress generate` and upload the contents of `dist/` to the server document root. OpenResty and Nginx can serve the generated directories as static files. Optional backends live in separate projects and are registered under plugins.consent.services in config.yml.',
|
|
43
|
-
''
|
|
44
|
-
].join('\n');
|
|
45
|
-
|
|
46
|
-
async function initializeProject() {
|
|
47
|
-
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
48
|
-
const root = process.cwd();
|
|
49
|
-
const directories = ['content', 'languages', 'plugins', 'static', 'themes'];
|
|
50
|
-
const files = ['.gitignore', 'README.md', 'config.yml', 'edgepress.config.mjs', 'project-compatibility.json', 'wrangler.jsonc', 'vercel.json', 'edgeone.json', 'esa.jsonc', 'netlify.toml'];
|
|
51
|
-
const destinations = [...directories, ...files];
|
|
52
|
-
const existing = [];
|
|
53
|
-
for (const relativePath of destinations) {
|
|
54
|
-
try { await access(resolve(root, relativePath)); existing.push(relativePath); }
|
|
55
|
-
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
56
|
-
}
|
|
57
|
-
if (existing.length) throw new Error('EdgePress init found existing project paths and made no changes: ' + existing.join(', '));
|
|
58
|
-
|
|
59
|
-
const packageData = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf8'));
|
|
60
|
-
let existingManifest = {};
|
|
61
|
-
let packageManifestExists = false;
|
|
62
|
-
try {
|
|
63
|
-
const manifestPath = resolve(root, 'package.json');
|
|
64
|
-
const manifestInfo = await lstat(manifestPath);
|
|
65
|
-
if (manifestInfo.isSymbolicLink() || !manifestInfo.isFile()) throw new Error('Refusing to edit an unsafe package.json path');
|
|
66
|
-
packageManifestExists = true;
|
|
67
|
-
existingManifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
68
|
-
if (!existingManifest || Array.isArray(existingManifest) || typeof existingManifest !== 'object') throw new Error('package.json must contain a JSON object');
|
|
69
|
-
} catch (error) {
|
|
70
|
-
if (error.code !== 'ENOENT') throw error;
|
|
71
|
-
}
|
|
72
|
-
const projectManifest = {
|
|
73
|
-
...existingManifest,
|
|
74
|
-
name: existingManifest.name || 'my-edgepress-site',
|
|
75
|
-
version: existingManifest.version || '1.0.0',
|
|
76
|
-
private: existingManifest.private ?? true,
|
|
77
|
-
type: 'module',
|
|
78
|
-
scripts: {
|
|
79
|
-
...(existingManifest.scripts || {}),
|
|
80
|
-
new: 'edgepress new',
|
|
81
|
-
generate: 'edgepress generate',
|
|
82
|
-
build: 'edgepress build',
|
|
83
|
-
dev: 'edgepress server',
|
|
84
|
-
preview: 'edgepress server',
|
|
85
|
-
check: 'edgepress check',
|
|
86
|
-
doctor: 'edgepress doctor',
|
|
87
|
-
deploy: 'edgepress deploy',
|
|
88
|
-
'deploy:cloudflare': 'edgepress deploy cloudflare',
|
|
89
|
-
'deploy:netlify': 'edgepress deploy netlify',
|
|
90
|
-
'deploy:vercel': 'edgepress deploy vercel',
|
|
91
|
-
'deploy:edgeone': 'edgepress deploy edgeone',
|
|
92
|
-
'deploy:esa': 'edgepress deploy esa'
|
|
93
|
-
},
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {reportDirectory} from './report-path.js';
|
|
3
|
+
import {publicationTimestamp} from './publication-time.js';
|
|
4
|
+
import { access, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { acquireBuildLock, buildSite } from './build.js';
|
|
10
|
+
import { loadConfig } from './config.js';
|
|
11
|
+
import { slugify } from './content.js';
|
|
12
|
+
import { checkPages } from './page-check.js';
|
|
13
|
+
import { checkCompatibility, writeIterationPlan } from './maintenance.js';
|
|
14
|
+
import { loadLanguagePacks, translate } from './i18n.js';
|
|
15
|
+
import { postTemplate } from './post-template.js';
|
|
16
|
+
import {deploySite} from './deploy.js';
|
|
17
|
+
import {formatPages} from '../tools/assets/format-pages.mjs';
|
|
18
|
+
|
|
19
|
+
const siteGitignore = ['node_modules/', 'dist/', '.edgepress/', '.wrangler/', '.dev.vars', '.vercel/', '.netlify/', '.edgeone/', '.esa/', '.env', '.env.*', '!.env.example', '*.tgz', ''].join('\n');
|
|
20
|
+
const siteReadme = [
|
|
21
|
+
'# My EdgePress site',
|
|
22
|
+
'',
|
|
23
|
+
'Static site source built with EdgePress. Use Node.js 22.12 or newer.',
|
|
24
|
+
'',
|
|
25
|
+
'## Start locally',
|
|
26
|
+
'',
|
|
27
|
+
' npm install',
|
|
28
|
+
' npm run dev',
|
|
29
|
+
'',
|
|
30
|
+
'Create an article with `npm run new -- post "My first article"`. Open the printed file path and write Markdown below the second `---` line. The command fills the title, date, and language. Run `npm run dev` to preview and `npm run build` before publishing.',
|
|
31
|
+
'',
|
|
32
|
+
'Pages use localized JSON files such as `content/pages/home/en.json`; VS Code can fold each row, cell and element and read the local schema. Articles stay in Markdown. Page layouts and theme customization are optional for everyday writing.',
|
|
33
|
+
'',
|
|
34
|
+
'## Deploy a static website',
|
|
35
|
+
'',
|
|
36
|
+
'Push this source repository to GitHub, then connect the repository to Cloudflare Workers Builds. The `build` and `deploy` scripts in `package.json` generate and deploy the static files. Set a unique Worker name in `wrangler.jsonc` first.',
|
|
37
|
+
'',
|
|
38
|
+
'Authenticate with the chosen platform, then run `edgepress deploy cloudflare`, `edgepress deploy vercel`, `edgepress deploy edgeone -n my-site`, `edgepress deploy netlify`, or `edgepress deploy esa --name my-site`.',
|
|
39
|
+
'',
|
|
40
|
+
'## Use another static web server',
|
|
41
|
+
'',
|
|
42
|
+
'Run `edgepress generate` and upload the contents of `dist/` to the server document root. OpenResty and Nginx can serve the generated directories as static files. Optional backends live in separate projects and are registered under plugins.consent.services in config.yml.',
|
|
43
|
+
''
|
|
44
|
+
].join('\n');
|
|
45
|
+
|
|
46
|
+
async function initializeProject() {
|
|
47
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
48
|
+
const root = process.cwd();
|
|
49
|
+
const directories = ['content', 'languages', 'plugins', 'static', 'themes'];
|
|
50
|
+
const files = ['.gitignore', 'README.md', 'config.yml', 'edgepress.config.mjs', 'project-compatibility.json', 'wrangler.jsonc', 'vercel.json', 'edgeone.json', 'esa.jsonc', 'netlify.toml'];
|
|
51
|
+
const destinations = [...directories, ...files];
|
|
52
|
+
const existing = [];
|
|
53
|
+
for (const relativePath of destinations) {
|
|
54
|
+
try { await access(resolve(root, relativePath)); existing.push(relativePath); }
|
|
55
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
56
|
+
}
|
|
57
|
+
if (existing.length) throw new Error('EdgePress init found existing project paths and made no changes: ' + existing.join(', '));
|
|
58
|
+
|
|
59
|
+
const packageData = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf8'));
|
|
60
|
+
let existingManifest = {};
|
|
61
|
+
let packageManifestExists = false;
|
|
62
|
+
try {
|
|
63
|
+
const manifestPath = resolve(root, 'package.json');
|
|
64
|
+
const manifestInfo = await lstat(manifestPath);
|
|
65
|
+
if (manifestInfo.isSymbolicLink() || !manifestInfo.isFile()) throw new Error('Refusing to edit an unsafe package.json path');
|
|
66
|
+
packageManifestExists = true;
|
|
67
|
+
existingManifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
68
|
+
if (!existingManifest || Array.isArray(existingManifest) || typeof existingManifest !== 'object') throw new Error('package.json must contain a JSON object');
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error.code !== 'ENOENT') throw error;
|
|
71
|
+
}
|
|
72
|
+
const projectManifest = {
|
|
73
|
+
...existingManifest,
|
|
74
|
+
name: existingManifest.name || 'my-edgepress-site',
|
|
75
|
+
version: existingManifest.version || '1.0.0',
|
|
76
|
+
private: existingManifest.private ?? true,
|
|
77
|
+
type: 'module',
|
|
78
|
+
scripts: {
|
|
79
|
+
...(existingManifest.scripts || {}),
|
|
80
|
+
new: 'edgepress new',
|
|
81
|
+
generate: 'edgepress generate',
|
|
82
|
+
build: 'edgepress build',
|
|
83
|
+
dev: 'edgepress server',
|
|
84
|
+
preview: 'edgepress server',
|
|
85
|
+
check: 'edgepress check',
|
|
86
|
+
doctor: 'edgepress doctor',
|
|
87
|
+
deploy: 'edgepress deploy',
|
|
88
|
+
'deploy:cloudflare': 'edgepress deploy cloudflare',
|
|
89
|
+
'deploy:netlify': 'edgepress deploy netlify',
|
|
90
|
+
'deploy:vercel': 'edgepress deploy vercel',
|
|
91
|
+
'deploy:edgeone': 'edgepress deploy edgeone',
|
|
92
|
+
'deploy:esa': 'edgepress deploy esa'
|
|
93
|
+
},
|
|
94
94
|
overrides: {
|
|
95
95
|
...(packageData.overrides || {}),
|
|
96
96
|
...(existingManifest.overrides || {})
|
|
97
97
|
},
|
|
98
98
|
dependencies: {
|
|
99
|
-
...(existingManifest.dependencies || {}),
|
|
100
|
-
edgepress: packageData.version,
|
|
101
|
-
wrangler: existingManifest.dependencies?.wrangler ?? packageData.devDependencies?.wrangler ?? '^4.129.1'
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
for (const directory of directories) await cp(resolve(packageRoot, directory), resolve(root, directory), { recursive: true, errorOnExist: true });
|
|
105
|
-
await formatPages(root, {write: true, schemaPath: resolve(root, 'node_modules/edgepress/schemas/pages.schema.json')});
|
|
106
|
-
for (const file of files) {
|
|
107
|
-
const destination = resolve(root, file);
|
|
108
|
-
await mkdir(dirname(destination), { recursive: true });
|
|
109
|
-
if (file === '.gitignore') await writeFile(destination, siteGitignore, { flag: 'wx' });
|
|
110
|
-
else if (file === 'README.md') await writeFile(destination, siteReadme, { flag: 'wx' });
|
|
111
|
-
else await cp(resolve(packageRoot, file), destination, { errorOnExist: true });
|
|
112
|
-
}
|
|
113
|
-
await writeFile(resolve(root, 'package.json'), JSON.stringify(projectManifest, null, 2) + '\n', { flag: packageManifestExists ? 'w' : 'wx' });
|
|
114
|
-
console.log('Created an EdgePress site. Next run npm install, then npm run dev. Create an article with npm run new -- post "My first article".');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function runSecurityAudit() {
|
|
118
|
-
await new Promise((resolveExit, reject) => {
|
|
119
|
-
const child = spawn('npm', ['audit', '--audit-level=moderate'], {
|
|
120
|
-
cwd: process.cwd(), env: process.env, stdio: 'inherit', shell: process.platform === 'win32', windowsHide: true
|
|
121
|
-
});
|
|
122
|
-
child.once('error', reject);
|
|
123
|
-
child.once('exit', (code) => {
|
|
124
|
-
if (code === 0) resolveExit();
|
|
125
|
-
else process.exitCode = code ?? 1, resolveExit();
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async function manageTheme(args, config) {
|
|
131
|
-
const [action, requested] = args;
|
|
132
|
-
const themesDirectory = resolve(config.root, 'themes');
|
|
133
|
-
const entries = (await readdir(themesDirectory, { withFileTypes: true }))
|
|
134
|
-
.filter((entry) => entry.isDirectory()).sort((left, right) => left.name.localeCompare(right.name));
|
|
135
|
-
const installed = [];
|
|
136
|
-
for (const entry of entries) {
|
|
137
|
-
try {
|
|
138
|
-
const metadata = JSON.parse(await readFile(resolve(themesDirectory, entry.name, 'theme.json'), 'utf8'));
|
|
139
|
-
installed.push({ id: entry.name, name: metadata.name || entry.name, path: 'themes/' + entry.name });
|
|
140
|
-
} catch (error) {
|
|
141
|
-
if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (action === 'list') {
|
|
145
|
-
for (const theme of installed) console.log((config.resolvedPaths.theme === resolve(config.root, theme.path) ? '* ' : ' ') + theme.id + ' (' + theme.name + ')');
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
if (action === 'create') {
|
|
149
|
-
if (!requested) throw new Error('Usage: edgepress theme create <theme-name>');
|
|
150
|
-
const id = slugify(requested);
|
|
151
|
-
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error('Theme name must contain letters or numbers and may use spaces or hyphens.');
|
|
152
|
-
const destination = resolve(themesDirectory, id);
|
|
153
|
-
try { await lstat(destination); throw new Error('Theme already exists: ' + id); }
|
|
154
|
-
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
155
|
-
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
156
|
-
await cp(resolve(packageRoot, 'themes/default'), destination, { recursive: true, errorOnExist: true });
|
|
157
|
-
const metadataPath = resolve(destination, 'theme.json');
|
|
158
|
-
const metadata = JSON.parse(await readFile(metadataPath, 'utf8'));
|
|
159
|
-
metadata.id = id;
|
|
160
|
-
metadata.name = requested.trim();
|
|
161
|
-
await writeFile(metadataPath, JSON.stringify(metadata, null, 2) + '\n', 'utf8');
|
|
162
|
-
const packageManifestPath = resolve(destination, 'package.json');
|
|
163
|
-
const packageManifest = {
|
|
164
|
-
name: 'edgepress-theme-' + id,
|
|
165
|
-
version: '1.0.0',
|
|
166
|
-
description: 'A custom theme for EdgePress.',
|
|
167
|
-
files: ['theme.json', 'layouts', 'assets', 'README.md'],
|
|
168
|
-
edgepress: { theme: id }
|
|
169
|
-
};
|
|
170
|
-
await writeFile(packageManifestPath, JSON.stringify(packageManifest, null, 2) + '\n', 'utf8');
|
|
171
|
-
const readmePath = resolve(destination, 'README.md');
|
|
172
|
-
const readme = '# ' + id + '\n\nAn EdgePress theme package. Edit the shared layout and partials under `layouts/` and add theme styles and assets under `assets/`.\n\n' +
|
|
173
|
-
'To install this theme in another EdgePress site, publish the package to npm and run:\n\n' +
|
|
174
|
-
' edgepress theme install edgepress-theme-' + id + '\n\n' +
|
|
175
|
-
'Keep the skip link, one main landmark, labeled navigation, visible keyboard focus, and a page-level `h1` in the layout.\n';
|
|
176
|
-
await writeFile(readmePath, readme, 'utf8');
|
|
177
|
-
await writeFile(resolve(destination, 'assets/style.css'), '/* Add the colors, typography, spacing, and responsive rules for this theme here. */\n', 'utf8');
|
|
178
|
-
console.log('Created an empty theme at themes/' + id + '. Edit its HTML partials and assets, then run edgepress theme use ' + id + '.');
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
if (action === 'install') {
|
|
182
|
-
if (!requested) throw new Error('Usage: edgepress theme install <npm-package>[@version]');
|
|
183
|
-
const packageSpecPattern = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[0-9][a-z0-9.+_-]*)?$/i;
|
|
184
|
-
if (!packageSpecPattern.test(requested)) throw new Error('Theme must be an npm package name with an optional version.');
|
|
185
|
-
const packageNameEnd = requested.lastIndexOf('@');
|
|
186
|
-
const packageName = packageNameEnd > requested.indexOf('/') ? requested.slice(0, packageNameEnd) : requested;
|
|
187
|
-
const tempDirectory = await mkdtemp(join(tmpdir(), 'edgepress-theme-'));
|
|
188
|
-
try {
|
|
189
|
-
await new Promise((resolveExit, reject) => {
|
|
190
|
-
const child = spawn('npm', ['install', '--prefix', tempDirectory, '--ignore-scripts', '--no-audit', '--no-fund', requested], {
|
|
191
|
-
cwd: config.root, env: process.env, stdio: 'inherit', shell: process.platform === 'win32', windowsHide: true
|
|
192
|
-
});
|
|
193
|
-
child.once('error', reject);
|
|
194
|
-
child.once('exit', (code) => code === 0 ? resolveExit() : reject(new Error('npm could not install theme package ' + requested)));
|
|
195
|
-
});
|
|
196
|
-
const packageDirectory = resolve(tempDirectory, 'node_modules', ...packageName.split('/'));
|
|
197
|
-
const packageInfo = await lstat(packageDirectory);
|
|
198
|
-
if (!packageInfo.isDirectory() || packageInfo.isSymbolicLink()) throw new Error('Installed theme package is not a regular directory.');
|
|
199
|
-
const themeRoot = await realpath(packageDirectory);
|
|
200
|
-
const readPackageFile = async (name) => {
|
|
201
|
-
const filePath = resolve(themeRoot, name);
|
|
202
|
-
const info = await lstat(filePath);
|
|
203
|
-
if (!info.isFile() || info.isSymbolicLink()) throw new Error('Theme package ' + name + ' must be a regular file.');
|
|
204
|
-
const realPath = await realpath(filePath);
|
|
205
|
-
const relativePath = relative(themeRoot, realPath);
|
|
206
|
-
if (relativePath === '..' || relativePath.startsWith('..' + sep) || relativePath.startsWith(sep)) {
|
|
207
|
-
throw new Error('Theme package ' + name + ' resolves outside the package directory.');
|
|
208
|
-
}
|
|
209
|
-
return readFile(realPath, 'utf8');
|
|
210
|
-
};
|
|
211
|
-
const metadata = JSON.parse(await readPackageFile('theme.json'));
|
|
212
|
-
const packageManifest = JSON.parse(await readPackageFile('package.json'));
|
|
213
|
-
const declaredId = metadata.id || packageManifest.edgepress?.theme || packageName.split('/').at(-1).replace(/^edgepress-theme-/, '');
|
|
214
|
-
const id = slugify(declaredId);
|
|
215
|
-
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error('Theme package must declare a safe id in theme.json or package.json#edgepress.theme.');
|
|
216
|
-
const layoutFile = resolve(themeRoot, 'layouts/layout.html');
|
|
217
|
-
const layoutInfo = await lstat(layoutFile);
|
|
218
|
-
if (!layoutInfo.isFile() || layoutInfo.isSymbolicLink()) throw new Error('Theme package layouts/layout.html must be a regular file.');
|
|
219
|
-
const layoutPath = await realpath(layoutFile);
|
|
220
|
-
const relLayout = relative(themeRoot, layoutPath);
|
|
221
|
-
if (relLayout === '..' || relLayout.startsWith('..' + sep) || relLayout.startsWith(sep)) {
|
|
222
|
-
throw new Error('Theme package layouts/layout.html must be a regular file inside the package.');
|
|
223
|
-
}
|
|
224
|
-
const destination = resolve(themesDirectory, id);
|
|
225
|
-
try { await lstat(destination); throw new Error('Theme already exists: ' + id); }
|
|
226
|
-
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
227
|
-
await cp(themeRoot, destination, { recursive: true, errorOnExist: true, dereference: false });
|
|
228
|
-
console.log('Installed ' + (metadata.name || packageName) + ' to themes/' + id + '. Select it with edgepress theme use ' + id + '.');
|
|
229
|
-
} finally {
|
|
230
|
-
await rm(tempDirectory, { recursive: true, force: true });
|
|
231
|
-
}
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
if (action !== 'use' || !requested) throw new Error('Usage: edgepress theme list | edgepress theme create <name> | edgepress theme install <npm-package>[@version] | edgepress theme use <theme-name>');
|
|
235
|
-
const selected = installed.find((theme) => theme.id === requested);
|
|
236
|
-
if (!selected) throw new Error('Theme not found. Run edgepress theme list to see installed themes.');
|
|
237
|
-
const configFile = resolve(config.root, 'edgepress.config.mjs');
|
|
238
|
-
const info = await lstat(configFile);
|
|
239
|
-
if (info.isSymbolicLink() || !info.isFile()) throw new Error('Refusing to edit an unsafe edgepress.config.mjs path');
|
|
240
|
-
const source = await readFile(configFile, 'utf8');
|
|
241
|
-
const pathsBlock = source.match(/(paths\s*:\s*\{)([^}]*)(\})/s);
|
|
242
|
-
if (!pathsBlock || !/^\s*theme\s*:/m.test(pathsBlock[2])) {
|
|
243
|
-
throw new Error('Could not safely find paths.theme in edgepress.config.mjs; set it manually.');
|
|
244
|
-
}
|
|
245
|
-
const updatedPaths = pathsBlock[2].replace(/(^\s*theme\s*:\s*)(["'])[^"']*\2/m, '$1"' + selected.path + '"');
|
|
246
|
-
const updated = source.replace(pathsBlock[0], pathsBlock[1] + updatedPaths + pathsBlock[3]);
|
|
247
|
-
await writeFile(configFile, updated, 'utf8');
|
|
248
|
-
console.log('Selected theme ' + selected.id + '.');
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
async function createPost(title, config) {
|
|
252
|
-
if (!title) throw new Error('Usage: edgepress new post "Post title"');
|
|
253
|
-
const releaseLock = await acquireBuildLock(config.resolvedPaths.cache);
|
|
254
|
-
try {
|
|
255
|
-
if (title.length > 160 || /[\x00-\x1f\x7f]/.test(title)) throw new Error('Post title must be one line of at most 160 characters.');
|
|
256
|
-
await loadLanguagePacks(config);
|
|
257
|
-
const timestamp = publicationTimestamp(new Date(), config.site.timeZone);
|
|
258
|
-
const today = timestamp.slice(0, 10);
|
|
259
|
-
const folder = resolve(config.resolvedPaths.content, 'posts', today + '-' + slugify(title));
|
|
260
|
-
const locale = config.i18n.defaultLocale;
|
|
261
|
-
const file = resolve(folder, locale.toLowerCase() + '.md');
|
|
262
|
-
await mkdir(folder, { recursive: true });
|
|
263
|
-
const body = postTemplate(title, timestamp, locale, translate(config, locale, 'postStarter'));
|
|
264
|
-
let handle;
|
|
265
|
-
try { handle = await open(file, 'wx'); }
|
|
266
|
-
catch (error) {
|
|
267
|
-
if (error.code === 'EEXIST') throw new Error('Post already exists: ' + file);
|
|
268
|
-
throw error;
|
|
269
|
-
}
|
|
270
|
-
let writeError;
|
|
271
|
-
try { await handle.writeFile(body, 'utf8'); }
|
|
272
|
-
catch (error) { writeError = error; }
|
|
273
|
-
await handle.close();
|
|
274
|
-
if (writeError) {
|
|
275
|
-
await rm(file, { force: true });
|
|
276
|
-
throw writeError;
|
|
277
|
-
}
|
|
278
|
-
console.log(translate(config, locale, 'postCreated').replace('{file}', relative(config.root, file)));
|
|
279
|
-
console.log(translate(config, locale, 'postNextSteps'));
|
|
280
|
-
console.log(translate(config, locale, 'postPreview'));
|
|
281
|
-
} finally {
|
|
282
|
-
await releaseLock();
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
async function clean(config) {
|
|
287
|
-
const cache = config.resolvedPaths.cache;
|
|
288
|
-
const releaseLock = await acquireBuildLock(cache);
|
|
289
|
-
try {
|
|
290
|
-
for (const target of [config.resolvedPaths.output]) {
|
|
291
|
-
try {
|
|
292
|
-
const info = await lstat(target);
|
|
293
|
-
if (info.isSymbolicLink()) throw new Error('Refusing to clean a symbolic-link output path: ' + target);
|
|
294
|
-
await rm(target, { recursive: true, force: true });
|
|
295
|
-
} catch (error) {
|
|
296
|
-
if (error.code !== 'ENOENT') throw error;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
const toolsDirectory = await reportDirectory(config.root, false);
|
|
300
|
-
if (toolsDirectory) try {
|
|
301
|
-
const info = await lstat(toolsDirectory);
|
|
302
|
-
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('Refusing to clean reports through an unsafe tools path: ' + toolsDirectory);
|
|
303
|
-
await rm(resolve(toolsDirectory, 'page-check.pdf'), { force: true });
|
|
304
|
-
} catch (error) {
|
|
305
|
-
if (error.code !== 'ENOENT') throw error;
|
|
306
|
-
}
|
|
307
|
-
let entries = [];
|
|
308
|
-
try { entries = await readdir(cache); }
|
|
309
|
-
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
310
|
-
await Promise.all(entries.filter((entry) => entry !== 'build.lock')
|
|
311
|
-
.map((entry) => rm(resolve(cache, entry), { recursive: true, force: true })));
|
|
312
|
-
} finally {
|
|
313
|
-
await releaseLock();
|
|
314
|
-
}
|
|
315
|
-
console.log('Removed generated output and reports.');
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
async function showReport(title, report, reportPath) {
|
|
319
|
-
console.log(title + ': ' + report.status);
|
|
320
|
-
console.log('Report: ' + reportPath);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function showStatus(title, report) {
|
|
324
|
-
console.log(title + ': ' + report.status);
|
|
325
|
-
for (const issue of report.issues) console.log('- ' + issue.severity + ': ' + issue.message);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const [command, ...args] = process.argv.slice(2);
|
|
329
|
-
try {
|
|
330
|
-
const config = command === 'new' || command === 'clean' || command === 'check' || command === 'doctor' || command === 'iterate'
|
|
331
|
-
? await loadConfig()
|
|
332
|
-
: null;
|
|
333
|
-
if (command === '--help' || command === '-h' || command === 'help') {
|
|
334
|
-
console.log('EdgePress commands: init, new, build, generate, server, check, doctor, iterate, theme, security, deploy, clean');
|
|
335
|
-
}
|
|
336
|
-
else if (command === 'build' || command === 'generate') await buildSite();
|
|
337
|
-
else if (command === 'init') await initializeProject();
|
|
338
|
-
else if (command === 'preview' || command === 'dev' || command === 'server') await import('./preview.js');
|
|
339
|
-
else if (command === 'deploy') {
|
|
340
|
-
const platform = args[0] && !args[0].startsWith('-') ? args.shift() : 'cloudflare';
|
|
341
|
-
const built = process.env.WORKERS_CI === '1' ? {output:(await loadConfig()).resolvedPaths.output} : await buildSite();
|
|
342
|
-
await deploySite(platform, built.output, args);
|
|
343
|
-
}
|
|
344
|
-
else if (command === 'theme') await manageTheme(args, await loadConfig());
|
|
345
|
-
else if (command === 'security') await runSecurityAudit();
|
|
346
|
-
else if (command === 'new') await createPost((args[0] === 'post' ? args.slice(1) : args).join(' ').trim(), config);
|
|
347
|
-
else if (command === 'clean') await clean(config);
|
|
348
|
-
else if (command === 'check') {
|
|
349
|
-
await buildSite(config.root);
|
|
350
|
-
const pageReport = await checkPages(config);
|
|
351
|
-
const compatibilityReport = await checkCompatibility(config);
|
|
352
|
-
await showReport('Page check', pageReport, resolve(config.root, 'tools/reports/page-check.pdf'));
|
|
353
|
-
showStatus('Compatibility check', compatibilityReport);
|
|
354
|
-
if (pageReport.errors || compatibilityReport.issues.some((issue) => issue.severity === 'error')) process.exitCode = 1;
|
|
355
|
-
} else if (command === 'doctor') {
|
|
356
|
-
const report = await checkCompatibility(config);
|
|
357
|
-
showStatus('Compatibility check', report);
|
|
358
|
-
if (report.issues.some((issue) => issue.severity === 'error')) process.exitCode = 1;
|
|
359
|
-
} else if (command === 'iterate') {
|
|
360
|
-
await buildSite(config.root);
|
|
361
|
-
const pageReport = await checkPages(config);
|
|
362
|
-
const compatibilityReport = await checkCompatibility(config);
|
|
363
|
-
const plan = await writeIterationPlan(config, pageReport, compatibilityReport);
|
|
364
|
-
await showReport('Safe iteration plan', plan, resolve(config.resolvedPaths.cache, 'reports/iteration-plan.md'));
|
|
365
|
-
} else throw new Error('Commands: init, build|generate, server, deploy, new, clean, check, doctor, iterate, theme list|create|install|use, security');
|
|
366
|
-
} catch (error) {
|
|
367
|
-
console.error(error?.stack || error);
|
|
368
|
-
process.exitCode = 1;
|
|
369
|
-
}
|
|
99
|
+
...(existingManifest.dependencies || {}),
|
|
100
|
+
edgepress: packageData.version,
|
|
101
|
+
wrangler: existingManifest.dependencies?.wrangler ?? packageData.devDependencies?.wrangler ?? '^4.129.1'
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
for (const directory of directories) await cp(resolve(packageRoot, directory), resolve(root, directory), { recursive: true, errorOnExist: true });
|
|
105
|
+
await formatPages(root, {write: true, schemaPath: resolve(root, 'node_modules/edgepress/schemas/pages.schema.json')});
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
const destination = resolve(root, file);
|
|
108
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
109
|
+
if (file === '.gitignore') await writeFile(destination, siteGitignore, { flag: 'wx' });
|
|
110
|
+
else if (file === 'README.md') await writeFile(destination, siteReadme, { flag: 'wx' });
|
|
111
|
+
else await cp(resolve(packageRoot, file), destination, { errorOnExist: true });
|
|
112
|
+
}
|
|
113
|
+
await writeFile(resolve(root, 'package.json'), JSON.stringify(projectManifest, null, 2) + '\n', { flag: packageManifestExists ? 'w' : 'wx' });
|
|
114
|
+
console.log('Created an EdgePress site. Next run npm install, then npm run dev. Create an article with npm run new -- post "My first article".');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function runSecurityAudit() {
|
|
118
|
+
await new Promise((resolveExit, reject) => {
|
|
119
|
+
const child = spawn('npm', ['audit', '--audit-level=moderate'], {
|
|
120
|
+
cwd: process.cwd(), env: process.env, stdio: 'inherit', shell: process.platform === 'win32', windowsHide: true
|
|
121
|
+
});
|
|
122
|
+
child.once('error', reject);
|
|
123
|
+
child.once('exit', (code) => {
|
|
124
|
+
if (code === 0) resolveExit();
|
|
125
|
+
else process.exitCode = code ?? 1, resolveExit();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function manageTheme(args, config) {
|
|
131
|
+
const [action, requested] = args;
|
|
132
|
+
const themesDirectory = resolve(config.root, 'themes');
|
|
133
|
+
const entries = (await readdir(themesDirectory, { withFileTypes: true }))
|
|
134
|
+
.filter((entry) => entry.isDirectory()).sort((left, right) => left.name.localeCompare(right.name));
|
|
135
|
+
const installed = [];
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
try {
|
|
138
|
+
const metadata = JSON.parse(await readFile(resolve(themesDirectory, entry.name, 'theme.json'), 'utf8'));
|
|
139
|
+
installed.push({ id: entry.name, name: metadata.name || entry.name, path: 'themes/' + entry.name });
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (action === 'list') {
|
|
145
|
+
for (const theme of installed) console.log((config.resolvedPaths.theme === resolve(config.root, theme.path) ? '* ' : ' ') + theme.id + ' (' + theme.name + ')');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (action === 'create') {
|
|
149
|
+
if (!requested) throw new Error('Usage: edgepress theme create <theme-name>');
|
|
150
|
+
const id = slugify(requested);
|
|
151
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error('Theme name must contain letters or numbers and may use spaces or hyphens.');
|
|
152
|
+
const destination = resolve(themesDirectory, id);
|
|
153
|
+
try { await lstat(destination); throw new Error('Theme already exists: ' + id); }
|
|
154
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
155
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
156
|
+
await cp(resolve(packageRoot, 'themes/default'), destination, { recursive: true, errorOnExist: true });
|
|
157
|
+
const metadataPath = resolve(destination, 'theme.json');
|
|
158
|
+
const metadata = JSON.parse(await readFile(metadataPath, 'utf8'));
|
|
159
|
+
metadata.id = id;
|
|
160
|
+
metadata.name = requested.trim();
|
|
161
|
+
await writeFile(metadataPath, JSON.stringify(metadata, null, 2) + '\n', 'utf8');
|
|
162
|
+
const packageManifestPath = resolve(destination, 'package.json');
|
|
163
|
+
const packageManifest = {
|
|
164
|
+
name: 'edgepress-theme-' + id,
|
|
165
|
+
version: '1.0.0',
|
|
166
|
+
description: 'A custom theme for EdgePress.',
|
|
167
|
+
files: ['theme.json', 'layouts', 'assets', 'README.md'],
|
|
168
|
+
edgepress: { theme: id }
|
|
169
|
+
};
|
|
170
|
+
await writeFile(packageManifestPath, JSON.stringify(packageManifest, null, 2) + '\n', 'utf8');
|
|
171
|
+
const readmePath = resolve(destination, 'README.md');
|
|
172
|
+
const readme = '# ' + id + '\n\nAn EdgePress theme package. Edit the shared layout and partials under `layouts/` and add theme styles and assets under `assets/`.\n\n' +
|
|
173
|
+
'To install this theme in another EdgePress site, publish the package to npm and run:\n\n' +
|
|
174
|
+
' edgepress theme install edgepress-theme-' + id + '\n\n' +
|
|
175
|
+
'Keep the skip link, one main landmark, labeled navigation, visible keyboard focus, and a page-level `h1` in the layout.\n';
|
|
176
|
+
await writeFile(readmePath, readme, 'utf8');
|
|
177
|
+
await writeFile(resolve(destination, 'assets/style.css'), '/* Add the colors, typography, spacing, and responsive rules for this theme here. */\n', 'utf8');
|
|
178
|
+
console.log('Created an empty theme at themes/' + id + '. Edit its HTML partials and assets, then run edgepress theme use ' + id + '.');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (action === 'install') {
|
|
182
|
+
if (!requested) throw new Error('Usage: edgepress theme install <npm-package>[@version]');
|
|
183
|
+
const packageSpecPattern = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[0-9][a-z0-9.+_-]*)?$/i;
|
|
184
|
+
if (!packageSpecPattern.test(requested)) throw new Error('Theme must be an npm package name with an optional version.');
|
|
185
|
+
const packageNameEnd = requested.lastIndexOf('@');
|
|
186
|
+
const packageName = packageNameEnd > requested.indexOf('/') ? requested.slice(0, packageNameEnd) : requested;
|
|
187
|
+
const tempDirectory = await mkdtemp(join(tmpdir(), 'edgepress-theme-'));
|
|
188
|
+
try {
|
|
189
|
+
await new Promise((resolveExit, reject) => {
|
|
190
|
+
const child = spawn('npm', ['install', '--prefix', tempDirectory, '--ignore-scripts', '--no-audit', '--no-fund', requested], {
|
|
191
|
+
cwd: config.root, env: process.env, stdio: 'inherit', shell: process.platform === 'win32', windowsHide: true
|
|
192
|
+
});
|
|
193
|
+
child.once('error', reject);
|
|
194
|
+
child.once('exit', (code) => code === 0 ? resolveExit() : reject(new Error('npm could not install theme package ' + requested)));
|
|
195
|
+
});
|
|
196
|
+
const packageDirectory = resolve(tempDirectory, 'node_modules', ...packageName.split('/'));
|
|
197
|
+
const packageInfo = await lstat(packageDirectory);
|
|
198
|
+
if (!packageInfo.isDirectory() || packageInfo.isSymbolicLink()) throw new Error('Installed theme package is not a regular directory.');
|
|
199
|
+
const themeRoot = await realpath(packageDirectory);
|
|
200
|
+
const readPackageFile = async (name) => {
|
|
201
|
+
const filePath = resolve(themeRoot, name);
|
|
202
|
+
const info = await lstat(filePath);
|
|
203
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error('Theme package ' + name + ' must be a regular file.');
|
|
204
|
+
const realPath = await realpath(filePath);
|
|
205
|
+
const relativePath = relative(themeRoot, realPath);
|
|
206
|
+
if (relativePath === '..' || relativePath.startsWith('..' + sep) || relativePath.startsWith(sep)) {
|
|
207
|
+
throw new Error('Theme package ' + name + ' resolves outside the package directory.');
|
|
208
|
+
}
|
|
209
|
+
return readFile(realPath, 'utf8');
|
|
210
|
+
};
|
|
211
|
+
const metadata = JSON.parse(await readPackageFile('theme.json'));
|
|
212
|
+
const packageManifest = JSON.parse(await readPackageFile('package.json'));
|
|
213
|
+
const declaredId = metadata.id || packageManifest.edgepress?.theme || packageName.split('/').at(-1).replace(/^edgepress-theme-/, '');
|
|
214
|
+
const id = slugify(declaredId);
|
|
215
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error('Theme package must declare a safe id in theme.json or package.json#edgepress.theme.');
|
|
216
|
+
const layoutFile = resolve(themeRoot, 'layouts/layout.html');
|
|
217
|
+
const layoutInfo = await lstat(layoutFile);
|
|
218
|
+
if (!layoutInfo.isFile() || layoutInfo.isSymbolicLink()) throw new Error('Theme package layouts/layout.html must be a regular file.');
|
|
219
|
+
const layoutPath = await realpath(layoutFile);
|
|
220
|
+
const relLayout = relative(themeRoot, layoutPath);
|
|
221
|
+
if (relLayout === '..' || relLayout.startsWith('..' + sep) || relLayout.startsWith(sep)) {
|
|
222
|
+
throw new Error('Theme package layouts/layout.html must be a regular file inside the package.');
|
|
223
|
+
}
|
|
224
|
+
const destination = resolve(themesDirectory, id);
|
|
225
|
+
try { await lstat(destination); throw new Error('Theme already exists: ' + id); }
|
|
226
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
227
|
+
await cp(themeRoot, destination, { recursive: true, errorOnExist: true, dereference: false });
|
|
228
|
+
console.log('Installed ' + (metadata.name || packageName) + ' to themes/' + id + '. Select it with edgepress theme use ' + id + '.');
|
|
229
|
+
} finally {
|
|
230
|
+
await rm(tempDirectory, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (action !== 'use' || !requested) throw new Error('Usage: edgepress theme list | edgepress theme create <name> | edgepress theme install <npm-package>[@version] | edgepress theme use <theme-name>');
|
|
235
|
+
const selected = installed.find((theme) => theme.id === requested);
|
|
236
|
+
if (!selected) throw new Error('Theme not found. Run edgepress theme list to see installed themes.');
|
|
237
|
+
const configFile = resolve(config.root, 'edgepress.config.mjs');
|
|
238
|
+
const info = await lstat(configFile);
|
|
239
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error('Refusing to edit an unsafe edgepress.config.mjs path');
|
|
240
|
+
const source = await readFile(configFile, 'utf8');
|
|
241
|
+
const pathsBlock = source.match(/(paths\s*:\s*\{)([^}]*)(\})/s);
|
|
242
|
+
if (!pathsBlock || !/^\s*theme\s*:/m.test(pathsBlock[2])) {
|
|
243
|
+
throw new Error('Could not safely find paths.theme in edgepress.config.mjs; set it manually.');
|
|
244
|
+
}
|
|
245
|
+
const updatedPaths = pathsBlock[2].replace(/(^\s*theme\s*:\s*)(["'])[^"']*\2/m, '$1"' + selected.path + '"');
|
|
246
|
+
const updated = source.replace(pathsBlock[0], pathsBlock[1] + updatedPaths + pathsBlock[3]);
|
|
247
|
+
await writeFile(configFile, updated, 'utf8');
|
|
248
|
+
console.log('Selected theme ' + selected.id + '.');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function createPost(title, config) {
|
|
252
|
+
if (!title) throw new Error('Usage: edgepress new post "Post title"');
|
|
253
|
+
const releaseLock = await acquireBuildLock(config.resolvedPaths.cache);
|
|
254
|
+
try {
|
|
255
|
+
if (title.length > 160 || /[\x00-\x1f\x7f]/.test(title)) throw new Error('Post title must be one line of at most 160 characters.');
|
|
256
|
+
await loadLanguagePacks(config);
|
|
257
|
+
const timestamp = publicationTimestamp(new Date(), config.site.timeZone);
|
|
258
|
+
const today = timestamp.slice(0, 10);
|
|
259
|
+
const folder = resolve(config.resolvedPaths.content, 'posts', today + '-' + slugify(title));
|
|
260
|
+
const locale = config.i18n.defaultLocale;
|
|
261
|
+
const file = resolve(folder, locale.toLowerCase() + '.md');
|
|
262
|
+
await mkdir(folder, { recursive: true });
|
|
263
|
+
const body = postTemplate(title, timestamp, locale, translate(config, locale, 'postStarter'));
|
|
264
|
+
let handle;
|
|
265
|
+
try { handle = await open(file, 'wx'); }
|
|
266
|
+
catch (error) {
|
|
267
|
+
if (error.code === 'EEXIST') throw new Error('Post already exists: ' + file);
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
let writeError;
|
|
271
|
+
try { await handle.writeFile(body, 'utf8'); }
|
|
272
|
+
catch (error) { writeError = error; }
|
|
273
|
+
await handle.close();
|
|
274
|
+
if (writeError) {
|
|
275
|
+
await rm(file, { force: true });
|
|
276
|
+
throw writeError;
|
|
277
|
+
}
|
|
278
|
+
console.log(translate(config, locale, 'postCreated').replace('{file}', relative(config.root, file)));
|
|
279
|
+
console.log(translate(config, locale, 'postNextSteps'));
|
|
280
|
+
console.log(translate(config, locale, 'postPreview'));
|
|
281
|
+
} finally {
|
|
282
|
+
await releaseLock();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function clean(config) {
|
|
287
|
+
const cache = config.resolvedPaths.cache;
|
|
288
|
+
const releaseLock = await acquireBuildLock(cache);
|
|
289
|
+
try {
|
|
290
|
+
for (const target of [config.resolvedPaths.output]) {
|
|
291
|
+
try {
|
|
292
|
+
const info = await lstat(target);
|
|
293
|
+
if (info.isSymbolicLink()) throw new Error('Refusing to clean a symbolic-link output path: ' + target);
|
|
294
|
+
await rm(target, { recursive: true, force: true });
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (error.code !== 'ENOENT') throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const toolsDirectory = await reportDirectory(config.root, false);
|
|
300
|
+
if (toolsDirectory) try {
|
|
301
|
+
const info = await lstat(toolsDirectory);
|
|
302
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('Refusing to clean reports through an unsafe tools path: ' + toolsDirectory);
|
|
303
|
+
await rm(resolve(toolsDirectory, 'page-check.pdf'), { force: true });
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error.code !== 'ENOENT') throw error;
|
|
306
|
+
}
|
|
307
|
+
let entries = [];
|
|
308
|
+
try { entries = await readdir(cache); }
|
|
309
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
310
|
+
await Promise.all(entries.filter((entry) => entry !== 'build.lock')
|
|
311
|
+
.map((entry) => rm(resolve(cache, entry), { recursive: true, force: true })));
|
|
312
|
+
} finally {
|
|
313
|
+
await releaseLock();
|
|
314
|
+
}
|
|
315
|
+
console.log('Removed generated output and reports.');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function showReport(title, report, reportPath) {
|
|
319
|
+
console.log(title + ': ' + report.status);
|
|
320
|
+
console.log('Report: ' + reportPath);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function showStatus(title, report) {
|
|
324
|
+
console.log(title + ': ' + report.status);
|
|
325
|
+
for (const issue of report.issues) console.log('- ' + issue.severity + ': ' + issue.message);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const [command, ...args] = process.argv.slice(2);
|
|
329
|
+
try {
|
|
330
|
+
const config = command === 'new' || command === 'clean' || command === 'check' || command === 'doctor' || command === 'iterate'
|
|
331
|
+
? await loadConfig()
|
|
332
|
+
: null;
|
|
333
|
+
if (command === '--help' || command === '-h' || command === 'help') {
|
|
334
|
+
console.log('EdgePress commands: init, new, build, generate, server, check, doctor, iterate, theme, security, deploy, clean');
|
|
335
|
+
}
|
|
336
|
+
else if (command === 'build' || command === 'generate') await buildSite();
|
|
337
|
+
else if (command === 'init') await initializeProject();
|
|
338
|
+
else if (command === 'preview' || command === 'dev' || command === 'server') await import('./preview.js');
|
|
339
|
+
else if (command === 'deploy') {
|
|
340
|
+
const platform = args[0] && !args[0].startsWith('-') ? args.shift() : 'cloudflare';
|
|
341
|
+
const built = process.env.WORKERS_CI === '1' ? {output:(await loadConfig()).resolvedPaths.output} : await buildSite();
|
|
342
|
+
await deploySite(platform, built.output, args);
|
|
343
|
+
}
|
|
344
|
+
else if (command === 'theme') await manageTheme(args, await loadConfig());
|
|
345
|
+
else if (command === 'security') await runSecurityAudit();
|
|
346
|
+
else if (command === 'new') await createPost((args[0] === 'post' ? args.slice(1) : args).join(' ').trim(), config);
|
|
347
|
+
else if (command === 'clean') await clean(config);
|
|
348
|
+
else if (command === 'check') {
|
|
349
|
+
await buildSite(config.root);
|
|
350
|
+
const pageReport = await checkPages(config);
|
|
351
|
+
const compatibilityReport = await checkCompatibility(config);
|
|
352
|
+
await showReport('Page check', pageReport, resolve(config.root, 'tools/reports/page-check.pdf'));
|
|
353
|
+
showStatus('Compatibility check', compatibilityReport);
|
|
354
|
+
if (pageReport.errors || compatibilityReport.issues.some((issue) => issue.severity === 'error')) process.exitCode = 1;
|
|
355
|
+
} else if (command === 'doctor') {
|
|
356
|
+
const report = await checkCompatibility(config);
|
|
357
|
+
showStatus('Compatibility check', report);
|
|
358
|
+
if (report.issues.some((issue) => issue.severity === 'error')) process.exitCode = 1;
|
|
359
|
+
} else if (command === 'iterate') {
|
|
360
|
+
await buildSite(config.root);
|
|
361
|
+
const pageReport = await checkPages(config);
|
|
362
|
+
const compatibilityReport = await checkCompatibility(config);
|
|
363
|
+
const plan = await writeIterationPlan(config, pageReport, compatibilityReport);
|
|
364
|
+
await showReport('Safe iteration plan', plan, resolve(config.resolvedPaths.cache, 'reports/iteration-plan.md'));
|
|
365
|
+
} else throw new Error('Commands: init, build|generate, server, deploy, new, clean, check, doctor, iterate, theme list|create|install|use, security');
|
|
366
|
+
} catch (error) {
|
|
367
|
+
console.error(error?.stack || error);
|
|
368
|
+
process.exitCode = 1;
|
|
369
|
+
}
|