edgepress 2026.10.13 → 2026.10.15

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.
@@ -0,0 +1,178 @@
1
+ import { createServer } from 'node:http';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { readFile, readdir, lstat, realpath, open, rename, unlink } from 'node:fs/promises';
4
+ import { resolve, relative, dirname, basename, sep } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createPage } from './page-scaffold.js';
7
+
8
+ const editorRoot = dirname(fileURLToPath(import.meta.url));
9
+ const maxDocumentBytes = 2 * 1024 * 1024;
10
+ const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ const revision = source => createHash('sha256').update(source).digest('hex');
12
+ const inside = (root, file) => file !== root && !relative(root, file).startsWith('..' + sep) && relative(root, file) !== '..' && !relative(root, file).startsWith(sep);
13
+
14
+ async function listPages(contentRoot) {
15
+ const found = [];
16
+ async function scan(folder, prefix = '') {
17
+ let entries;
18
+ try { entries = await readdir(folder, { withFileTypes: true }); }
19
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
20
+ for (const entry of entries) {
21
+ if (entry.isSymbolicLink()) continue;
22
+ if (entry.isDirectory()) await scan(resolve(folder, entry.name), prefix + entry.name + '/');
23
+ else if (entry.isFile() && entry.name.endsWith('.json') && prefix) found.push(prefix + entry.name);
24
+ }
25
+ }
26
+ await scan(contentRoot);
27
+ return found.sort((a, b) => a.localeCompare(b));
28
+ }
29
+
30
+ async function safePageFile(contentRoot, path) {
31
+ if (typeof path !== 'string' || path.includes('\\') || path.includes('..') || path.startsWith('/') || !path.endsWith('.json')) {
32
+ throw new Error('Invalid page path');
33
+ }
34
+ if (!(await listPages(contentRoot)).includes(path)) throw new Error('Page does not exist');
35
+ const folder = await realpath(contentRoot);
36
+ const file = resolve(contentRoot, path);
37
+ const actual = await realpath(file);
38
+ const info = await lstat(file);
39
+ if (!info.isFile() || info.isSymbolicLink() || !inside(folder, actual)) throw new Error('Unsafe page file');
40
+ // The directory scanner rejects linked directories, even when the final file is regular.
41
+ return file;
42
+ }
43
+
44
+ function validatePage(page, original, typeNames) {
45
+ if (!isObject(page)) throw new Error('Page must be an object');
46
+ if (page.lang !== original.lang || page.$schema !== original.$schema) throw new Error('The page language and schema cannot be changed here');
47
+ if (typeof page.title !== 'string' || !page.title.trim() || page.title.length > 500) throw new Error('Page title is required');
48
+ if (page.homepage !== undefined && typeof page.homepage !== 'boolean') throw new Error('homepage must be a boolean');
49
+ if (!Array.isArray(page.blocks) || !page.blocks.length || page.blocks.length > 100) throw new Error('Page must contain 1–100 rows');
50
+ function validateElements(elements, location, depth) {
51
+ if (!Array.isArray(elements) || !elements.length || elements.length > 100) throw new Error(location + ' needs 1–100 elements');
52
+ if (depth > 12) throw new Error('Nested elements are too deep');
53
+ for (const [index, element] of elements.entries()) {
54
+ if (!isObject(element) || !typeNames.has(element.type)) throw new Error(location + '[' + index + '] has an invalid element type');
55
+ if (element.blocks !== undefined) validateElements(element.blocks, location + '[' + index + '].blocks', depth + 1);
56
+ }
57
+ }
58
+ for (const [index, row] of page.blocks.entries()) {
59
+ if (!isObject(row) || Object.keys(row).some(key => !['columns', 'cells'].includes(key))) throw new Error('Invalid row at ' + index);
60
+ if (!Number.isInteger(row.columns) || row.columns < 1 || row.columns > 4 || !Array.isArray(row.cells) || row.cells.length !== row.columns) throw new Error('Row ' + (index + 1) + ' must have 1–4 matching columns and cells');
61
+ for (const [cell, elements] of row.cells.entries()) validateElements(elements, 'Row ' + (index + 1) + ', column ' + (cell + 1), 0);
62
+ }
63
+ }
64
+
65
+ function respond(res, status, data) {
66
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
67
+ res.end(JSON.stringify(data));
68
+ }
69
+
70
+ async function requestBody(req) {
71
+ if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] || '')) throw new Error('Expected application/json');
72
+ let bytes = 0;
73
+ const chunks = [];
74
+ for await (const chunk of req) {
75
+ bytes += chunk.length;
76
+ if (bytes > maxDocumentBytes) throw new Error('Page exceeds 2 MB');
77
+ chunks.push(chunk);
78
+ }
79
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
80
+ }
81
+
82
+ /** Starts a loopback-only editor. Never deploy this server. */
83
+ export async function startPageEditor({ root = process.cwd(), port = 4178, locales = ['en', 'zh-CN'], defaultLocale = 'en' } = {}) {
84
+ const contentRoot = resolve(root, 'content/pages');
85
+ const typeSchema = JSON.parse(await readFile(resolve(editorRoot, '../schemas/pages.schema.json'), 'utf8'));
86
+ const typeNames = new Set(typeSchema.definitions.element.properties.type.enum);
87
+ const catalog = JSON.parse(await readFile(resolve(editorRoot, 'page-block-catalog.json'), 'utf8'));
88
+ if (typeNames.size !== Object.keys(catalog.blocks).length || [...typeNames].some(type => !catalog.blocks[type]?.template || catalog.blocks[type].template.type !== type)) {
89
+ throw new Error('The visual editor block catalog is out of sync with the Pages schema');
90
+ }
91
+ const staticFiles = new Map([
92
+ ['/', { file: 'page-editor.html', type: 'text/html; charset=utf-8' }],
93
+ ['/editor.js', { file: 'page-editor-client.js', type: 'text/javascript; charset=utf-8' }],
94
+ ['/editor.css', { file: 'page-editor.css', type: 'text/css; charset=utf-8' }]
95
+ ]);
96
+ // Serialize writes so two browser tabs cannot commit the same revision.
97
+ let pendingWrite = Promise.resolve();
98
+ let serverPort = null;
99
+ const server = createServer(async (req, res) => {
100
+ try {
101
+ if (req.headers.host !== '127.0.0.1:' + serverPort) return respond(res, 403, { error: 'Localhost only' });
102
+ res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
103
+ res.setHeader('X-Frame-Options', 'DENY');
104
+ const url = new URL(req.url, 'http://127.0.0.1:' + serverPort);
105
+ if (req.method === 'GET' && staticFiles.has(url.pathname)) {
106
+ const item = staticFiles.get(url.pathname);
107
+ res.writeHead(200, { 'Content-Type': item.type, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
108
+ const bytes = await readFile(resolve(editorRoot, item.file));
109
+ // The optional editor extension shares the same module scope as the original
110
+ // editor to reuse its loaded page and revision state without a second copy.
111
+ if (url.pathname === '/editor.js') {
112
+ const extension = await readFile(resolve(editorRoot, 'page-editor-extension.js'));
113
+ return res.end(Buffer.concat([bytes, Buffer.from('\n'), extension]));
114
+ }
115
+ return res.end(bytes);
116
+ }
117
+ if (req.method === 'GET' && url.pathname === '/api/block-catalog') {
118
+ return respond(res, 200, catalog);
119
+ }
120
+ if (req.method === 'GET' && url.pathname === '/api/locales') {
121
+ return respond(res, 200, { locales, defaultLocale });
122
+ }
123
+ if (req.method === 'POST' && url.pathname === '/api/pages') {
124
+ if (req.headers.origin !== 'http://127.0.0.1:' + serverPort) return respond(res, 403, { error: 'Invalid editor origin' });
125
+ const body = await requestBody(req);
126
+ const result = await (pendingWrite = pendingWrite.catch(() => {}).then(async () => {
127
+ if (!isObject(body) || Object.keys(body).some(key => !['title', 'slug', 'lang'].includes(key))) throw new Error('Invalid new page parameters');
128
+ return createPage(root, { title: body.title, slug: body.slug, lang: body.lang, locales, defaultLocale });
129
+ }));
130
+ return respond(res, 201, { path: result.path, revision: revision(result.source), page: result.page });
131
+ }
132
+ if (req.method === 'GET' && url.pathname === '/api/pages') {
133
+ const paths = await listPages(contentRoot);
134
+ return respond(res, 200, { pages: paths });
135
+ }
136
+ if (req.method === 'GET' && url.pathname === '/api/page') {
137
+ const file = await safePageFile(contentRoot, url.searchParams.get('path'));
138
+ const source = await readFile(file, 'utf8');
139
+ if (Buffer.byteLength(source) > maxDocumentBytes) throw new Error('Page exceeds 2 MB');
140
+ return respond(res, 200, { page: JSON.parse(source), revision: revision(source) });
141
+ }
142
+ if (req.method === 'PUT' && url.pathname === '/api/page') {
143
+ if (req.headers.origin !== 'http://127.0.0.1:' + serverPort) return respond(res, 403, { error: 'Invalid editor origin' });
144
+ const body = await requestBody(req);
145
+ const result = await (pendingWrite = pendingWrite.catch(() => {}).then(async () => {
146
+ const file = await safePageFile(contentRoot, url.searchParams.get('path'));
147
+ const originalSource = await readFile(file, 'utf8');
148
+ if (!isObject(body) || typeof body.revision !== 'string' || body.revision !== revision(originalSource)) {
149
+ return { status: 409, data: { error: 'This page has changed on disk. Reload before saving.' } };
150
+ }
151
+ const original = JSON.parse(originalSource);
152
+ validatePage(body.page, original, typeNames);
153
+ const nextSource = JSON.stringify(body.page, null, 2) + '\n';
154
+ if (Buffer.byteLength(nextSource) > maxDocumentBytes) throw new Error('Page exceeds 2 MB');
155
+ const temp = resolve(dirname(file), '.' + basename(file) + '.' + randomBytes(8).toString('hex') + '.tmp');
156
+ let created = false;
157
+ try {
158
+ const fd = await open(temp, 'wx', 0o600);
159
+ created = true;
160
+ try { await fd.writeFile(nextSource, 'utf8'); await fd.sync(); }
161
+ finally { await fd.close(); }
162
+ await rename(temp, file);
163
+ } finally { if (created) await unlink(temp).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
164
+ return { status: 200, data: { revision: revision(nextSource) } };
165
+ }));
166
+ return respond(res, result.status, result.data);
167
+ }
168
+ return respond(res, 404, { error: 'Not found' });
169
+ } catch (error) {
170
+ const badInput = error instanceof SyntaxError || /Invalid|must|exceeds|needs|required|Unsafe|does not exist|cannot be changed|too deep|Expected|Unsupported|slug|title|locale/.test(error.message);
171
+ const conflict = /already exists/.test(error.message);
172
+ return respond(res, conflict ? 409 : badInput ? 400 : 500, { error: conflict || badInput ? error.message : 'Editor request failed' });
173
+ }
174
+ });
175
+ await new Promise((ok, fail) => { server.once('error', fail); server.listen(port, '127.0.0.1', ok); });
176
+ serverPort = server.address().port;
177
+ return { server, url: 'http://127.0.0.1:' + serverPort + '/', close: () => new Promise((ok, fail) => server.close(error => error ? fail(error) : ok())) };
178
+ }
@@ -0,0 +1,70 @@
1
+ import { mkdir, lstat, realpath, open, access, rm } from 'node:fs/promises';
2
+ import { dirname, relative, resolve, sep } from 'node:path';
3
+ import { slugify } from './content.js';
4
+
5
+ const validSlug = /^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*$/u;
6
+ function within(root, path) {
7
+ const rel = relative(root, path);
8
+ return rel && rel !== '..' && !rel.startsWith('..' + sep) && !rel.startsWith(sep);
9
+ }
10
+ async function directory(path) {
11
+ try {
12
+ const stats = await lstat(path);
13
+ if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error('Unsafe page directory: ' + path);
14
+ } catch (error) {
15
+ if (error.code !== 'ENOENT') throw error;
16
+ await mkdir(path);
17
+ }
18
+ }
19
+
20
+ /** Reused by the CLI and the local editor. The browser never supplies a filesystem path. */
21
+ export async function createPage(root, {
22
+ title, slug, lang, locales = ['en', 'zh-CN'], defaultLocale = 'en'
23
+ } = {}) {
24
+ if (typeof title !== 'string' || !title.trim() || title.length > 160 || /[\x00-\x1f\x7f]/.test(title)) {
25
+ throw new Error('Page title must be one line of 1–160 characters');
26
+ }
27
+ const locale = locales.find(value => value.toLowerCase() === String(lang || defaultLocale).toLowerCase());
28
+ if (!locale) throw new Error('Unsupported page locale; configure the language first');
29
+ const name = slug === undefined || slug === '' ? slugify(title) : String(slug);
30
+ if (!validSlug.test(name) || name.length > 100 || name !== name.toLowerCase()) {
31
+ throw new Error('Page slug must be a single lowercase URL segment (letters, digits, hyphens)');
32
+ }
33
+ const projectRoot = resolve(root);
34
+ const content = resolve(projectRoot, 'content');
35
+ const pages = resolve(content, 'pages');
36
+ await directory(content);
37
+ await directory(pages);
38
+ const pageDir = resolve(pages, name);
39
+ await directory(pageDir);
40
+ const actualPages = await realpath(pages);
41
+ const actualPageDir = await realpath(pageDir);
42
+ if (!within(actualPages, actualPageDir)) throw new Error('Unsafe page path');
43
+ const file = resolve(pageDir, locale.toLowerCase() + '.json');
44
+ const alternate = resolve(pageDir, locale.toLowerCase() + '.md');
45
+ try { await access(alternate); throw new Error('Page already exists: ' + alternate); }
46
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
47
+ const bundledSchema = resolve(projectRoot, 'schemas/pages.schema.json');
48
+ let schemaPath = resolve(projectRoot, 'node_modules/edgepress/schemas/pages.schema.json');
49
+ try { await access(bundledSchema); schemaPath = bundledSchema; }
50
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
51
+ const schema = relative(dirname(file), schemaPath).split(sep).join('/');
52
+ const page = {
53
+ $schema: schema.startsWith('.') ? schema : './' + schema,
54
+ title: title.trim(), lang: locale, slug: name,
55
+ description: '', blocks: [
56
+ { columns: 1, cells: [[{ type: 'hero', title: title.trim(), text: 'Describe this page.' }]] }
57
+ ]
58
+ };
59
+ const source = JSON.stringify(page, null, 2) + '\n';
60
+ let handle;
61
+ try { handle = await open(file, 'wx', 0o644); }
62
+ catch (error) {
63
+ if (error.code === 'EEXIST') throw new Error('Page already exists: ' + file);
64
+ throw error;
65
+ }
66
+ try { await handle.writeFile(source); }
67
+ catch (error) { await handle.close(); await rm(file, { force: true }); throw error; }
68
+ await handle.close();
69
+ return { file, path: name + '/' + locale.toLowerCase() + '.json', page, source };
70
+ }
@@ -4,22 +4,17 @@ import { readFile } from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
5
  import { dirname, resolve } from 'node:path';
6
6
 
7
- test('cloud dependency is pinned to a published source commit and excludes the removed PPT engine', async () => {
7
+ test('cloud dependency follows published release ranges and excludes the removed PPT engine', async () => {
8
8
  const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
9
- const lock = JSON.parse(await readFile(new URL('../package-lock.json', import.meta.url), 'utf8'));
10
9
  const source = manifest.dependencies['@jsw-teams/document-viewer'];
11
- assert.match(source, /^https:\/\/codeload\.github\.com\/jsw-teams\/document-viewer\/tar\.gz\/[a-f0-9]{40}$/);
12
- assert.equal(lock.packages['node_modules/@jsw-teams/document-viewer'].resolved, source);
10
+ assert.match(source, /^git\+https:\/\/github\.com\/jsw-teams\/document-viewer\.git#semver:\^2026\.10\.1$/);
13
11
  assert.ok(!manifest.files.includes('vendor'));
14
- assert.ok(!lock.packages['node_modules/@file-viewer/ppt']);
15
- assert.equal(manifest.overrides['@file-viewer/doc'].dompurify, '3.4.17');
16
- const sanitizerVersions = Object.entries(lock.packages).filter(([path]) => path.endsWith('node_modules/dompurify')).map(([, dependency]) => dependency.version);
17
- assert.deepEqual(sanitizerVersions, ['3.4.17']);
12
+ assert.match(manifest.overrides['@file-viewer/doc'].dompurify,/^\^3\./);
18
13
  const require = createRequire(import.meta.url);
19
14
  const root = resolve(dirname(require.resolve('@jsw-teams/document-viewer')), '..');
20
15
  const component = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
21
16
  assert.ok(!component.dependencies['@file-viewer/ppt']);
22
- assert.equal(component.dependencies.cfb, '1.2.2');
17
+ assert.match(component.dependencies.cfb, /^\^1\./);
23
18
  for (const path of ['src/ppt/parser.js', 'src/ppt/worker.js', 'src/renderers/ppt.js', 'src/icons.js', 'src/sheets/columns.js', 'src/sheets/column-controls.js', 'src/lucide-license.txt', 'tools/assets.mjs', 'NOTICE.md']) {
24
19
  const content = await readFile(resolve(root, path), 'utf8');
25
20
  assert.ok(content.length);
@@ -60,7 +60,9 @@ test('embed entry stays compact until requested, retries failures and keeps keyb
60
60
  assert.equal(await entry.locator('[data-oembed-status]').evaluate(element => element.getBoundingClientRect().height), 0);
61
61
  assert((await entry.boundingBox()).height < 300);
62
62
  assert.equal(requests, 0);
63
- await button.focus(); await page.keyboard.press('Enter');
63
+ // Locator.press does not wait for CSS visibility; hidden controls cannot receive focus.
64
+ await button.waitFor({state:'visible'});
65
+ await button.press('Enter');
64
66
  // This is an event counter, so deferred text-view styles must not make
65
67
  // the assertion depend on the browser scheduling animation frames.
66
68
  await page.waitForFunction(() => window.testOpened > 0, null, { polling: 50 });
@@ -68,7 +70,8 @@ test('embed entry stays compact until requested, retries failures and keeps keyb
68
70
  assert.equal(requests, 0);
69
71
  assert.equal(await button.isEnabled(), true);
70
72
  await page.evaluate(() => { window.testAllowed = true; });
71
- await button.focus(); await page.keyboard.press('Enter');
73
+ await button.waitFor({state:'visible'});
74
+ await button.press('Enter');
72
75
  await page.waitForFunction(() => document.querySelector('[data-edgepress-oembed]').getAttribute('aria-busy') === 'true');
73
76
  assert.equal(await button.isEnabled(), false);
74
77
  const pendingBounds = await entry.locator('[data-oembed-placeholder]').boundingBox();
@@ -81,7 +84,8 @@ test('embed entry stays compact until requested, retries failures and keeps keyb
81
84
  await page.waitForTimeout(300);
82
85
  assert.equal(calls,1);
83
86
  assert.equal(await entry.getAttribute('data-oembed-unavailable'),'true');
84
- await button.focus(); await page.keyboard.press('Enter');
87
+ await button.waitFor({state:'visible'});
88
+ await button.press('Enter');
85
89
  await page.waitForFunction(() => document.querySelector('[data-edgepress-oembed]').dataset.loaded === 'true');
86
90
  await page.frameLocator('iframe').getByText('Shared story content').waitFor();
87
91
  assert.equal(calls, 2);
@@ -0,0 +1,106 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, readFile, writeFile, rm, symlink } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { chromium } from 'playwright';
7
+ import { startPageEditor } from '../src/page-editor.js';
8
+
9
+ const sample = {
10
+ $schema: '../../../schemas/pages.schema.json',
11
+ title: 'Hello', lang: 'en', homepage: true,
12
+ description: 'Example', customSetting: { untouched: ['a', 'b'] },
13
+ blocks: [{ columns: 2, cells: [
14
+ [{ type: 'heading', level: 2, text: 'First' }],
15
+ [{ type: 'section', title: 'Other', blocks: [{ type: 'text', text: 'Nested' }], custom: 7 }]
16
+ ] }]
17
+ };
18
+
19
+ async function withEditor(run) {
20
+ const root = await mkdtemp(join(tmpdir(), 'edgepress-visual-editor-'));
21
+ const dir = resolve(root, 'content/pages/home');
22
+ await mkdir(dir, { recursive: true });
23
+ const file = resolve(dir, 'en.json');
24
+ await writeFile(file, JSON.stringify(sample, null, 2) + '\n');
25
+ const editor = await startPageEditor({ root, port: 0 });
26
+ try { await run({ root, file, ...editor }); }
27
+ finally { await editor.close(); await rm(root, { recursive: true, force: true }); }
28
+ }
29
+
30
+ test('local visual editor serves a content-safe interface and lists JSON Pages', async () => {
31
+ await withEditor(async ({ url }) => {
32
+ const response = await fetch(url);
33
+ assert.equal(response.status, 200);
34
+ assert.match(response.headers.get('content-security-policy'), /frame-ancestors 'none'/);
35
+ assert.match(await response.text(), /可视化 Pages 编辑器/);
36
+ const pages = await (await fetch(url + 'api/pages')).json();
37
+ assert.deepEqual(pages.pages, ['home/en.json']);
38
+ const script = await fetch(url + 'editor.js');
39
+ assert.equal(script.status, 200);
40
+ assert.match(script.headers.get('content-type'), /javascript/);
41
+ });
42
+ });
43
+
44
+ test('editor preserves unknown metadata and nested elements, detects external changes, and rejects unsafe writes', async () => {
45
+ await withEditor(async ({ url, file, root }) => {
46
+ const path = 'api/page?path=home%2Fen.json';
47
+ const original = await (await fetch(url + path)).json();
48
+ assert.deepEqual(original.page, sample);
49
+ const payload = { page: structuredClone(original.page), revision: original.revision };
50
+ payload.page.title = 'Edited';
51
+ payload.page.blocks[0].cells[0][0].text = 'Updated';
52
+ const update = () => fetch(url + path, { method: 'PUT', headers: { 'Content-Type': 'application/json', Origin: url.slice(0, -1) }, body: JSON.stringify(payload) });
53
+ let response = await update();
54
+ assert.equal(response.status, 200, await response.text());
55
+ const saved = JSON.parse(await readFile(file, 'utf8'));
56
+ assert.equal(saved.title, 'Edited');
57
+ assert.equal(saved.blocks[0].cells[0][0].text, 'Updated');
58
+ assert.deepEqual(saved.customSetting, sample.customSetting);
59
+ assert.deepEqual(saved.blocks[0].cells[1][0], sample.blocks[0].cells[1][0]);
60
+ response = await update();
61
+ assert.equal(response.status, 409);
62
+ const current = await (await fetch(url + path)).json();
63
+ current.page.blocks[0].columns = 1;
64
+ response = await fetch(url + path, { method: 'PUT', headers: { 'Content-Type': 'application/json', Origin: url.slice(0, -1) }, body: JSON.stringify(current) });
65
+ assert.equal(response.status, 400);
66
+ assert.equal(JSON.parse(await readFile(file, 'utf8')).blocks[0].columns, 2);
67
+ response = await fetch(url + path, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(current) });
68
+ assert.equal(response.status, 403);
69
+ assert.equal((await fetch(url + 'api/page?path=..%2F..%2Fsecret.json')).status, 400);
70
+ assert.equal((await fetch(url + 'api/page?path=home%2Fmissing.json')).status, 400);
71
+ const outside = await mkdtemp(join(tmpdir(), 'edgepress-visual-outside-'));
72
+ try {
73
+ await writeFile(resolve(outside, 'en.json'), JSON.stringify(sample));
74
+ await symlink(outside, resolve(root, 'content/pages/linked'), 'dir');
75
+ const list = await (await fetch(url + 'api/pages')).json();
76
+ assert.deepEqual(list.pages, ['home/en.json']);
77
+ assert.equal((await fetch(url + 'api/page?path=linked%2Fen.json')).status, 400);
78
+ } finally { await rm(outside, { recursive: true, force: true }); }
79
+ });
80
+ });
81
+
82
+ test('visual browser controls edit, preview and save a page without hand-writing JSON', { timeout: 45000 }, async () => {
83
+ await withEditor(async ({ url, file }) => {
84
+ const browser = await chromium.launch({ args: ['--disable-extensions'] });
85
+ try {
86
+ const page = await browser.newPage();
87
+ await page.goto(url);
88
+ await page.waitForFunction(() => document.querySelector('#page-name')?.textContent === 'Hello');
89
+ await page.locator('#title').fill('Visual title');
90
+ await page.locator('.canvas-row').first().locator('select').first().selectOption('1');
91
+ await page.locator('#add-row').click();
92
+ assert.equal(await page.locator('.canvas-row').count(), 2);
93
+ await page.locator('.canvas-row').last().locator('.pick').click();
94
+ await page.locator('#fields textarea[aria-label="text"]').fill('Typed in the visual editor');
95
+ await page.locator('#save').click();
96
+ await page.locator('#status.saved').waitFor();
97
+ const result = JSON.parse(await readFile(file, 'utf8'));
98
+ assert.equal(result.title, 'Visual title');
99
+ assert.equal(result.blocks[0].columns, 1);
100
+ assert.equal(result.blocks[0].cells[0].length, 2);
101
+ assert.equal(result.blocks[0].cells[0][1].custom, 7);
102
+ assert.equal(result.blocks[1].cells[0][0].text, 'Typed in the visual editor');
103
+ assert.deepEqual(result.customSetting, sample.customSetting);
104
+ } finally { await browser.close(); }
105
+ });
106
+ });
@@ -0,0 +1,125 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile, mkdir, writeFile, rm, symlink } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { chromium } from 'playwright';
7
+ import { createPage } from '../src/page-scaffold.js';
8
+ import { startPageEditor } from '../src/page-editor.js';
9
+
10
+ async function sandbox(run) {
11
+ const root = await mkdtemp(join(tmpdir(), 'edgepress-pages-create-'));
12
+ try { return await run(root); }
13
+ finally { await rm(root, { recursive: true, force: true }); }
14
+ }
15
+
16
+ test('createPage scaffolds valid localized JSON Pages without overwriting existing files', async () => {
17
+ await sandbox(async root => {
18
+ await mkdir(resolve(root, 'schemas'), { recursive: true });
19
+ await writeFile(resolve(root, 'schemas/pages.schema.json'), '{}');
20
+ const created = await createPage(root, { title: 'About This Site', locales: ['en', 'zh-CN'] });
21
+ assert.equal(created.path, 'about-this-site/en.json');
22
+ assert.equal(created.page.title, 'About This Site');
23
+ assert.equal(created.page.lang, 'en');
24
+ assert.equal(created.page.$schema, '../../../schemas/pages.schema.json');
25
+ assert.equal(created.page.blocks[0].cells[0][0].type, 'hero');
26
+ assert.deepEqual(JSON.parse(await readFile(created.file, 'utf8')), created.page);
27
+ await assert.rejects(createPage(root, { title: 'About This Site' }), /already exists/);
28
+ const translated = await createPage(root, {
29
+ title: '关于我们', slug: 'about-this-site', lang: 'zh-cn', locales: ['en', 'zh-CN']
30
+ });
31
+ assert.equal(translated.path, 'about-this-site/zh-cn.json');
32
+ assert.equal(translated.page.lang, 'zh-CN');
33
+ assert.notEqual(created.file, translated.file);
34
+ await assert.rejects(createPage(root, { title: 'No', slug: '../oops' }), /slug/);
35
+ await assert.rejects(createPage(root, { title: 'No', slug: 'OOPS' }), /slug/);
36
+ await assert.rejects(createPage(root, { title: 'No', lang: 'fr', locales: ['en'] }), /locale/);
37
+ const existing = resolve(root, 'content/pages/legacy/en.md');
38
+ await mkdir(resolve(root, 'content/pages/legacy'));
39
+ await writeFile(existing, '# legacy');
40
+ await assert.rejects(createPage(root, { title: 'Legacy' }), /already exists/);
41
+ assert.equal(await readFile(existing, 'utf8'), '# legacy');
42
+ });
43
+ });
44
+
45
+ test('createPage refuses to use a symlink as a page folder', async () => {
46
+ await sandbox(async root => {
47
+ const target = resolve(root, 'unrelated');
48
+ await mkdir(resolve(root, 'content/pages'), { recursive: true });
49
+ await mkdir(target);
50
+ await symlink(target, resolve(root, 'content/pages/linked'), 'dir');
51
+ await assert.rejects(createPage(root, { title: 'linked' }), /Unsafe/);
52
+ });
53
+ });
54
+
55
+ test('public editor catalog covers every supported Pages type and reusable website components', async () => {
56
+ const source = JSON.parse(await readFile('src/page-block-catalog.json', 'utf8'));
57
+ const schema = JSON.parse(await readFile('schemas/pages.schema.json', 'utf8'));
58
+ const types = schema.definitions.element.properties.type.enum;
59
+ assert.equal(types.length, 30);
60
+ assert.deepEqual(Object.keys(source.blocks).sort(), [...types].sort());
61
+ for (const [key, definition] of Object.entries(source.blocks)) {
62
+ assert.equal(definition.template.type, key);
63
+ assert.ok(definition.label && definition.category);
64
+ }
65
+ for (const type of ['link-directory', 'privacy-controller', 'media-text', 'feature-grid', 'media-viewer', 'local-tool']) {
66
+ assert.ok(source.blocks[type], type + ' must be supported for web and other site consumers');
67
+ }
68
+ for (const type of ['captcha', 'oembed', 'service', 'ad-slot', 'local-tool']) {
69
+ assert.equal(source.blocks[type].requiresSetup, true);
70
+ }
71
+ });
72
+
73
+ test('page API creates a page and enforces local-origin, duplicate and path restrictions', async () => {
74
+ await sandbox(async root => {
75
+ const editor = await startPageEditor({ root, port: 0, locales: ['en', 'zh-CN'] });
76
+ try {
77
+ const request = (body, origin) => fetch(editor.url + 'api/pages', {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json', ...(origin ? { Origin: origin } : {}) },
80
+ body: JSON.stringify(body)
81
+ });
82
+ assert.equal((await request({ title: 'Test' })).status, 403);
83
+ const origin = editor.url.slice(0, -1);
84
+ assert.equal((await request({ title: 'Test', lang: 'fr' }, origin)).status, 400);
85
+ assert.equal((await request({ title: 'Test', slug: '../../no' }, origin)).status, 400);
86
+ const created = await request({ title: 'New Page', slug: 'my-new-page', lang: 'en' }, origin);
87
+ assert.equal(created.status, 201);
88
+ const data = await created.json();
89
+ assert.equal(data.path, 'my-new-page/en.json');
90
+ assert.equal((await request({ title: 'New Page', slug: 'my-new-page' }, origin)).status, 409);
91
+ const onDisk = JSON.parse(await readFile(resolve(root, 'content/pages/my-new-page/en.json'), 'utf8'));
92
+ assert.equal(onDisk.title, 'New Page');
93
+ const registry = await (await fetch(editor.url + 'api/block-catalog')).json();
94
+ assert.equal(Object.keys(registry.blocks).length, 30);
95
+ } finally { await editor.close(); }
96
+ });
97
+ });
98
+
99
+ test('browser creates a Page and adds a specialized shared block without editing JSON', { timeout: 45000 }, async () => {
100
+ await sandbox(async root => {
101
+ const editor = await startPageEditor({ root, port: 0, locales: ['en', 'zh-CN'] });
102
+ const browser = await chromium.launch({ args: ['--disable-extensions'] });
103
+ try {
104
+ const page = await browser.newPage();
105
+ await page.goto(editor.url);
106
+ await page.locator('#new-page').click();
107
+ await page.locator('#create-title').fill('Site Information');
108
+ await page.locator('#create-slug').fill('site-info');
109
+ await page.locator('#create-lang').selectOption('en');
110
+ await page.getByRole('button', { name: '创建并编辑' }).click();
111
+ await page.waitForFunction(() => document.querySelector('#page-path')?.textContent?.includes('site-info/en.json'));
112
+ await page.waitForFunction(() => [...document.querySelectorAll('#canvas .canvas-cell select option')].some(option => option.value === 'privacy-controller'));
113
+ const selector = page.locator('#canvas .canvas-cell select').first();
114
+ await selector.selectOption('privacy-controller');
115
+ await page.locator('#canvas .canvas-cell button').filter({ hasText: '添加' }).first().click();
116
+ await page.locator('#save').click();
117
+ await page.locator('#status.saved').waitFor();
118
+ const saved = JSON.parse(await readFile(resolve(root, 'content/pages/site-info/en.json'), 'utf8'));
119
+ assert.equal(saved.title, 'Site Information');
120
+ assert.equal(saved.blocks[0].cells[0][1].type, 'privacy-controller');
121
+ assert.equal(saved.blocks[0].cells[0][1].contactLabel, '联系方式');
122
+ assert.equal(saved.blocks[0].cells[0][0].type, 'hero');
123
+ } finally { await browser.close(); await editor.close(); }
124
+ });
125
+ });
@@ -0,0 +1,32 @@
1
+ // Capture real guest terminal/editor/browser interactions, never host input or drawn terminal frames.
2
+ import assert from 'node:assert/strict';
3
+ import {mkdir,writeFile} from 'node:fs/promises';
4
+ import {resolve} from 'node:path';
5
+ import {chromium} from 'playwright';
6
+ import {workbench} from './demo-workbench.mjs';
7
+ import {recordingContext,record,closeRecording} from './desktop-recording.mjs';
8
+ import {activate,pointAt} from './vm-desktop.mjs';
9
+
10
+ const root=resolve(import.meta.dirname,'../..'),artifact=resolve(root,'tools/.recordings/cli-native');
11
+ const output=resolve(root,'content/assets/images/previews');
12
+ await mkdir(artifact,{recursive:true});await mkdir(output,{recursive:true});
13
+ const browser=await chromium.connectOverCDP('http://127.0.0.1:9222');
14
+ const context=await recordingContext(browser,{locale:'en'},artifact);
15
+ let bench;
16
+ try{
17
+ bench=await workbench({artifact,packageRoot:root,locale:'en',context});
18
+ const page=await context.newPage(),errors=[];page.on('pageerror',error=>errors.push(error.message));
19
+ await record(page,'edgepress-cli-local',async({omitWait})=>{
20
+ await bench.activateTerminal();await bench.newPost();
21
+ await bench.editPost('## A working local site\n\nThis article was created in the real CLI and saved in Geany.\n\nThe local preview runs with **npm run deploy:local**.');
22
+ await bench.activateTerminal();await bench.startServer({omitWait,command:'npm run deploy:local --'});
23
+ await activate('Chromium');await page.goto('https://edgepress-demo.test/');
24
+ const link=page.getByRole('link',{name:bench.title,exact:true}).first();
25
+ await link.waitFor();await pointAt(page,link);await link.click();
26
+ await page.getByRole('heading',{name:'A working local site',exact:true}).waitFor();
27
+ await new Promise(done=>setTimeout(done,2400));
28
+ },{foreground:()=>bench.activateTerminal()});
29
+ assert.deepEqual(errors,[]);
30
+ const entries=await closeRecording(context,{artifact,output,source:'tools/recordings/capture-cli-demo.mjs'});
31
+ await writeFile(resolve(artifact,'result.json'),JSON.stringify(entries,null,2)+'\n');
32
+ }finally{await bench?.dispose();await browser.close();}
@@ -2,7 +2,7 @@
2
2
  // Geany edits the actual generated file. Nothing is installed into production.
3
3
  import assert from 'node:assert/strict';
4
4
  import {readFile,writeFile,mkdir,rm,cp} from 'node:fs/promises';
5
- import {resolve,sep,extname} from 'node:path';
5
+ import {resolve,sep} from 'node:path';
6
6
  import {spawn} from 'node:child_process';
7
7
  import {pathToFileURL} from 'node:url';
8
8
  import {guest,guestPath,typeCommand,editFile,openApp,activate} from './vm-desktop.mjs';
@@ -23,7 +23,7 @@ export async function workbench({artifact,packageRoot,photoPath,locale,context,e
23
23
  await rm(resolve(root,'content/pages'),{recursive:true,force:true});await mkdir(resolve(root,'content/pages/home'),{recursive:true});
24
24
  await writeFile(resolve(root,'content/pages/home',locale.toLowerCase()+'.json'),JSON.stringify({title:settings.site.title,lang:locale,slug:'home',homepage:true,blocks:[{columns:1,cells:[[{type:'hero',eyebrow:chinese?'生活里的小事':'Notes from everyday life',title:settings.site.title,text:settings.site.description}]]},{columns:1,cells:[[{type:'post-list',title:chinese?'最近的文章':'Recent stories',category:'uncategorized',count:3}]]}]},null,2)+'\n');
25
25
  if(embedding){settings.plugins.consent.services=[{id:'ishare',provider:'oembed',backendUrl:'https://ishare.js.gripe',enabled:true,name:chinese?'爱分享':'ishare',purpose:chinese?'在你同意后显示作者分享的图片与视频。':'Show the author’s shared photos and videos after you agree.',dataCategories:chinese?'请求的媒体和 IP 地址。':'Requested media and IP address.',recipient:'ishare',retention:chinese?'直到发布者删除。':'Until the publisher deletes it.',privacyUrl:'https://ishare.js.gripe/privacy/'}];await writeFile(resolve(root,'config.yml'),stringify(settings));}
26
- await mkdir(resolve(root,'content/assets/photos'),{recursive:true});await cp(photoPath,resolve(root,'content/assets/photos/seaside.jpg'));
26
+ if(photoPath){await mkdir(resolve(root,'content/assets/photos'),{recursive:true});await cp(photoPath,resolve(root,'content/assets/photos/seaside.jpg'));}
27
27
  let file=null,port;
28
28
  const terminalTitle='EdgePress '+locale;
29
29
  const {loadConfig}=await import(pathToFileURL(resolve(upstream,'src/config.js')));const {readDocuments}=await import(pathToFileURL(resolve(upstream,'src/content.js')));
@@ -37,9 +37,9 @@ export async function workbench({artifact,packageRoot,photoPath,locale,context,e
37
37
  async function createPost(){await run(['new','post',title]);const article=await findPost();assert.ok(article);return file;}
38
38
  async function newPost(){await typeCommand(root,terminalTitle,"edgepress new post '"+title+"'");for(let attempt=0;attempt<30;attempt++){const article=await findPost();if(article)return;await new Promise(done=>setTimeout(done,300));}throw Error('CLI did not create the post');}
39
39
  async function editPost(body){assert.ok(file);const front=(await readFile(file,'utf8')).split('---')[1];await editFile(root,file,'---'+front+'---\n\n'+body+'\n');}
40
- async function startServer({omitWait=task=>task()}={}){
40
+ async function startServer({omitWait=task=>task(),command='edgepress server'}={}){
41
41
  const {createServer}=await import('node:net');const socket=createServer();await new Promise(done=>socket.listen(0,'127.0.0.1',done));port=socket.address().port;await new Promise(done=>socket.close(done));
42
- await typeCommand(root,terminalTitle,'edgepress server --port '+port);
42
+ await typeCommand(root,terminalTitle,command+' --port '+port);
43
43
  await new Promise(done=>setTimeout(done,1600));
44
44
  await omitWait(async()=>{for(let attempt=0;attempt<180;attempt++){try{const response=await fetch('http://127.0.0.1:'+port+'/',{signal:AbortSignal.timeout(1200)});if(response.ok)return;}catch{}await new Promise(done=>setTimeout(done,500));}throw Error('Guest preview did not become ready');});
45
45
  }
@@ -57,7 +57,7 @@ export async function record(page, name, story, {foreground} = {}) {
57
57
  }
58
58
 
59
59
  // Export the VM desktop master after the browser fixture has been disposed.
60
- export async function closeRecording(context, {artifact, output}) {
60
+ export async function closeRecording(context, {artifact, output, source='tools/recordings/desktop-recording.mjs', kind}) {
61
61
  await context.close();
62
62
  const session = sessions.get(context);
63
63
  const entries = [];
@@ -81,7 +81,7 @@ export async function closeRecording(context, {artifact, output}) {
81
81
  const poster = name + '.' + hash(await readFile(posterPath)) + '.webp';
82
82
  for (const [source, target] of [[gif, file], [movie, movieFile], [posterPath, poster]]) await cp(source, resolve(output, target));
83
83
  const entry = {name, file, poster, movie:movieFile, movieBytes:movieBytes.length, duration, bytes:bytes.length,
84
- date:new Date().toISOString().slice(0, 10), kind:'FFmpeg recording of an isolated WSL2 desktop; '+(name.startsWith('edgepress-')||name.startsWith('ishare-sharing-')?'real terminal, editor and Chromium; preview startup wait shortened':'actual Chromium interface')+'; isolated service fixtures', source:'tools/recordings/capture-project-demos.mjs'};
84
+ date:new Date().toISOString().slice(0, 10), kind:kind||'FFmpeg recording of an isolated WSL2 desktop; '+(name.startsWith('edgepress-')||name.startsWith('ishare-sharing-')?'real terminal, editor and Chromium; preview startup wait shortened':'actual Chromium interface')+'; isolated service fixtures', source};
85
85
  entries.push(entry);
86
86
  const manifest = resolve(artifact, 'manifest.json'), previous = await readFile(manifest, 'utf8').then(JSON.parse).catch(() => []);
87
87
  await writeFile(manifest, JSON.stringify([...previous.filter(item => item.name !== name), entry], null, 2) + '\n');