feat(task-013): add draft assembly API, immutable versioning, and editor preview
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user