feat(task-013): add draft assembly API, immutable versioning, and editor preview

This commit is contained in:
2026-05-21 23:33:15 +03:00
parent 8ebb5ad623
commit c3914bf8be
19 changed files with 2416 additions and 16 deletions
+38
View File
@@ -344,6 +344,44 @@ main {
padding: 10px 0;
}
.draftEditor {
margin-top: 28px;
}
.draftEditor .detailGrid select {
width: 100%;
border-radius: 6px;
border: 1px solid #d0d5dd;
padding: 10px;
font: inherit;
}
.previewPane {
border: 1px solid #d0d5dd;
border-radius: 8px;
background: #ffffff;
padding: 14px;
}
.previewImage {
margin: 0;
border: 1px dashed #d0d5dd;
border-radius: 8px;
padding: 10px;
}
.previewImage img {
display: block;
max-width: 100%;
max-height: 180px;
}
.previewImage figcaption {
margin-top: 6px;
color: #667085;
font-size: 12px;
}
@media (max-width: 640px) {
main {
padding: 28px;
@@ -0,0 +1,170 @@
import type { DraftFaqItem, DraftSummary } from "@pipeline/shared";
export type DraftPreviewBlock =
| { type: "heading"; level: number; text: string }
| { type: "paragraph"; text: string; links: DraftPreviewLink[] }
| { type: "table"; headers: string[]; rows: string[][] }
| { type: "image"; alt: string; src: string }
| { type: "faq"; items: DraftFaqItem[] };
export type DraftPreviewLink = {
label: string;
href: string;
};
export type DraftVersionComparison = {
leftVersion: number;
rightVersion: number;
titleChanged: boolean;
metaTitleChanged: boolean;
metaDescriptionChanged: boolean;
bodyChanged: boolean;
changedLineCount: number;
};
export function selectDraftById(
drafts: readonly DraftSummary[],
draftId: string | null,
): DraftSummary | null {
if (!draftId) {
return drafts[0] ?? null;
}
return drafts.find((draft) => draft.id === draftId) ?? null;
}
export function compareDraftVersions(
left: DraftSummary,
right: DraftSummary,
): DraftVersionComparison {
const leftBodyMarkdown = left.body_markdown ?? "";
const rightBodyMarkdown = right.body_markdown ?? "";
const leftLines = normalizedLines(leftBodyMarkdown);
const rightLines = normalizedLines(rightBodyMarkdown);
return {
leftVersion: left.version,
rightVersion: right.version,
titleChanged: left.title !== right.title,
metaTitleChanged: (left.meta_title ?? "") !== (right.meta_title ?? ""),
metaDescriptionChanged:
(left.meta_description ?? "") !== (right.meta_description ?? ""),
bodyChanged: leftBodyMarkdown !== rightBodyMarkdown,
changedLineCount: changedLineCount(leftLines, rightLines),
};
}
export function parseDraftPreview(
markdown: string,
faqItems: readonly DraftFaqItem[],
): DraftPreviewBlock[] {
const lines = markdown.split(/\r?\n/);
const blocks: DraftPreviewBlock[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index].trim();
if (!line) {
index += 1;
continue;
}
if (line.startsWith("#")) {
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match) {
blocks.push({
type: "heading",
level: match[1].length,
text: match[2].trim(),
});
index += 1;
continue;
}
}
if (line.startsWith("![") && line.includes("](") && line.endsWith(")")) {
const match = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (match) {
blocks.push({
type: "image",
alt: match[1],
src: match[2],
});
index += 1;
continue;
}
}
if (line.startsWith("|") && index + 1 < lines.length && lines[index + 1].includes("|")) {
const tableLines: string[] = [];
while (index < lines.length && lines[index].trim().startsWith("|")) {
tableLines.push(lines[index].trim());
index += 1;
}
if (tableLines.length >= 2) {
const headers = parseTableRow(tableLines[0]);
const rows = tableLines.slice(2).map(parseTableRow);
blocks.push({
type: "table",
headers,
rows,
});
}
continue;
}
const paragraphLines: string[] = [];
while (index < lines.length && lines[index].trim()) {
paragraphLines.push(lines[index].trim());
index += 1;
}
const paragraphText = paragraphLines.join(" ");
blocks.push({
type: "paragraph",
text: paragraphText,
links: extractLinks(paragraphText),
});
}
if (faqItems.length > 0) {
blocks.push({
type: "faq",
items: [...faqItems],
});
}
return blocks;
}
function normalizedLines(value: string): string[] {
return value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function changedLineCount(left: string[], right: string[]): number {
const limit = Math.max(left.length, right.length);
let changes = 0;
for (let index = 0; index < limit; index += 1) {
if ((left[index] ?? "") !== (right[index] ?? "")) {
changes += 1;
}
}
return changes;
}
function parseTableRow(row: string): string[] {
return row
.slice(1, row.endsWith("|") ? -1 : row.length)
.split("|")
.map((cell) => cell.trim());
}
function extractLinks(text: string): DraftPreviewLink[] {
const links: DraftPreviewLink[] = [];
const pattern = /\[([^\]]+)\]\(([^)]+)\)/g;
let match: RegExpExecArray | null = pattern.exec(text);
while (match) {
links.push({ label: match[1], href: match[2] });
match = pattern.exec(text);
}
return links;
}
@@ -0,0 +1,299 @@
"use client";
import { useMemo, useState } from "react";
import type { DraftSummary } from "@pipeline/shared";
import { ApiError, assembleDraft, updateDraft } from "@/shared/pipeline-api";
import {
compareDraftVersions,
parseDraftPreview,
selectDraftById,
} from "./model";
type DraftEditorPanelProps = {
articleId: string;
initialDrafts: readonly DraftSummary[];
};
type DraftEditorState = {
title: string;
metaTitle: string;
metaDescription: string;
bodyMarkdown: string;
};
export function DraftEditorPanel({ articleId, initialDrafts }: DraftEditorPanelProps) {
const [drafts, setDrafts] = useState<DraftSummary[]>(() => [...initialDrafts]);
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(
initialDrafts[0]?.id ?? null,
);
const [compareDraftId, setCompareDraftId] = useState<string | null>(null);
const [message, setMessage] = useState("");
const [isBusy, setIsBusy] = useState(false);
const [editor, setEditor] = useState<DraftEditorState>(() =>
createEditorState(initialDrafts[0] ?? null),
);
const selectedDraft = useMemo(
() => selectDraftById(drafts, selectedDraftId),
[drafts, selectedDraftId],
);
const compareDraft = useMemo(
() => selectDraftById(drafts, compareDraftId),
[drafts, compareDraftId],
);
const previewBlocks = useMemo(
() => parseDraftPreview(editor.bodyMarkdown, selectedDraft?.faq_items ?? []),
[editor.bodyMarkdown, selectedDraft],
);
const comparison = useMemo(() => {
if (!selectedDraft || !compareDraft || selectedDraft.id === compareDraft.id) {
return null;
}
return compareDraftVersions(selectedDraft, compareDraft);
}, [selectedDraft, compareDraft]);
function onSelectDraft(draftId: string) {
setSelectedDraftId(draftId);
setCompareDraftId((current) => (current === draftId ? null : current));
const nextDraft = selectDraftById(drafts, draftId);
setEditor(createEditorState(nextDraft));
}
async function onAssembleDraft() {
setIsBusy(true);
setMessage("");
try {
const response = await assembleDraft(articleId);
setDrafts((current) => [response.draft, ...current]);
setSelectedDraftId(response.draft.id);
setEditor(createEditorState(response.draft));
setMessage(`Draft v${response.draft.version} assembled.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Failed to assemble draft.");
} finally {
setIsBusy(false);
}
}
async function onSaveDraft() {
if (!selectedDraft) {
setMessage("Assemble draft before editing.");
return;
}
setIsBusy(true);
setMessage("");
try {
const response = await updateDraft(articleId, selectedDraft.id, {
title: editor.title,
meta_title: editor.metaTitle,
meta_description: editor.metaDescription,
body_markdown: editor.bodyMarkdown,
});
setDrafts((current) => [response.draft, ...current]);
setSelectedDraftId(response.draft.id);
setEditor(createEditorState(response.draft));
setMessage(`Saved draft v${response.draft.version}.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Failed to save draft.");
} finally {
setIsBusy(false);
}
}
return (
<section className="draftEditor">
<div className="sectionHeader">
<div>
<h2>Draft editor</h2>
<p>{drafts.length ? `${drafts.length} version(s)` : "No drafts yet"}</p>
</div>
<button type="button" onClick={onAssembleDraft} disabled={isBusy}>
{isBusy ? "Working..." : "Assemble draft"}
</button>
</div>
<div className="detailGrid">
<div>
<dt>Selected draft</dt>
<dd>
<select
value={selectedDraftId ?? ""}
onChange={(event) => onSelectDraft(event.target.value)}
disabled={!drafts.length || isBusy}
>
{drafts.length === 0 ? <option value="">No drafts</option> : null}
{drafts.map((draft) => (
<option key={draft.id} value={draft.id}>
v{draft.version} • {draft.title}
</option>
))}
</select>
</dd>
</div>
<div>
<dt>Compare with</dt>
<dd>
<select
value={compareDraftId ?? ""}
onChange={(event) => setCompareDraftId(event.target.value || null)}
disabled={!drafts.length || isBusy}
>
<option value="">No comparison</option>
{drafts
.filter((draft) => draft.id !== selectedDraftId)
.map((draft) => (
<option key={draft.id} value={draft.id}>
v{draft.version}
</option>
))}
</select>
</dd>
</div>
</div>
{comparison ? (
<p className="formSuccess">
Compare v{comparison.leftVersion} vs v{comparison.rightVersion}: changed lines{" "}
{comparison.changedLineCount}, title {comparison.titleChanged ? "changed" : "same"},
meta title {comparison.metaTitleChanged ? "changed" : "same"}, description{" "}
{comparison.metaDescriptionChanged ? "changed" : "same"}.
</p>
) : null}
{message ? <p className={message.startsWith("Failed") ? "formError" : "formSuccess"}>{message}</p> : null}
<form
className="articleForm"
onSubmit={(event) => {
event.preventDefault();
void onSaveDraft();
}}
>
<label htmlFor="draft-title">Title</label>
<input
id="draft-title"
value={editor.title}
onChange={(event) =>
setEditor((current) => ({ ...current, title: event.target.value }))
}
/>
<label htmlFor="draft-meta-title">Meta title</label>
<input
id="draft-meta-title"
value={editor.metaTitle}
onChange={(event) =>
setEditor((current) => ({ ...current, metaTitle: event.target.value }))
}
/>
<label htmlFor="draft-meta-description">Meta description</label>
<input
id="draft-meta-description"
value={editor.metaDescription}
onChange={(event) =>
setEditor((current) => ({ ...current, metaDescription: event.target.value }))
}
/>
<label htmlFor="draft-body">Body markdown</label>
<textarea
id="draft-body"
className="codeArea"
value={editor.bodyMarkdown}
onChange={(event) =>
setEditor((current) => ({ ...current, bodyMarkdown: event.target.value }))
}
/>
<div className="formActions">
<button type="submit" disabled={isBusy || !selectedDraft}>
Save as next version
</button>
</div>
</form>
<h3>Preview</h3>
<div className="previewPane">
{previewBlocks.map((block, index) => {
if (block.type === "heading") {
if (block.level <= 2) {
return <h4 key={`heading-${index}`}>{block.text}</h4>;
}
return <h5 key={`heading-${index}`}>{block.text}</h5>;
}
if (block.type === "paragraph") {
return (
<p key={`paragraph-${index}`}>
{block.text}
{block.links.map((link) => (
<span key={`${link.href}-${link.label}`}> • <a href={link.href}>{link.label}</a></span>
))}
</p>
);
}
if (block.type === "table") {
return (
<table className="table" key={`table-${index}`}>
<thead>
<tr>
{block.headers.map((header, headerIndex) => (
<th key={`${header}-${headerIndex}`}>{header}</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`cell-${rowIndex}-${cellIndex}`}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
if (block.type === "image") {
return (
<figure key={`image-${index}`} className="previewImage">
<img src={block.src} alt={block.alt} />
<figcaption>{block.alt || block.src}</figcaption>
</figure>
);
}
return (
<section key={`faq-${index}`}>
<h4>FAQ</h4>
<ul className="timeline">
{block.items.map((item) => (
<li key={item.question}>
<strong>{item.question}</strong>
<div>{item.answer}</div>
</li>
))}
</ul>
</section>
);
})}
{previewBlocks.length === 0 ? <p>No preview content.</p> : null}
</div>
</section>
);
}
function createEditorState(draft: DraftSummary | null): DraftEditorState {
if (!draft) {
return {
title: "",
metaTitle: "",
metaDescription: "",
bodyMarkdown: "",
};
}
return {
title: draft.title,
metaTitle: draft.meta_title ?? "",
metaDescription: draft.meta_description ?? "",
bodyMarkdown: draft.body_markdown ?? "",
};
}
@@ -5,7 +5,8 @@ import {
buildDetailSummary,
} from "@/features/article-detail/model";
import { ArticleDetailShell } from "@/features/article-detail/ui";
import { fetchArticleDetail } from "@/shared/pipeline-api";
import { DraftEditorPanel } from "@/features/article-draft/ui";
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
import { ApiError } from "@/shared/pipeline-api";
type ArticleDetailPageProps = {
@@ -16,7 +17,10 @@ export default async function ArticleDetailPage({
articleId,
}: ArticleDetailPageProps) {
try {
const detail = await fetchArticleDetail(articleId);
const [detail, draftsResponse] = await Promise.all([
fetchArticleDetail(articleId),
fetchDrafts(articleId),
]);
const summary = buildDetailSummary(detail);
return (
@@ -31,6 +35,7 @@ export default async function ArticleDetailPage({
<RoleNavigation role="EDITOR" />
<section className="panel">
<ArticleDetailShell summary={summary} />
<DraftEditorPanel articleId={articleId} initialDrafts={draftsResponse.drafts ?? []} />
</section>
</main>
);
+33
View File
@@ -10,6 +10,10 @@ import type {
BoundaryQuestionResponse,
BoundaryQuestionUpdateRequest,
CurrentUserResponse,
DraftAssembleResponse,
DraftListResponse,
DraftResponse,
DraftUpdateRequest,
EvidenceMatrixResponse,
EvidenceResponse,
EvidenceCreateRequest,
@@ -426,3 +430,32 @@ export function removeEvidence(
DEMO_EDITOR_EMAIL,
);
}
export function assembleDraft(articleId: string): Promise<DraftAssembleResponse> {
return apiPost<unknown, DraftAssembleResponse>(
`/api/articles/${articleId}/draft/assemble`,
undefined,
);
}
export function fetchDrafts(articleId: string): Promise<DraftListResponse> {
return apiGet<DraftListResponse>(`/api/articles/${articleId}/drafts`);
}
export function fetchDraft(
articleId: string,
draftId: string,
): Promise<DraftResponse> {
return apiGet<DraftResponse>(`/api/articles/${articleId}/drafts/${draftId}`);
}
export function updateDraft(
articleId: string,
draftId: string,
request: DraftUpdateRequest,
): Promise<DraftResponse> {
return apiPatch<DraftUpdateRequest, DraftResponse>(
`/api/articles/${articleId}/drafts/${draftId}`,
request,
);
}
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(testDir, "../src/features/article-draft/model.ts");
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
});
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const { compareDraftVersions, parseDraftPreview, selectDraftById } = moduleExports;
const baseDraft = {
id: "11111111-1111-1111-1111-111111111111",
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
version: 1,
title: "Draft One",
slug: "draft-one",
meta_title: "Meta One",
meta_description: "Description One",
body_object_key: "articles/a/drafts/v1.md",
body_markdown: [
"# Draft One",
"",
"## Intro",
"See [guide](https://example.com/guide).",
"",
"| A | B |",
"| --- | --- |",
"| 1 | 2 |",
"",
"![Mock](placeholder://mock/1)",
].join("\n"),
faq_items: [{ question: "What is this?", answer: "Preview draft." }],
visual_placeholders: ["![Mock](placeholder://mock/1)"],
evidence_references: ["evidence-id-1"],
unsupported_claim_warnings: [],
based_on_draft_id: null,
status: "DRAFT_ASSEMBLED",
created_at: "2026-05-21T00:00:00Z",
updated_at: "2026-05-21T00:00:00Z",
};
const secondDraft = {
...baseDraft,
id: "22222222-2222-2222-2222-222222222222",
version: 2,
title: "Draft Two",
meta_title: "Meta Two",
body_markdown: `${baseDraft.body_markdown}\n\nRevision note.`,
based_on_draft_id: baseDraft.id,
};
const previewBlocks = parseDraftPreview(baseDraft.body_markdown, baseDraft.faq_items);
assert.ok(previewBlocks.some((block) => block.type === "heading" && block.text === "Draft One"));
assert.ok(previewBlocks.some((block) => block.type === "table"));
assert.ok(previewBlocks.some((block) => block.type === "image" && block.src === "placeholder://mock/1"));
assert.ok(previewBlocks.some((block) => block.type === "paragraph" && block.links.length === 1));
assert.ok(previewBlocks.some((block) => block.type === "faq" && block.items.length === 1));
const selected = selectDraftById([secondDraft, baseDraft], baseDraft.id);
assert.equal(selected.id, baseDraft.id);
const comparison = compareDraftVersions(secondDraft, baseDraft);
assert.equal(comparison.titleChanged, true);
assert.equal(comparison.metaTitleChanged, true);
assert.equal(comparison.bodyChanged, true);
assert.ok(comparison.changedLineCount >= 1);