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

This commit was merged in pull request #4.
This commit is contained in:
botbob
2026-10-12 12:33:26 +07:00
parent 1b03672340
commit 325fd6e1ff
22 changed files with 2706 additions and 18 deletions
+18
View File
@@ -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 }}
+2
View File
@@ -1,2 +1,4 @@
node_modules/
*.tgz
dist/
release/
+33 -2
View File
@@ -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.
+1511 -3
View File
File diff suppressed because it is too large Load Diff
+52 -5
View File
@@ -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"
}
}
+8
View File
@@ -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,
});
+45
View File
@@ -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}`);
+211
View File
@@ -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();
+64
View File
@@ -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}`);
}
+26 -8
View File
@@ -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);
}
}
+264
View 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>;
}
+33
View File
@@ -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>;
}
+34
View File
@@ -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; }
+1
View File
@@ -0,0 +1 @@
export { ArticleDocumentEditor } from './ArticleDocumentEditor.jsx';
+48
View File
@@ -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."
}
+48
View File
@@ -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."
}
+115
View File
@@ -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 '&nbsp;';
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) });
}
+12
View File
@@ -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(); },
};
}
+62
View File
@@ -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();
}
});
+37
View File
@@ -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![New alt](https://example.org/owned.png)';
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('![Unknown](https://example.org/unknown.png)', [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![Team](https://example.org/owned.png)';
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();
}
});
+34
View File
@@ -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";
}
+48
View File
@@ -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']);
});