feat: share the optional article editor across React and Filament hosts
Verify WYSIWYG package / Full verification (pull_request) Successful in 1m9s
Verify WYSIWYG package / Immutable editor package (pull_request) Skipped
Verify WYSIWYG package / Full verification (push) Successful in 1m3s
Verify WYSIWYG package / Immutable editor package (push) Successful in 15s
Verify WYSIWYG package / Full verification (pull_request) Successful in 1m9s
Verify WYSIWYG package / Immutable editor package (pull_request) Skipped
Verify WYSIWYG package / Full verification (push) Successful in 1m3s
Verify WYSIWYG package / Immutable editor package (push) Successful in 15s
This commit was merged in pull request #4.
This commit is contained in:
@@ -20,4 +20,22 @@ jobs:
|
||||
php-version: '8.3'
|
||||
tools: composer
|
||||
- run: composer validate --strict && php -l src/WysiwygServiceProvider.php
|
||||
- run: php test/assets.test.php
|
||||
- run: git diff --check
|
||||
publish:
|
||||
name: Immutable editor package
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs: verify
|
||||
runs-on: ci
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
- run: npm ci --ignore-scripts && npm run build && node scripts/build-release.mjs
|
||||
- run: node scripts/publish-release.mjs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
node_modules/
|
||||
*.tgz
|
||||
dist/
|
||||
release/
|
||||
|
||||
@@ -4,7 +4,8 @@ A small, dependency-free browser editing engine for real website previews.
|
||||
Works with Filament 3 and 5 host adapters. The browser engine has no framework
|
||||
dependency; an optional Laravel 12 service provider serves the same engine to
|
||||
the Filament parent without copying assets between repositories.
|
||||
It is not a CMS, rich-document editor or generic HTML page builder.
|
||||
The optional article export adds a shared rich-document editor. This package
|
||||
is not a CMS, synchronization service or generic HTML page builder.
|
||||
|
||||
```js
|
||||
import { createInlineEditor } from '@tropikal/filament-wysiwyg';
|
||||
@@ -29,7 +30,8 @@ destroy the caret. Call `destroy()` on teardown.
|
||||
Images are selected by the host and edited with its existing media controls;
|
||||
the engine deliberately does not own file storage or duplicate business models.
|
||||
Pin an immutable repository commit in each consumer's lockfile. Run `npm ci`
|
||||
and `npm run verify` before release. There is no runtime dependency or build step.
|
||||
and `npm run verify` before release. The page engine has no runtime dependency
|
||||
or build step; the optional article editor is built separately.
|
||||
|
||||
`createEditQueue({ storage, key, deliver, onChange })` serializes draft RPCs and
|
||||
backs up pending text in browser storage. Scope `key` to user/site/page. Failed
|
||||
@@ -52,3 +54,32 @@ from this public VCS repository at an immutable released tag, then import
|
||||
serves only the package's static module and validates its content hash. It has
|
||||
no content API, database or configurable file path. The npm and Composer
|
||||
lockfiles should resolve to the same release commit.
|
||||
|
||||
## Optional article editor
|
||||
|
||||
React hosts import `ArticleDocumentEditor` from the `/article` export. Filament
|
||||
loads the compiled `/article/standalone` module only on its article screen and
|
||||
calls `mountArticleEditor(element, props)`. Both use the same component and
|
||||
Markdown model, not separate editor implementations. Optional peers are needed
|
||||
only for React/source consumers; the standalone bundle includes its dependencies.
|
||||
|
||||
The host supplies a trusted article projection, `documentKey`, `saveToken`,
|
||||
`onSave(content)`, and optionally `storageKey`, `onBlocked`, `uiLocale` (`en`/`de`),
|
||||
`resolvePreview(path)` and `components.MediaPicker`. React hosts may inject their
|
||||
own Button, Dialog, TextField, TextArea, Select and readonly Preview controls.
|
||||
Scope document/storage keys to authenticated user, website, immutable revision
|
||||
and save token. Never restore another user's draft or automatically retry a save.
|
||||
A confirmed save returns `{ revision_id }`; onSave must reject on a failed or
|
||||
uncertain response. Update host props with the verified new revision afterward.
|
||||
|
||||
The editor preserves supported Markdown, tables and owned image identities.
|
||||
Unsupported formatting remains editable in source mode without silent flattening.
|
||||
No MediaPicker means image replacement/insertion is unavailable. Media URLs and
|
||||
metadata come only from authorized host responses; no credentials belong in props.
|
||||
Publishing, permission checks, category/author records and cache policy remain
|
||||
outside this package. Serve drafts and private image previews with no-store;
|
||||
cache approved public projections in the website's existing Laravel cache.
|
||||
|
||||
Protected-main CI publishes an exact-SHA Composer ZIP, npm tarball, Composer
|
||||
descriptor and SHA-256 manifest. Article consumers must install those immutable
|
||||
artifacts, not a branch checkout: generated bundles are not tracked in Git.
|
||||
|
||||
Generated
+1511
-3
File diff suppressed because it is too large
Load Diff
+52
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@tropikal/filament-wysiwyg",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.0",
|
||||
"description": "Small framework-independent inline editing engine for Filament website previews",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -8,18 +8,65 @@
|
||||
".": {
|
||||
"types": "./src/index.d.ts",
|
||||
"import": "./src/index.js"
|
||||
}
|
||||
},
|
||||
"./article/model": "./src/article/model.js",
|
||||
"./article": "./src/article/index.jsx",
|
||||
"./article/editor.css": "./src/article/editor.css",
|
||||
"./article/standalone": "./dist/article-editor.js"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test",
|
||||
"verify": "npm test && npm audit --audit-level=high && npm pack --dry-run"
|
||||
"build": "node scripts/build-article.mjs",
|
||||
"test": "npm run build && node --test",
|
||||
"i18n:check": "node scripts/check-i18n-exact.mjs --source src/article --catalog en=src/article/locales/en.json --catalog de=src/article/locales/de.json",
|
||||
"verify": "npm test && npm run i18n:check && npm audit --audit-level=high && npm pack --dry-run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "26.1.0"
|
||||
"jsdom": "26.1.0",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-image": "3.31.3",
|
||||
"@tiptap/extension-paragraph": "3.31.3",
|
||||
"@tiptap/extension-table": "3.31.3",
|
||||
"@tiptap/markdown": "3.31.3",
|
||||
"@tiptap/react": "3.31.3",
|
||||
"@tiptap/starter-kit": "3.31.3",
|
||||
"markdown-it": "15.0.2",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"esbuild": "0.28.2",
|
||||
"@babel/parser": "7.28.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-image": "3.31.3",
|
||||
"@tiptap/extension-paragraph": "3.31.3",
|
||||
"@tiptap/extension-table": "3.31.3",
|
||||
"@tiptap/markdown": "3.31.3",
|
||||
"@tiptap/react": "3.31.3",
|
||||
"@tiptap/starter-kit": "3.31.3",
|
||||
"markdown-it": "15.0.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@tiptap/core": { "optional": true },
|
||||
"@tiptap/extension-image": { "optional": true },
|
||||
"@tiptap/extension-paragraph": { "optional": true },
|
||||
"@tiptap/extension-table": { "optional": true },
|
||||
"@tiptap/markdown": { "optional": true },
|
||||
"@tiptap/react": { "optional": true },
|
||||
"@tiptap/starter-kit": { "optional": true },
|
||||
"markdown-it": { "optional": true },
|
||||
"react": { "optional": true },
|
||||
"react-dom": { "optional": true }
|
||||
},
|
||||
"overrides": {
|
||||
"@tiptap/extension-bubble-menu": "3.31.3",
|
||||
"@tiptap/extension-floating-menu": "3.31.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { build } from 'esbuild';
|
||||
|
||||
await build({
|
||||
entryPoints: ['src/article/standalone.jsx'], outfile: 'dist/article-editor.js',
|
||||
bundle: true, format: 'esm', target: ['es2022'], jsx: 'automatic',
|
||||
define: { 'process.env.NODE_ENV': '"production"' }, minify: true,
|
||||
legalComments: 'linked', metafile: false,
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { cp, mkdir, mkdtemp, readFile, readdir, utimes, writeFile } from 'node:fs/promises';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const commit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
||||
if (execFileSync('git', ['status', '--porcelain'], { encoding: 'utf8' }).trim()) {
|
||||
throw new Error('Package source must be a clean committed checkout');
|
||||
}
|
||||
if (process.env.GITHUB_SHA && process.env.GITHUB_SHA !== commit) throw new Error('Checkout revision differs');
|
||||
const { version } = JSON.parse(await readFile('package.json', 'utf8'));
|
||||
const output = resolve('release');
|
||||
await mkdir(output, { recursive: true });
|
||||
const workspace = await mkdtemp(`${tmpdir()}/tropikal-wysiwyg-package-`);
|
||||
for (const path of ['composer.json', 'LICENSE', 'README.md', 'src', 'dist']) await cp(path, `${workspace}/${path}`, { recursive: true });
|
||||
const epoch = new Date('2000-01-01T00:00:00Z');
|
||||
const files = [];
|
||||
async function normalize(path) {
|
||||
for (const item of (await readdir(path, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const child = `${path}/${item.name}`;
|
||||
if (item.isDirectory()) await normalize(child);
|
||||
else files.push(child.slice(workspace.length + 1));
|
||||
await utimes(child, epoch, epoch);
|
||||
}
|
||||
}
|
||||
await normalize(workspace);
|
||||
const zip = `filament-wysiwyg-${commit}.zip`;
|
||||
execFileSync('zip', ['-X', '-q', `${output}/${zip}`, ...files], {
|
||||
cwd: workspace, env: { ...process.env, TZ: 'UTC' },
|
||||
});
|
||||
const packed = JSON.parse(execFileSync('npm', ['pack', '--json', '--pack-destination', output], { encoding: 'utf8' }))[0];
|
||||
const hash = (bytes, algorithm) => createHash(algorithm).update(bytes).digest('hex');
|
||||
const archive = await readFile(`${output}/${zip}`);
|
||||
const base = `https://git.tropikal.dev/tropikal-open-source/tropikal-filament-wysiwyg/releases/download/v${version}`;
|
||||
const composer = JSON.parse(await readFile('composer.json', 'utf8'));
|
||||
await writeFile(`${output}/composer-package.json`, JSON.stringify({ type: 'package', package: {
|
||||
...composer, version, dist: { type: 'zip', url: `${base}/${zip}`, reference: commit, shasum: hash(archive, 'sha1') },
|
||||
} }, null, 2) + '\n');
|
||||
const assets = [];
|
||||
for (const name of [zip, packed.filename, 'composer-package.json']) {
|
||||
assets.push({ name, sha256: hash(await readFile(`${output}/${name}`), 'sha256') });
|
||||
}
|
||||
await writeFile(`${output}/manifest.json`, JSON.stringify({ version, commit, npm_integrity: packed.integrity, assets }, null, 2) + '\n');
|
||||
console.log(`Built immutable package ${commit}`);
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parse } from "@babel/parser";
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"]);
|
||||
const TEST_SEGMENTS = new Set(["__tests__", "test", "tests"]);
|
||||
const DEFAULT_CALLS = new Set(["t", "tkT"]);
|
||||
|
||||
function usage(message = "") {
|
||||
const detail = message ? `\nError: ${message}\n` : "";
|
||||
return `Check exact equality between translation keys used by source and every locale catalog.${detail}
|
||||
Usage:
|
||||
node check-i18n-exact.mjs \\
|
||||
--source src \\
|
||||
--catalog en=src/locales/en.json \\
|
||||
--catalog de=src/locales/de.json \\
|
||||
[--registry src/i18n.dynamic-keys.json] \\
|
||||
[--call t,tkT] [--prefix ops.] [--include-tests]
|
||||
|
||||
Registries are JSON arrays of translation keys. They declare the finite domain
|
||||
used by dynamic lookups and count as source usage; they are not ignore lists.`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {
|
||||
sources: [], catalogs: [], registries: [], calls: new Set(DEFAULT_CALLS),
|
||||
prefixes: [], includeTests: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--help" || arg === "-h") return { help: true };
|
||||
if (arg === "--include-tests") {
|
||||
result.includeTests = true;
|
||||
continue;
|
||||
}
|
||||
const value = argv[++index];
|
||||
if (!value) throw new Error(`Missing value for ${arg}`);
|
||||
if (arg === "--source") result.sources.push(value);
|
||||
else if (arg === "--catalog") {
|
||||
const separator = value.indexOf("=");
|
||||
if (separator < 1 || separator === value.length - 1) {
|
||||
throw new Error(`Catalog must be locale=path, received ${value}`);
|
||||
}
|
||||
result.catalogs.push([value.slice(0, separator), value.slice(separator + 1)]);
|
||||
} else if (arg === "--registry") result.registries.push(value);
|
||||
else if (arg === "--call") result.calls = new Set(value.split(",").filter(Boolean));
|
||||
else if (arg === "--prefix") result.prefixes.push(value);
|
||||
else throw new Error(`Unknown argument ${arg}`);
|
||||
}
|
||||
if (!result.sources.length) throw new Error("At least one --source is required");
|
||||
if (result.catalogs.length < 2) throw new Error("At least two --catalog values are required");
|
||||
return result;
|
||||
}
|
||||
|
||||
function walk(entry, includeTests) {
|
||||
const absolute = path.resolve(entry);
|
||||
const stat = fs.statSync(absolute);
|
||||
if (stat.isFile()) return SOURCE_EXTENSIONS.has(path.extname(absolute)) ? [absolute] : [];
|
||||
const files = [];
|
||||
for (const dirent of fs.readdirSync(absolute, { withFileTypes: true })) {
|
||||
if (dirent.name.startsWith(".") || dirent.name === "node_modules") continue;
|
||||
if (!includeTests && TEST_SEGMENTS.has(dirent.name)) continue;
|
||||
const child = path.join(absolute, dirent.name);
|
||||
if (dirent.isDirectory()) files.push(...walk(child, includeTests));
|
||||
else if (SOURCE_EXTENSIONS.has(path.extname(dirent.name))) {
|
||||
if (!includeTests && /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(dirent.name)) continue;
|
||||
files.push(child);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function readJsonWithDuplicateCheck(file) {
|
||||
const raw = fs.readFileSync(file, "utf8");
|
||||
const duplicates = [];
|
||||
const counts = new Map();
|
||||
const pattern = /"((?:\\.|[^"\\])*)"\s*:/g;
|
||||
let match;
|
||||
while ((match = pattern.exec(raw))) {
|
||||
let key = match[1];
|
||||
try { key = JSON.parse(`"${key}"`); } catch { /* JSON.parse below reports syntax. */ }
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
for (const [key, count] of counts) if (count > 1) duplicates.push(key);
|
||||
return { value: JSON.parse(raw), duplicates };
|
||||
}
|
||||
|
||||
function callName(expression) {
|
||||
if (expression?.type === "Identifier") return expression.name;
|
||||
if (expression?.type === "MemberExpression" && !expression.computed) {
|
||||
return expression.property?.name || "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function literalValue(node) {
|
||||
if (node?.type === "StringLiteral") return node.value;
|
||||
if (node?.type === "TemplateLiteral" && node.expressions.length === 0) {
|
||||
return node.quasis[0]?.value?.cooked ?? node.quasis[0]?.value?.raw ?? "";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectSourceKeys(files, calls) {
|
||||
const keys = new Set();
|
||||
const dynamicCalls = [];
|
||||
for (const file of files) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
const tree = parse(source, {
|
||||
sourceType: "unambiguous",
|
||||
plugins: ["jsx", "typescript", "decorators-legacy"],
|
||||
errorRecovery: false,
|
||||
});
|
||||
const visit = (node) => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (node.type === "CallExpression" && calls.has(callName(node.callee)) && node.arguments.length) {
|
||||
const value = literalValue(node.arguments[0]);
|
||||
if (value === null) {
|
||||
dynamicCalls.push(`${path.relative(process.cwd(), file)}:${node.loc?.start.line || 1}`);
|
||||
} else keys.add(value);
|
||||
}
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === "loc" || key === "start" || key === "end" || key === "extra") continue;
|
||||
if (Array.isArray(value)) value.forEach(visit);
|
||||
else if (value && typeof value === "object") visit(value);
|
||||
}
|
||||
};
|
||||
visit(tree.program);
|
||||
}
|
||||
return { keys, dynamicCalls };
|
||||
}
|
||||
|
||||
function filtered(values, prefixes) {
|
||||
const items = [...values];
|
||||
return new Set(prefixes.length ? items.filter((key) => prefixes.some((prefix) => key.startsWith(prefix))) : items);
|
||||
}
|
||||
|
||||
function difference(left, right) {
|
||||
return [...left].filter((key) => !right.has(key)).sort();
|
||||
}
|
||||
|
||||
function addFailure(failures, label, values) {
|
||||
if (values.length) failures.push(`${label}:\n ${values.join("\n ")}`);
|
||||
}
|
||||
|
||||
export function checkExactI18n(options) {
|
||||
const files = [...new Set(options.sources.flatMap((source) => walk(source, options.includeTests)))];
|
||||
const source = collectSourceKeys(files, options.calls);
|
||||
const used = new Set(source.keys);
|
||||
const failures = [];
|
||||
|
||||
for (const registryPath of options.registries) {
|
||||
const { value, duplicates } = readJsonWithDuplicateCheck(registryPath);
|
||||
if (!Array.isArray(value) || value.some((key) => typeof key !== "string" || !key)) {
|
||||
failures.push(`invalid registry ${registryPath}: expected a JSON array of non-empty strings`);
|
||||
continue;
|
||||
}
|
||||
const registryCounts = new Map();
|
||||
for (const key of value) registryCounts.set(key, (registryCounts.get(key) || 0) + 1);
|
||||
const repeatedValues = [...registryCounts]
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([key]) => key)
|
||||
.sort();
|
||||
addFailure(failures, `duplicate registry keys in ${registryPath}`, [
|
||||
...new Set([...duplicates, ...repeatedValues]),
|
||||
]);
|
||||
for (const key of value) used.add(key);
|
||||
}
|
||||
|
||||
if (source.dynamicCalls.length && !options.registries.length) {
|
||||
addFailure(failures, "unregistered dynamic translation calls", source.dynamicCalls);
|
||||
}
|
||||
|
||||
const expected = filtered(used, options.prefixes);
|
||||
for (const [locale, catalogPath] of options.catalogs) {
|
||||
const { value, duplicates } = readJsonWithDuplicateCheck(catalogPath);
|
||||
const keys = filtered(Object.keys(value), options.prefixes);
|
||||
addFailure(failures, `${locale} duplicate keys`, duplicates);
|
||||
addFailure(failures, `${locale} missing keys`, difference(expected, keys));
|
||||
addFailure(failures, `${locale} unused keys`, difference(keys, expected));
|
||||
addFailure(failures, `${locale} empty values`, [...keys]
|
||||
.filter((key) => typeof value[key] !== "string" || !value[key].trim()).sort());
|
||||
}
|
||||
|
||||
return { ok: failures.length === 0, failures, files: files.length, keys: expected.size };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let options;
|
||||
try { options = parseArgs(process.argv.slice(2)); }
|
||||
catch (error) {
|
||||
console.error(usage(error.message));
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (options.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
const result = checkExactI18n(options);
|
||||
if (!result.ok) {
|
||||
console.error(`Exact i18n validation failed:\n${result.failures.join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(`Exact i18n validation passed: ${result.keys} keys across ${options.catalogs.length} catalogs (${result.files} source files).`);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) await main();
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
export async function publishRelease({ server, repository, token, version, commit, assets, request = fetch }) {
|
||||
if (server !== 'https://git.tropikal.dev' || !/^[\w-]+\/[\w-]+$/.test(repository)
|
||||
|| !/^[a-f0-9]{40}$/.test(commit) || !/^\d+\.\d+\.\d+$/.test(version) || !token) {
|
||||
throw new Error('Invalid release authority');
|
||||
}
|
||||
const api = `${server}/api/v1/repos/${repository}/releases`;
|
||||
const headers = { Authorization: `token ${token}` };
|
||||
const call = (url, options = {}) => request(url, { ...options,
|
||||
headers: { ...headers, ...options.headers }, redirect: 'error', signal: AbortSignal.timeout(30000) });
|
||||
let response = await call(`${api}/tags/v${version}`);
|
||||
if (response.status === 404) {
|
||||
response = await call(api, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tag_name: `v${version}`, target_commitish: commit,
|
||||
name: `v${version}`, body: `Immutable protected-main package: ${commit}` }) });
|
||||
}
|
||||
if (!response.ok) throw new Error(`Release request failed (${response.status})`);
|
||||
const release = await response.json();
|
||||
if (release.target_commitish !== commit) throw new Error('Release source revision differs');
|
||||
for (const asset of assets) {
|
||||
const existing = (release.assets || []).find(item => item.name === asset.name);
|
||||
if (existing) {
|
||||
const url = new URL(existing.browser_download_url);
|
||||
if (url.origin !== server) throw new Error('Unexpected artifact origin');
|
||||
const saved = await call(url.href);
|
||||
if (!saved.ok || digest(Buffer.from(await saved.arrayBuffer())) !== digest(asset.bytes)) {
|
||||
throw new Error('Existing artifact differs; refusing overwrite');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('attachment', new Blob([asset.bytes]), asset.name);
|
||||
const uploaded = await call(`${api}/${release.id}/assets?name=${encodeURIComponent(asset.name)}`, {
|
||||
method: 'POST', body: form,
|
||||
});
|
||||
if (!uploaded.ok) throw new Error(`Artifact publication failed (${uploaded.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const { GITHUB_SERVER_URL: server, GITHUB_REPOSITORY: repository, GITHUB_SHA: commit,
|
||||
GITHUB_REF: ref, GITHUB_TOKEN: token } = process.env;
|
||||
if (ref !== 'refs/heads/main' || execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() !== commit) {
|
||||
throw new Error('Only the exact protected-main checkout may publish');
|
||||
}
|
||||
const manifest = JSON.parse(await readFile('release/manifest.json', 'utf8'));
|
||||
if (manifest.commit !== commit) throw new Error('Artifact source revision differs');
|
||||
const assets = [];
|
||||
for (const asset of manifest.assets) {
|
||||
if (!/^[\w.-]+$/.test(asset.name)) throw new Error('Invalid artifact name');
|
||||
const bytes = await readFile(`release/${asset.name}`);
|
||||
if (digest(bytes) !== asset.sha256) throw new Error('Artifact digest differs');
|
||||
assets.push({ name: asset.name, bytes });
|
||||
}
|
||||
assets.push({ name: 'manifest.json', bytes: await readFile('release/manifest.json') });
|
||||
await publishRelease({ server, repository, token, version: manifest.version, commit, assets });
|
||||
console.log(`Published immutable package ${commit}`);
|
||||
}
|
||||
@@ -9,23 +9,41 @@ final class WysiwygServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
Route::get('/tropikal-wysiwyg/{hash}/editor.js', function (string $hash) {
|
||||
abort_unless(hash_equals(self::hash(), $hash), 404);
|
||||
$this->asset('tropikal-wysiwyg.asset', 'editor.js', __DIR__.'/index.js', 'application/javascript; charset=UTF-8');
|
||||
$this->asset('tropikal-wysiwyg.article', 'article-editor.js', dirname(__DIR__).'/dist/article-editor.js', 'application/javascript; charset=UTF-8');
|
||||
$this->asset('tropikal-wysiwyg.article-css', 'article-editor.css', dirname(__DIR__).'/dist/article-editor.css', 'text/css; charset=UTF-8');
|
||||
}
|
||||
|
||||
return response()->file(__DIR__.'/index.js', [
|
||||
'Content-Type' => 'application/javascript; charset=UTF-8',
|
||||
private function asset(string $name, string $suffix, string $file, string $mime): void
|
||||
{
|
||||
Route::get('/tropikal-wysiwyg/{hash}/'.$suffix, function (string $hash) use ($file, $mime) {
|
||||
abort_unless(hash_equals(self::hash($file), $hash), 404);
|
||||
|
||||
return response()->file($file, [
|
||||
'Content-Type' => $mime,
|
||||
'Cache-Control' => 'public, max-age=31536000, immutable',
|
||||
]);
|
||||
})->name('tropikal-wysiwyg.asset');
|
||||
})->name($name);
|
||||
}
|
||||
|
||||
public static function assetUrl(): string
|
||||
{
|
||||
return route('tropikal-wysiwyg.asset', ['hash' => self::hash()]);
|
||||
return route('tropikal-wysiwyg.asset', ['hash' => self::hash(__DIR__.'/index.js')]);
|
||||
}
|
||||
|
||||
private static function hash(): string
|
||||
public static function articleAssetUrl(): string
|
||||
{
|
||||
return hash_file('sha256', __DIR__.'/index.js');
|
||||
return route('tropikal-wysiwyg.article', ['hash' => self::hash(dirname(__DIR__).'/dist/article-editor.js')]);
|
||||
}
|
||||
|
||||
public static function articleStylesheetUrl(): string
|
||||
{
|
||||
return route('tropikal-wysiwyg.article-css', ['hash' => self::hash(dirname(__DIR__).'/dist/article-editor.css')]);
|
||||
}
|
||||
|
||||
private static function hash(string $file): string
|
||||
{
|
||||
abort_unless(is_file($file), 404);
|
||||
return hash_file('sha256', $file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { articleExtensions, canRoundTrip, moveSelectedBlock, removeSelectedBlock, seedImageBlocks, serializeArticle, serializeMarkdownArticle } from './model.js';
|
||||
import * as controls from './controls.jsx';
|
||||
import en from './locales/en.json';
|
||||
import de from './locales/de.json';
|
||||
import './editor.css';
|
||||
|
||||
const messages = { en, de };
|
||||
const noop = () => {};
|
||||
const identity = path => path;
|
||||
|
||||
function readDraft(key, token) {
|
||||
if (!key) return null;
|
||||
try {
|
||||
const value = JSON.parse(sessionStorage.getItem(key));
|
||||
return value?.hash === token && typeof value.title === 'string'
|
||||
&& typeof value.excerpt === 'string' && typeof value.markdown === 'string'
|
||||
&& Array.isArray(value.image_blocks) ? value : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function ArticleDocumentEditor(props) {
|
||||
return <ArticleEditorDraft key={JSON.stringify([props.documentKey, props.saveToken])} {...props} />;
|
||||
}
|
||||
|
||||
function ArticleEditorDraft({ article, saveToken, storageKey = null, initialDraft, disabled, replacementPending, onSave, onBlocked = noop, onReady, components = {}, resolvePreview = identity, uiLocale = 'en',
|
||||
citationLinksOnly = false, requireComplete = true, externalDirty = false, markdownViews = true }) {
|
||||
const t = key => messages[uiLocale]?.[key] ?? messages.en[key];
|
||||
const { Button = controls.Button, Dialog = controls.Dialog, TextField = controls.TextField,
|
||||
TextArea = controls.TextArea, Select = controls.Select, MediaPicker, Preview = controls.Preview } = components;
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => { mounted.current = false; };
|
||||
}, []);
|
||||
const [restored] = useState(() => initialDraft ?? readDraft(storageKey, saveToken));
|
||||
const [title, setTitle] = useState(restored?.title ?? article.title);
|
||||
const [excerpt, setExcerpt] = useState(restored?.excerpt ?? article.excerpt);
|
||||
const [blocks, setBlocks] = useState(restored?.image_blocks || article.image_blocks || []);
|
||||
const [changed, setChanged] = useState(Boolean(restored));
|
||||
const [version, setVersion] = useState(0);
|
||||
const [supported, setSupported] = useState(null);
|
||||
const [mode, setMode] = useState('visual');
|
||||
const [markdownSource, setMarkdownSource] = useState(restored?.markdown ?? article.editable_markdown);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const recoveryWasPending = useRef(Boolean(replacementPending));
|
||||
useEffect(() => {
|
||||
if (recoveryWasPending.current && !replacementPending) setError('');
|
||||
recoveryWasPending.current = Boolean(replacementPending);
|
||||
}, [replacementPending]);
|
||||
const [draftRetained, setDraftRetained] = useState(true);
|
||||
const [insert, setInsert] = useState(false);
|
||||
const [media, setMedia] = useState(null);
|
||||
const addControl = useRef(null);
|
||||
const linkControl = useRef(null);
|
||||
const [link, setLink] = useState(null);
|
||||
const [linkError, setLinkError] = useState('');
|
||||
const initialized = useRef(false);
|
||||
const [previews, setPreviews] = useState(() => {
|
||||
const result = Object.fromEntries(Object.entries(article.media_previews || {}).map(([url, path]) => [url, resolvePreview(path)]));
|
||||
const cover = article.image_blocks?.find(block => block.role === 'cover');
|
||||
if (cover && article.cover_image?.preview_path) result[cover.url] = resolvePreview(article.cover_image.preview_path);
|
||||
return result;
|
||||
});
|
||||
const source = restored?.markdown ?? article.editable_markdown;
|
||||
const editor = useEditor({ immediatelyRender: false, extensions: articleExtensions(), content: source, contentType: 'markdown',
|
||||
editorProps: { attributes: { class: 'article-body', role: 'textbox', 'aria-multiline': 'true', 'aria-label': t('body') },
|
||||
handleClickOn: (_view, position, node) => {
|
||||
if (node.type.name !== 'image' || !MediaPicker) return false;
|
||||
setMedia({ id: node.attrs.blockId, position, role: 'inline', description: node.attrs.description,
|
||||
alt: node.attrs.alt || '', placement: node.attrs.placement || '', url: node.attrs.src, preview: node.attrs.previewSrc });
|
||||
return true;
|
||||
} },
|
||||
onCreate: ({ editor: value }) => {
|
||||
const safe = canRoundTrip(source, value.getMarkdown());
|
||||
setSupported(safe); value.setEditable(safe && !disabled, false);
|
||||
if (!safe && markdownViews) setMode('markdown');
|
||||
seedImageBlocks(value, blocks, previews); initialized.current = true;
|
||||
},
|
||||
onUpdate: () => { if (initialized.current) { setChanged(true); setVersion(value => value + 1); } },
|
||||
onSelectionUpdate: () => setVersion(value => value + 1),
|
||||
});
|
||||
const dirty = externalDirty || changed || title !== article.title || excerpt !== article.excerpt;
|
||||
useEffect(() => { onBlocked(dirty || busy || Boolean(media) || link !== null); }, [dirty, busy, media, link, onBlocked]);
|
||||
useEffect(() => { onReady?.(supported === true ? article.revision_id : null); }, [supported, article.revision_id, onReady]);
|
||||
useEffect(() => { editor?.setEditable(supported === true && !disabled && !busy, false); }, [editor, supported, disabled, busy]);
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const warn = (event) => { event.preventDefault(); event.returnValue = ''; };
|
||||
window.addEventListener('beforeunload', warn);
|
||||
return () => { window.removeEventListener('beforeunload', warn); };
|
||||
}, [dirty]);
|
||||
useEffect(() => {
|
||||
if (!storageKey || !dirty || !editor || !initialized.current) return;
|
||||
try {
|
||||
const body = mode === 'markdown' ? serializeMarkdownArticle(markdownSource, blocks) : serializeArticle(editor, blocks);
|
||||
const record = JSON.stringify({ hash: saveToken, title, excerpt, ...body });
|
||||
sessionStorage.setItem(storageKey, record);
|
||||
setDraftRetained(sessionStorage.getItem(storageKey) === record);
|
||||
} catch { setDraftRetained(false); }
|
||||
}, [dirty, editor, blocks, title, excerpt, version, storageKey, saveToken, mode, markdownSource]);
|
||||
async function save() {
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
const body = mode === 'markdown' ? serializeMarkdownArticle(markdownSource, blocks) : serializeArticle(editor, blocks);
|
||||
let recoveryRecord;
|
||||
try { recoveryRecord = storageKey ? sessionStorage.getItem(storageKey) : null; } catch { /* Saving does not depend on local recovery storage. */ }
|
||||
const receipt = await onSave({ title, excerpt, ...body, markdown: changed ? body.markdown : article.markdown });
|
||||
if (!mounted.current && !receipt?.revision_id) return;
|
||||
try {
|
||||
if (storageKey && typeof recoveryRecord === 'string' && sessionStorage.getItem(storageKey) === recoveryRecord) {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
}
|
||||
} catch { /* The server already confirmed this save. */ }
|
||||
if (!mounted.current) return;
|
||||
setChanged(false); onBlocked(false);
|
||||
} catch (failure) { if (mounted.current) setError(failure.message); }
|
||||
finally { if (mounted.current) setBusy(false); }
|
||||
}
|
||||
function changeMode(next) {
|
||||
setError('');setInsert(false);
|
||||
try {
|
||||
if (next === 'markdown') {
|
||||
const body = serializeArticle(editor, blocks);
|
||||
setMarkdownSource(changed ? body.markdown : article.markdown);setBlocks(body.image_blocks);setMode(next);
|
||||
} else {
|
||||
editor.commands.setContent(markdownSource, {contentType:'markdown', emitUpdate:false});
|
||||
const safe = canRoundTrip(markdownSource, editor.getMarkdown());
|
||||
setSupported(safe);
|
||||
if (!safe) {setError(t('unsupported'));return;}
|
||||
seedImageBlocks(editor, serializeMarkdownArticle(markdownSource, blocks).image_blocks, previews);
|
||||
setMode(next);
|
||||
}
|
||||
} catch {setError(t('unsupported'));}
|
||||
}
|
||||
function currentMediaNode() {
|
||||
let found = null;
|
||||
editor.state.doc.descendants((node, position) => { if (node.type.name === 'image' && node.attrs.blockId === media.id) found = { node, position }; });
|
||||
return found;
|
||||
}
|
||||
function applyMedia(replacement) {
|
||||
if (!mounted.current) return;
|
||||
const id = media.id || crypto.randomUUID();
|
||||
const value = { id, role: media.role || 'inline', url: replacement?.url || media.url,
|
||||
description: replacement?.description || media.description, alt: replacement?.alt ?? media.alt,
|
||||
placement: media.placement || '', ...(replacement?.allow_reuse ? { allow_reuse: true } : {}) };
|
||||
if (!value.description.trim()) { setError(t('contextHelp')); return; }
|
||||
if (replacement?.media_id) setPreviews(current => ({ ...current, [value.url]: resolvePreview(`/news/media/${replacement.media_id}/preview`) }));
|
||||
if (value.role === 'cover') setBlocks(current => [...current.filter(block => block.role !== 'cover'), value]);
|
||||
else {
|
||||
const attrs = { src: value.url, alt: value.alt, blockId: id, description: value.description,
|
||||
placement: value.placement, allowReuse: value.allow_reuse || false,
|
||||
previewSrc: replacement?.media_id ? resolvePreview(`/news/media/${replacement.media_id}/preview`) : media.preview || previews[value.url] || null };
|
||||
const found = media.id ? currentMediaNode() : null;
|
||||
if (found) editor.view.dispatch(editor.state.tr.setNodeMarkup(found.position, undefined, { ...found.node.attrs, ...attrs }));
|
||||
else editor.chain().focus().setImage(attrs).run();
|
||||
}
|
||||
setChanged(true); setMedia(null);
|
||||
}
|
||||
function removeImage() {
|
||||
if (media.role === 'cover') setBlocks(current => current.filter(block => block.id !== media.id));
|
||||
else { const found = currentMediaNode(); if (found) editor.commands.deleteRange({ from: found.position, to: found.position + found.node.nodeSize }); }
|
||||
setChanged(true); setMedia(null);
|
||||
}
|
||||
function closeMedia() {
|
||||
if (busy) return;
|
||||
const newImage = Boolean(media) && !media.id;
|
||||
setMedia(null);
|
||||
if (newImage) requestAnimationFrame(() => { if (mounted.current) addControl.current?.focus(); });
|
||||
}
|
||||
function openLink(value, control) {
|
||||
linkControl.current = control;
|
||||
setLinkError('');
|
||||
setLink(value);
|
||||
}
|
||||
function closeLink() {
|
||||
setLinkError('');
|
||||
setLink(null);
|
||||
requestAnimationFrame(() => {
|
||||
if (mounted.current) (linkControl.current?.isConnected ? linkControl.current : addControl.current)?.focus();
|
||||
});
|
||||
}
|
||||
const cover = blocks.find(block => block.role === 'cover');
|
||||
const textSelected = editor && !editor.state.selection.empty;
|
||||
if (supported === false && !markdownViews) return <><p role="alert">{t('unsupported')}</p><Preview article={article} /></>;
|
||||
return <article className="article-visual-editor" lang={article.locale} onKeyDown={event=>{
|
||||
if (event.key === 'Escape' && media) {event.preventDefault();event.stopPropagation();closeMedia();}
|
||||
}}>
|
||||
<TextField className="article-visual-title" label={t('title')} placeholder={t('title')} required maxLength={255} value={title} disabled={disabled || busy} onChange={event => setTitle(event.target.value)} />
|
||||
<TextArea className="article-visual-excerpt" label={t('excerpt')} placeholder={t('excerpt')} required rows={2} maxLength={1000} value={excerpt} disabled={disabled || busy} onChange={event => setExcerpt(event.target.value)} />
|
||||
{cover && <button className="article-image-button" type="button" disabled={disabled || busy || !MediaPicker} aria-label={t('editImage')} onClick={() => setMedia({ ...cover, preview: previews[cover.url] })}>
|
||||
<img src={previews[cover.url] || cover.url} alt={cover.alt || cover.description} /></button>}
|
||||
{markdownViews && <div className="article-visual-tools">
|
||||
<Button disabled={disabled || busy || Boolean(media) || link !== null || mode === 'visual'} onClick={()=>changeMode('visual')}>{t('visual')}</Button>
|
||||
<Button disabled={disabled || busy || Boolean(media) || link !== null || mode === 'markdown' || supported !== true} onClick={()=>changeMode('markdown')}>{t('markdown')}</Button>
|
||||
</div>}
|
||||
{mode === 'visual' && <><div className="article-visual-tools" aria-label={t('tools')}>
|
||||
<Button ref={addControl} size="sm" disabled={disabled || busy || !editor} onClick={() => setInsert(!insert)} aria-expanded={insert}>{t('insert')}</Button>
|
||||
<Button size="sm" variant="ghost" disabled={disabled || busy || !editor?.can().undo()} onClick={() => editor.chain().focus().undo().run()}>{t('undo')}</Button>
|
||||
<Button size="sm" variant="ghost" disabled={disabled || busy || !editor?.can().redo()} onClick={() => editor.chain().focus().redo().run()}>{t('redo')}</Button>
|
||||
{textSelected && <><Button size="sm" variant="ghost" onClick={() => editor.chain().focus().toggleBold().run()}>{t('bold')}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={event => openLink(editor.getAttributes('link').href || '', event.currentTarget)}>{t('link')}</Button></>}
|
||||
{editor?.isFocused && <><Button size="sm" variant="ghost" onClick={() => moveSelectedBlock(editor, -1)}>{t('up')}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => moveSelectedBlock(editor, 1)}>{t('down')}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => removeSelectedBlock(editor)}>{t('removeBlock')}</Button></>}
|
||||
</div>
|
||||
{insert && <div className="article-visual-tools">
|
||||
<Button size="sm" onClick={() => { editor.chain().focus().insertContent({ type: 'paragraph' }).run(); setInsert(false); }}>{t('paragraph')}</Button>
|
||||
<Button size="sm" onClick={() => { editor.chain().focus().toggleHeading({ level: 2 }).run(); setInsert(false); }}>{t('heading')}</Button>
|
||||
<Button size="sm" onClick={() => { editor.chain().focus().toggleBulletList().run(); setInsert(false); }}>{t('list')}</Button>
|
||||
<Button size="sm" onClick={event => { openLink('', event.currentTarget); setInsert(false); }}>{t('link')}</Button>
|
||||
<Button size="sm" disabled={!MediaPicker} onClick={() => { setMedia({ role: 'inline', description: '', alt: '', placement: '' }); setInsert(false); }}>{t('image')}</Button>
|
||||
<Button size="sm" onClick={() => { editor.chain().focus().insertTable({ rows: 3, cols: 2, withHeaderRow: true }).run(); setInsert(false); }}>{t('table')}</Button>
|
||||
</div>}
|
||||
{editor?.isActive('table') && <div className="article-visual-tools" aria-label={t('tableTools')}>
|
||||
<Button size="sm" onClick={() => editor.chain().focus().addRowAfter().run()}>{t('addRow')}</Button>
|
||||
<Button size="sm" onClick={() => editor.chain().focus().deleteRow().run()}>{t('removeRow')}</Button>
|
||||
<Button size="sm" onClick={() => editor.chain().focus().addColumnAfter().run()}>{t('addColumn')}</Button>
|
||||
<Button size="sm" onClick={() => editor.chain().focus().deleteColumn().run()}>{t('removeColumn')}</Button>
|
||||
<Button size="sm" onClick={() => editor.chain().focus().deleteTable().run()}>{t('removeTable')}</Button>
|
||||
</div>}
|
||||
{link !== null && <div className="article-visual-menu" onKeyDown={event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeLink(); }
|
||||
}}><TextField label={t('linkUrl')} type="url" autoFocus value={link} onChange={event => setLink(event.target.value)} />
|
||||
{linkError && <p role="alert">{linkError === 'sourceLink' ? t('sourceLink') : t('safeLink')}</p>}
|
||||
<Button onClick={() => { try { const url = new URL(link); if (url.protocol !== 'https:' || url.username || url.password) throw new Error();
|
||||
if (citationLinksOnly && !article.citations?.some(citation => citation.url === url.href)) { setLinkError('sourceLink'); return; }
|
||||
if (editor.state.selection.empty) editor.chain().focus().insertContent({ type: 'text', text: url.href, marks: [{ type: 'link', attrs: { href: url.href } }] }).run();
|
||||
else editor.chain().focus().extendMarkRange('link').setLink({ href: url.href }).run(); setLinkError(''); setLink(null);
|
||||
} catch { setLinkError('safeLink'); } }}>{t('apply')}</Button>
|
||||
<Button variant="ghost" onClick={closeLink}>{t('cancel')}</Button></div>}
|
||||
<div onKeyDown={event => {
|
||||
const id = event.target.getAttribute?.('data-image-block');
|
||||
if (MediaPicker && id && ['Enter', ' '].includes(event.key)) { event.preventDefault(); editor.state.doc.descendants((node, position) => {
|
||||
if (node.type.name === 'image' && node.attrs.blockId === id) setMedia({ id, position, role: 'inline', description: node.attrs.description,
|
||||
alt: node.attrs.alt || '', placement: node.attrs.placement || '', url: node.attrs.src, preview: node.attrs.previewSrc });
|
||||
}); }
|
||||
}}><EditorContent editor={editor} /></div></>}
|
||||
{mode === 'markdown' && <TextArea label={t('markdownSource')} rows={18} maxLength={100000}
|
||||
value={markdownSource} disabled={disabled || busy} onChange={event=>{setMarkdownSource(event.target.value);setChanged(true);}} />}
|
||||
<Dialog open={Boolean(media)} onClose={closeMedia}
|
||||
title={t('editImage')} description={t('replaceImage')}>
|
||||
{media && <div className="article-visual-menu">
|
||||
{!media.id && <Select label={t('role')} value={media.role} onChange={event => setMedia({ ...media, role: event.target.value })}
|
||||
options={[{ value: 'inline', label: t('inline') }, ...(!cover ? [{ value: 'cover', label: t('cover') }] : [])]} />}
|
||||
{media.url && <div className="article-image-current">
|
||||
<img src={media.preview || previews[media.url] || media.url} alt={media.alt || media.description} />
|
||||
<Button disabled={busy} variant="ghost" onClick={removeImage}>{t('remove')}</Button>
|
||||
</div>}
|
||||
{MediaPicker && <MediaPicker article={article} onBusy={setBusy} onSelect={applyMedia} />}
|
||||
<Button variant="ghost" disabled={busy} onClick={closeMedia}>{t('cancel')}</Button>
|
||||
</div>}
|
||||
</Dialog>
|
||||
{dirty && !draftRetained && <p role="status">{t('draftNotRetained')}</p>}
|
||||
{error && !replacementPending && <p role="alert">{error}</p>}
|
||||
<div className="article-visual-tools"><Button disabled={!dirty || disabled || busy || Boolean(media) || link !== null || (mode === 'visual' && supported !== true) || (requireComplete && (!title.trim() || !excerpt.trim()))} loading={busy} onClick={() => void save()}>{t('save')}</Button>
|
||||
<span role="status">{dirty ? t('unsaved') : t('saved')}</span></div>
|
||||
{article.citations?.length > 0 && <details className="article-visual-sources"><summary>{t('sources')} ({article.citations.length})</summary><ol>{article.citations.map(citation => <li key={citation.url}><a href={citation.url} target="_blank" rel="noopener noreferrer">{citation.title}</a></li>)}</ol></details>}
|
||||
<details><summary>{t('revision')}</summary><p>{article.revision_id}</p><p>{article.author} · {article.locale} · {article.category}</p><p>{t('free')}</p></details>
|
||||
</article>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function Button({ size: _size, variant: _variant, loading, children, ...props }) {
|
||||
return <button type="button" aria-busy={loading || undefined} {...props}>{children}</button>;
|
||||
}
|
||||
|
||||
export function TextField({ label, ...props }) {
|
||||
return <label className={props.className}><span>{label}</span><input {...props} /></label>;
|
||||
}
|
||||
|
||||
export function TextArea({ label, ...props }) {
|
||||
return <label className={props.className}><span>{label}</span><textarea {...props} /></label>;
|
||||
}
|
||||
|
||||
export function Select({ label, options, ...props }) {
|
||||
return <label><span>{label}</span><select {...props}>{options.map(option =>
|
||||
<option key={option.value} value={option.value}>{option.label}</option>)}</select></label>;
|
||||
}
|
||||
|
||||
export function Dialog({ open, onClose, title, description, children }) {
|
||||
const element = useRef(null);
|
||||
useEffect(() => {
|
||||
if (open && !element.current.open) element.current.showModal();
|
||||
else if (!open && element.current.open) element.current.close();
|
||||
}, [open]);
|
||||
return <dialog ref={element} onCancel={event => { event.preventDefault(); onClose(); }}
|
||||
aria-label={title}><h2>{title}</h2><p>{description}</p>{children}</dialog>;
|
||||
}
|
||||
|
||||
// Hosts may supply their published article renderer. Never inject raw Markdown HTML.
|
||||
export function Preview({ article }) {
|
||||
return <section><h2>{article.title}</h2><p>{article.excerpt}</p><pre>{article.markdown}</pre></section>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.article-visual-editor { max-width:860px; width:100%; margin-inline:auto; display:grid; gap:12px; }
|
||||
.article-visual-editor .article-visual-tools { margin-block:.25rem; }
|
||||
.article-visual-editor > details > summary { display:list-item; min-height:44px; padding-block:10px; cursor:pointer; }
|
||||
.article-visual-sources a { overflow-wrap:anywhere; }
|
||||
.article-visual-title > span, .article-visual-excerpt > span { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); }
|
||||
.article-visual-title :is(input, textarea) { font-size:clamp(1.5rem,4vw,2.3rem); font-weight:650; line-height:1.2; background:transparent; border-color:transparent; padding:.5rem 0; }
|
||||
.article-visual-excerpt :is(input, textarea) { font-size:1.05rem; line-height:1.6; background:transparent; border-color:transparent; padding:.5rem 0; resize:vertical; min-height:0; }
|
||||
.article-visual-title :is(input, textarea):focus, .article-visual-excerpt :is(input, textarea):focus { border-color:var(--border-default, #cbd5e1); }
|
||||
.article-visual-tools { display:flex; gap:.35rem; align-items:center; flex-wrap:wrap; margin-block:.75rem; }
|
||||
.article-visual-tools button { min-height:44px; }
|
||||
.article-visual-tools > span { color:var(--text-muted, #64748b); font-size:.8rem; }
|
||||
.article-visual-menu { display:grid; gap:.65rem; padding:1rem; border:1px solid var(--border-default, #cbd5e1); border-radius:var(--radius-sm, .5rem); margin-block:1rem; }
|
||||
.article-image-current { display:grid; gap:.5rem; }
|
||||
.article-image-current img { width:100%; max-height:240px; object-fit:contain; border-radius:var(--radius-sm, .5rem); background:var(--surface-2, #f1f5f9); }
|
||||
.article-visual-editor .tiptap { outline:none; min-height:140px; padding:.5rem 0; line-height:1.75; overflow-wrap:anywhere; }
|
||||
.article-visual-editor .tiptap > :first-child { margin-top:0; }
|
||||
.article-visual-editor .tiptap > :last-child { margin-bottom:0; }
|
||||
.article-visual-editor .tiptap:focus { box-shadow:inset 2px 0 var(--border-default, #cbd5e1); padding-left:.65rem; }
|
||||
.article-visual-editor .tiptap img { max-width:100%; height:auto; cursor:pointer; border-radius:.5rem; }
|
||||
.article-visual-editor .tiptap .ProseMirror-selectednode { outline:2px solid var(--accent, #059669); outline-offset:3px; }
|
||||
.article-visual-editor .tiptap .tableWrapper { overflow-x:auto; }
|
||||
.article-visual-editor .tiptap table { display:table; width:100%; min-width:300px; border-collapse:collapse; table-layout:fixed; }
|
||||
.article-visual-editor .tiptap th, .article-visual-editor .tiptap td { border:1px solid var(--border-default, #cbd5e1); padding:.6rem; overflow-wrap:anywhere; }
|
||||
.article-visual-editor .tiptap .selectedCell { background:var(--surface-glass, #ecfdf5); }
|
||||
.article-image-button { display:block; width:100%; padding:0; border:0; background:transparent; cursor:pointer; }
|
||||
.article-image-button img { width:100%; max-height:480px; object-fit:cover; border-radius:.75rem; }
|
||||
|
||||
.article-visual-editor input, .article-visual-editor textarea { width: 100%; box-sizing: border-box; font: inherit; color: inherit; }
|
||||
.article-visual-editor button { font: inherit; color: inherit; padding: .5rem .75rem; cursor: pointer; }
|
||||
.article-visual-editor button:disabled { cursor: default; opacity: .5; }
|
||||
.article-visual-editor label { display: grid; gap: .35rem; }
|
||||
.article-visual-editor dialog { max-width: min(600px, 90vw); color: inherit; background: Canvas; border: 1px solid var(--border-default, #cbd5e1); border-radius: .75rem; }
|
||||
.article-visual-editor dialog::backdrop { background: #0006; }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ArticleDocumentEditor } from './ArticleDocumentEditor.jsx';
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"body": "Artikeltext",
|
||||
"unsupported": "Dieser Artikel enthält Formatierungen, die der visuelle Editor nicht sicher erhalten kann. Der gespeicherte Artikel bleibt unverändert; diese Version kann hier nicht bearbeitet werden.",
|
||||
"contextHelp": "Beschreibe, was auf dem Bild zu sehen ist. Der Autor verwendet diese Beschreibung und kann das Bild nicht sehen.",
|
||||
"title": "Titel",
|
||||
"excerpt": "Kurzbeschreibung",
|
||||
"editImage": "Bild bearbeiten",
|
||||
"visual": "Visuell",
|
||||
"markdown": "Markdown-Ansicht",
|
||||
"tools": "Artikel bearbeiten",
|
||||
"insert": "+ Einfügen",
|
||||
"undo": "Rückgängig",
|
||||
"redo": "Wiederholen",
|
||||
"bold": "Fett",
|
||||
"link": "Verknüpfung",
|
||||
"up": "Nach oben",
|
||||
"down": "Nach unten",
|
||||
"removeBlock": "Block entfernen",
|
||||
"paragraph": "Absatz",
|
||||
"heading": "Überschrift",
|
||||
"list": "Liste",
|
||||
"image": "Bild",
|
||||
"table": "Tabelle",
|
||||
"tableTools": "Tabellenoptionen",
|
||||
"addRow": "Zeile hinzufügen",
|
||||
"removeRow": "Zeile entfernen",
|
||||
"addColumn": "Spalte hinzufügen",
|
||||
"removeColumn": "Spalte entfernen",
|
||||
"removeTable": "Tabelle entfernen",
|
||||
"linkUrl": "HTTPS-Verknüpfung",
|
||||
"sourceLink": "Nutze einen Link aus den Quellen dieses Artikels.",
|
||||
"safeLink": "Nutze eine öffentliche HTTPS-Adresse ohne Zugangsdaten.",
|
||||
"apply": "Übernehmen",
|
||||
"cancel": "Abbrechen",
|
||||
"markdownSource": "Markdown-Quelltext",
|
||||
"replaceImage": "Anderes Bild hochladen oder auswählen",
|
||||
"role": "Verwenden als",
|
||||
"inline": "Im Artikel",
|
||||
"cover": "Titelbild",
|
||||
"remove": "Entfernen",
|
||||
"draftNotRetained": "Lass diesen Tab bis zum Speichern offen. Dieser Browser kann deinen Entwurf nicht behalten.",
|
||||
"save": "Änderungen speichern",
|
||||
"unsaved": "Ungespeicherte Änderungen",
|
||||
"saved": "Gespeicherte Version",
|
||||
"sources": "Quellen",
|
||||
"revision": "Exakte Version",
|
||||
"free": "Textbearbeitung und eigene Bilder lösen keine Artikelgenerierung oder KI-Kosten aus. Deine Freigabe veröffentlicht genau diese gespeicherte Version."
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"body": "Article body",
|
||||
"unsupported": "This article contains formatting the visual editor cannot safely preserve. The saved article is unchanged; editing is unavailable for this revision.",
|
||||
"contextHelp": "Describe what the image shows. The writer uses this description and cannot see the image.",
|
||||
"title": "Article title",
|
||||
"excerpt": "Excerpt",
|
||||
"editImage": "Edit image",
|
||||
"visual": "Visual",
|
||||
"markdown": "Markdown",
|
||||
"tools": "Article editing",
|
||||
"insert": "+ Add",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"bold": "Bold",
|
||||
"link": "Link",
|
||||
"up": "Move up",
|
||||
"down": "Move down",
|
||||
"removeBlock": "Remove block",
|
||||
"paragraph": "Text",
|
||||
"heading": "Heading",
|
||||
"list": "List",
|
||||
"image": "Image",
|
||||
"table": "Table",
|
||||
"tableTools": "Table options",
|
||||
"addRow": "Add row",
|
||||
"removeRow": "Remove row",
|
||||
"addColumn": "Add column",
|
||||
"removeColumn": "Remove column",
|
||||
"removeTable": "Remove table",
|
||||
"linkUrl": "HTTPS link",
|
||||
"sourceLink": "Use a link from this article’s Sources.",
|
||||
"safeLink": "Use a public HTTPS link without credentials.",
|
||||
"apply": "Apply",
|
||||
"cancel": "Cancel",
|
||||
"markdownSource": "Markdown source",
|
||||
"replaceImage": "Upload or choose another image",
|
||||
"role": "Use as",
|
||||
"inline": "Inside the article",
|
||||
"cover": "Cover",
|
||||
"remove": "Remove",
|
||||
"draftNotRetained": "Keep this tab open until you save. This browser cannot retain your draft.",
|
||||
"save": "Save changes",
|
||||
"unsaved": "Unsaved changes",
|
||||
"saved": "Saved revision",
|
||||
"sources": "Sources",
|
||||
"revision": "Exact revision",
|
||||
"free": "Editing text and selecting your own images does not generate an article or charge AI credits. Approval publishes this exact saved revision."
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { Markdown } from '@tiptap/markdown';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import { TableKit } from '@tiptap/extension-table';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
|
||||
// The stock Markdown paragraph parser unwraps standalone images as block
|
||||
// nodes. Our article contract also permits images within prose, so retain the
|
||||
// paragraph around a standalone inline image to keep the document schema valid.
|
||||
const ArticleParagraph = Paragraph.extend({
|
||||
// Blank visual blocks are explicit paragraphs. Preserve their marker even
|
||||
// when reopening leaves only one; the stock serializer drops that marker.
|
||||
renderMarkdown(node, helpers, context) {
|
||||
if (node && !node.content?.length) return ' ';
|
||||
return this.parent?.(node, helpers, context);
|
||||
},
|
||||
parseMarkdown(token, helpers) {
|
||||
if (token.tokens?.length === 1 && token.tokens[0].type === 'image') {
|
||||
return helpers.createNode('paragraph', undefined, helpers.parseInline(token.tokens));
|
||||
}
|
||||
return this.parent?.(token, helpers);
|
||||
},
|
||||
});
|
||||
|
||||
const ArticleImage = Image.extend({
|
||||
addAttributes() {
|
||||
return { ...this.parent?.(), blockId: { default: null, rendered: false }, previewSrc: { default: null, rendered: false },
|
||||
description: { default: '', rendered: false }, placement: { default: '', rendered: false }, allowReuse: { default: false, rendered: false } };
|
||||
},
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return ['img', { ...HTMLAttributes, src: node.attrs.previewSrc || node.attrs.src,
|
||||
'data-image-block': node.attrs.blockId, tabindex: '0', role: 'button', 'aria-label': node.attrs.alt || '' }];
|
||||
},
|
||||
}).configure({ inline: true, allowBase64: false });
|
||||
|
||||
export function articleExtensions() {
|
||||
return [StarterKit.configure({ paragraph: false, link: { openOnClick: false, autolink: false, protocols: ['https'] }, underline: false, strike: false, trailingNode: false }),
|
||||
ArticleParagraph, ArticleImage, TableKit, Markdown];
|
||||
}
|
||||
|
||||
const parser = new MarkdownIt('commonmark', { html: false }).enable('table');
|
||||
function meaning(markdown) {
|
||||
const tokens = (items) => items.map(token => ({ type: token.type, tag: token.tag,
|
||||
attrs: token.attrs?.filter(([key]) => key !== 'class') || null,
|
||||
content: token.children ? null : token.content,
|
||||
info: token.info || '', children: token.children ? tokens(token.children) : null }));
|
||||
return JSON.stringify(tokens(parser.parse(markdown, {})));
|
||||
}
|
||||
export function canRoundTrip(source, serialized) {
|
||||
return meaning(source) === meaning(serialized);
|
||||
}
|
||||
|
||||
export function seedImageBlocks(editor, blocks, previews = {}) {
|
||||
const remaining = blocks.filter(block => block.role === 'inline');
|
||||
const transaction = editor.state.tr;
|
||||
editor.state.doc.descendants((node, position) => {
|
||||
if (node.type.name !== 'image') return;
|
||||
const index = remaining.findIndex(block => block.url === node.attrs.src);
|
||||
if (index < 0) return;
|
||||
const block = remaining.splice(index, 1)[0];
|
||||
if (block) transaction.setNodeMarkup(position, undefined, { ...node.attrs,
|
||||
blockId: block.id, previewSrc: previews[block.url] || null, description: block.description,
|
||||
placement: block.placement, allowReuse: block.allow_reuse || false });
|
||||
});
|
||||
if (transaction.docChanged) editor.view.dispatch(transaction.setMeta('addToHistory', false));
|
||||
}
|
||||
|
||||
export function serializeArticle(editor, blocks) {
|
||||
const images = blocks.filter(block => block.role === 'cover');
|
||||
editor.state.doc.descendants(node => {
|
||||
if (node.type.name !== 'image') return;
|
||||
if (!node.attrs.blockId || !node.attrs.description) throw new Error('Choose every image through the image picker before saving.');
|
||||
images.push({ id: node.attrs.blockId, url: node.attrs.src, role: 'inline', description: node.attrs.description,
|
||||
placement: node.attrs.placement || '', alt: node.attrs.alt || '', ...(node.attrs.allowReuse ? { allow_reuse: true } : {}) });
|
||||
});
|
||||
return { markdown: editor.getMarkdown(), image_blocks: images };
|
||||
}
|
||||
|
||||
export function serializeMarkdownArticle(markdown, blocks) {
|
||||
const remaining = blocks.filter(block => block.role === 'inline');
|
||||
const images = blocks.filter(block => block.role === 'cover');
|
||||
for (const token of parser.parse(markdown, {})) {
|
||||
for (const child of token.children || []) {
|
||||
if (child.type !== 'image') continue;
|
||||
const index = remaining.findIndex(block => block.url === child.attrGet('src'));
|
||||
if (index < 0) throw new Error('Choose every image through the image picker before saving.');
|
||||
images.push({...remaining.splice(index, 1)[0], alt:child.content});
|
||||
}
|
||||
}
|
||||
return {markdown, image_blocks:images};
|
||||
}
|
||||
|
||||
export function moveSelectedBlock(editor, direction) {
|
||||
const { state } = editor;
|
||||
const { $from } = state.selection;
|
||||
if ($from.depth < 1) return false;
|
||||
const index = $from.index(0), siblings = [];
|
||||
state.doc.forEach(node => siblings.push(node));
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= siblings.length) return false;
|
||||
[siblings[index], siblings[target]] = [siblings[target], siblings[index]];
|
||||
const selection = siblings.slice(0, target).reduce((size, node) => size + node.nodeSize, 0)
|
||||
+ $from.pos - $from.before(1);
|
||||
return editor.chain().command(({ tr }) => {
|
||||
tr.replaceWith(0, tr.doc.content.size, siblings);
|
||||
return true;
|
||||
}).setTextSelection(selection).run();
|
||||
}
|
||||
|
||||
export function removeSelectedBlock(editor) {
|
||||
const { $from } = editor.state.selection;
|
||||
if ($from.depth < 1) return false;
|
||||
return editor.commands.deleteRange({ from: $from.before(1), to: $from.after(1) });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ArticleDocumentEditor } from './ArticleDocumentEditor.jsx';
|
||||
|
||||
/** Mount the same editor in a non-React host; callbacks remain host-owned. */
|
||||
export function mountArticleEditor(element, initialProps) {
|
||||
const root = createRoot(element);
|
||||
root.render(<ArticleDocumentEditor {...initialProps} />);
|
||||
return {
|
||||
update(props) { root.render(<ArticleDocumentEditor {...props} />); },
|
||||
destroy() { root.unmount(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
async function settle() { await new Promise(resolve => setTimeout(resolve, 30)); }
|
||||
|
||||
test('the standalone host edits title and Markdown, saves once and destroys cleanly', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="editor"></div></body></html>', {
|
||||
url: 'https://fixture.example', pretendToBeVisual: true,
|
||||
});
|
||||
for (const name of ['window', 'document', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Node', 'MutationObserver', 'sessionStorage']) {
|
||||
Object.defineProperty(globalThis, name, { value: dom.window[name], configurable: true });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true });
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
||||
const { mountArticleEditor } = await import('../dist/article-editor.js');
|
||||
const saved = [];
|
||||
let rejectSave = true;
|
||||
const host = mountArticleEditor(document.getElementById('editor'), {
|
||||
documentKey: 'owner/site/revision', saveToken: 'digest', storageKey: 'fixture/owner/site/revision/digest',
|
||||
article: { title: 'Original', excerpt: '', markdown: 'First note', editable_markdown: 'First note',
|
||||
locale: 'en', revision_id: 'revision', image_blocks: [], citations: [] },
|
||||
requireComplete: false, onSave: async content => {
|
||||
if (rejectSave) throw new Error('Connection unavailable');
|
||||
saved.push(content); return { revision_id: 'confirmed' };
|
||||
},
|
||||
});
|
||||
try {
|
||||
await settle(); await settle();
|
||||
const title = document.querySelector('input');
|
||||
assert.equal(title.value, 'Original');
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value').set.call(title, 'Human insight');
|
||||
title.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
|
||||
await settle();
|
||||
const button = label => [...document.querySelectorAll('button')].find(item => item.textContent === label);
|
||||
button('Markdown').click();
|
||||
await settle();
|
||||
const source = [...document.querySelectorAll('textarea')].find(item => item.value === 'First note');
|
||||
assert.ok(source, 'The shared source mode must be editable');
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, 'value').set.call(source, '## Changed note');
|
||||
source.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
|
||||
await settle();
|
||||
button('Save changes').click();
|
||||
await settle();
|
||||
assert.equal(saved.length, 0);
|
||||
assert.equal(document.querySelector('[role="alert"]').textContent, 'Connection unavailable');
|
||||
assert.equal(JSON.parse(sessionStorage.getItem('fixture/owner/site/revision/digest')).markdown, '## Changed note');
|
||||
rejectSave = false;
|
||||
button('Save changes').click();
|
||||
await settle();
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].title, 'Human insight');
|
||||
assert.equal(saved[0].markdown, '## Changed note');
|
||||
assert.equal(sessionStorage.getItem('fixture/owner/site/revision/digest'), null);
|
||||
} finally {
|
||||
host.destroy();
|
||||
assert.equal(document.getElementById('editor').children.length, 0);
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { articleExtensions, canRoundTrip, seedImageBlocks, serializeArticle, serializeMarkdownArticle } from '../src/article/model.js';
|
||||
|
||||
test('Markdown stays verbatim and cannot invent owned image associations', () => {
|
||||
const markdown = '## Notes\n\n';
|
||||
const block = { id: 'owned', url: 'https://example.org/owned.png', role: 'inline', description: 'Owned image', alt: 'Old alt' };
|
||||
assert.deepEqual(serializeMarkdownArticle(markdown, [block]), { markdown, image_blocks: [{ ...block, alt: 'New alt' }] });
|
||||
assert.throws(() => serializeMarkdownArticle('', [block]), /Choose every image/);
|
||||
assert.deepEqual(serializeMarkdownArticle('Text only', [block]).image_blocks, []);
|
||||
});
|
||||
|
||||
test('visual edits round-trip supported Markdown and retain exact image identity', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { pretendToBeVisual: true });
|
||||
for (const name of ['window', 'document', 'HTMLElement', 'Node', 'MutationObserver']) {
|
||||
Object.defineProperty(globalThis, name, { value: dom.window[name], configurable: true });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true });
|
||||
const { Editor } = await import('@tiptap/core');
|
||||
const source = '## Training\n\nA **bold** note.\n\n| Step | Time |\n| --- | --- |\n| Warmup | 5 minutes |\n\n';
|
||||
const editor = new Editor({ extensions: articleExtensions(), content: source, contentType: 'markdown' });
|
||||
try {
|
||||
assert.equal(canRoundTrip(source, editor.getMarkdown()), true);
|
||||
const block = { id: 'owned', url: 'https://example.org/owned.png', role: 'inline', description: 'Team training', alt: 'Team', placement: '' };
|
||||
seedImageBlocks(editor, [block]);
|
||||
assert.deepEqual(serializeArticle(editor, [block]).image_blocks, [block]);
|
||||
editor.commands.setTextSelection(3);
|
||||
editor.commands.insertContent('Practical ');
|
||||
assert.match(editor.getMarkdown(), /Practical/);
|
||||
editor.commands.undo();
|
||||
assert.doesNotMatch(editor.getMarkdown(), /Practical/);
|
||||
} finally {
|
||||
editor.destroy();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Illuminate\Support { class ServiceProvider {} }
|
||||
namespace Illuminate\Support\Facades {
|
||||
final class Route {
|
||||
public static array $routes = [];
|
||||
public static function get(string $path, callable $handler): object {
|
||||
return new class($path, $handler) {
|
||||
public function __construct(private string $path, private $handler) {}
|
||||
public function name(string $name): void { Route::$routes[$name] = [$this->path, $this->handler]; }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace {
|
||||
function abort_unless(bool $allowed, int $status): void { if (!$allowed) throw new RuntimeException((string) $status); }
|
||||
function response(): object { return new class { public function file(string $path, array $headers): array { return [$path, $headers]; } }; }
|
||||
function route(string $name, array $parameters): string { return str_replace(array_map(fn ($key) => '{'.$key.'}', array_keys($parameters)), array_values($parameters), \Illuminate\Support\Facades\Route::$routes[$name][0]); }
|
||||
require __DIR__.'/../src/WysiwygServiceProvider.php';
|
||||
$provider = new \Tropikal\FilamentWysiwyg\WysiwygServiceProvider;
|
||||
$provider->boot();
|
||||
foreach (['articleAssetUrl' => 'application/javascript; charset=UTF-8', 'articleStylesheetUrl' => 'text/css; charset=UTF-8'] as $method => $mime) {
|
||||
$url = $provider::$method();
|
||||
preg_match('/\/([a-f0-9]{64})\//', $url, $match);
|
||||
if (!$match) throw new RuntimeException('Missing immutable asset identity');
|
||||
$name = $method === 'articleAssetUrl' ? 'tropikal-wysiwyg.article' : 'tropikal-wysiwyg.article-css';
|
||||
$handler = \Illuminate\Support\Facades\Route::$routes[$name][1];
|
||||
[$file, $headers] = $handler($match[1]);
|
||||
if (!is_file($file) || $headers['Content-Type'] !== $mime || $headers['Cache-Control'] !== 'public, max-age=31536000, immutable') throw new RuntimeException('Invalid immutable asset response');
|
||||
try { $handler(str_repeat('0', 64)); throw new RuntimeException('Invalid identity served'); }
|
||||
catch (RuntimeException $error) { if ($error->getMessage() !== '404') throw $error; }
|
||||
}
|
||||
echo "Immutable article asset guards passed (stubbed Laravel facade)\n";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { publishRelease } from '../scripts/publish-release.mjs';
|
||||
|
||||
test('publication refuses a release belonging to another source revision', async () => {
|
||||
const calls = [];
|
||||
await assert.rejects(publishRelease({
|
||||
server: 'https://git.tropikal.dev', repository: 'fixture/package', token: 'fixture-token',
|
||||
version: '0.2.0', commit: 'a'.repeat(40), assets: [],
|
||||
request: async (url, options) => {
|
||||
calls.push(options?.method || 'GET');
|
||||
return Response.json({ id: 1, target_commitish: 'b'.repeat(40), assets: [] });
|
||||
},
|
||||
}), /revision/);
|
||||
assert.deepEqual(calls, ['GET']);
|
||||
});
|
||||
|
||||
test('publication creates an exact-SHA release and sends only the supplied artifact', async () => {
|
||||
const calls = [];
|
||||
await publishRelease({
|
||||
server: 'https://git.tropikal.dev', repository: 'fixture/package', token: 'fixture-token',
|
||||
version: '0.2.0', commit: 'a'.repeat(40), assets: [{ name: 'package.zip', bytes: Buffer.from('verified') }],
|
||||
request: async (url, options = {}) => {
|
||||
calls.push({ url, options });
|
||||
if (calls.length === 1) return new Response(null, { status: 404 });
|
||||
if (calls.length === 2) return Response.json({ id: 1, target_commitish: 'a'.repeat(40), assets: [] });
|
||||
return Response.json({ name: 'package.zip' });
|
||||
},
|
||||
});
|
||||
assert.equal(JSON.parse(calls[1].options.body).target_commitish, 'a'.repeat(40));
|
||||
assert.equal(calls[2].options.method, 'POST');
|
||||
assert.equal(calls[2].options.body.get('attachment').size, 8);
|
||||
});
|
||||
|
||||
test('a same-revision asset cannot be silently overwritten with different bytes', async () => {
|
||||
const calls = [];
|
||||
await assert.rejects(publishRelease({
|
||||
server: 'https://git.tropikal.dev', repository: 'fixture/package', token: 'fixture-token',
|
||||
version: '0.2.0', commit: 'a'.repeat(40), assets: [{ name: 'package.zip', bytes: Buffer.from('new') }],
|
||||
request: async (url, options = {}) => {
|
||||
calls.push(options.method || 'GET');
|
||||
if (calls.length === 1) return Response.json({ id: 1, target_commitish: 'a'.repeat(40),
|
||||
assets: [{ name: 'package.zip', browser_download_url: 'https://git.tropikal.dev/fixture/package/package.zip' }] });
|
||||
return new Response('old');
|
||||
},
|
||||
}), /refusing overwrite/);
|
||||
assert.deepEqual(calls, ['GET', 'GET']);
|
||||
});
|
||||
Reference in New Issue
Block a user