feat(task-013): add draft assembly API, immutable versioning, and editor preview
This commit is contained in:
@@ -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.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>
|
||||
);
|
||||
|
||||
@@ -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 |",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
faq_items: [{ question: "What is this?", answer: "Preview draft." }],
|
||||
visual_placeholders: [""],
|
||||
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);
|
||||
Reference in New Issue
Block a user