edgepress 2026.10.13 → 2026.10.14
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 +22 -3
- package/package.json +11 -8
- package/src/cli.js +39 -4
- package/src/page-block-catalog.json +345 -0
- package/src/page-editor-client.js +396 -0
- package/src/page-editor-extension.js +86 -0
- package/src/page-editor.css +1 -0
- package/src/page-editor.html +40 -0
- package/src/page-editor.js +178 -0
- package/src/page-scaffold.js +70 -0
- package/tests/document-package.test.mjs +4 -9
- package/tests/oembed-layout.test.mjs +7 -3
- package/tests/page-editor.test.mjs +106 -0
- package/tests/page-scaffold.test.mjs +125 -0
|
@@ -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
|
|
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:\/\/
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
});
|