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
@@ -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;
}