feat(export): pure frontmatter + slug + markdown + jsonl + manifest composers

Pure compose layer for F5 (Export). slugifyTitle, composeFilename,
composeFrontmatter, composeMarkdown, composeIndexJsonl, composeManifest
+ ExportNote/ExportNoteMedia/ExportNoteTag types. No fs deps.
24 unit tests covering normal cases + edge cases (null title,
forbidden chars, multiline summary needing block scalar, colon
needing single-quote, image numbering by id8__n.ext).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-26 10:39:32 +09:00
parent c3b650058a
commit 8e09464d5e
2 changed files with 443 additions and 0 deletions

View File

@@ -0,0 +1,251 @@
/**
* Pure compose functions for F5 (Export).
*
* No filesystem, no I/O, no Date.now() — every output is a function of the input.
* Caller layer (Task 2+) handles fs writes, atomic rename, zip packaging.
*/
export interface ExportNoteMedia {
rel: string;
mime: string;
bytes: number;
}
export interface ExportNoteTag {
name: string;
source: 'ai' | 'user';
}
export interface ExportNote {
id: string;
createdAt: string;
updatedAt: string;
rawText: string;
aiTitle: string | null;
aiSummary: string | null;
titleEditedByUser: boolean;
summaryEditedByUser: boolean;
aiProvider: string | null;
aiGeneratedAt: string | null;
userIntent: string | null;
intentPromptedAt: string | null;
tags: ExportNoteTag[];
media: ExportNoteMedia[];
}
const FORBIDDEN_FS_CHARS_REGEX = /[/\\:*?"<>|]/g;
const WHITESPACE_RUN_REGEX = /\s+/g;
const SLUG_MAX_CODEPOINTS = 32;
export function slugifyTitle(title: string | null): string {
if (title === null || title === undefined) return 'untitled';
// Strip forbidden characters first.
const stripped = title.replace(FORBIDDEN_FS_CHARS_REGEX, '');
// Collapse whitespace runs to a single hyphen.
const hyphenated = stripped.replace(WHITESPACE_RUN_REGEX, '-');
// Trim leading/trailing hyphens.
const trimmed = hyphenated.replace(/^-+|-+$/g, '');
if (trimmed.length === 0) return 'untitled';
// Truncate to 32 unicode codepoints (handle Korean / emoji properly).
const cps = [...trimmed];
const truncated = cps.length > SLUG_MAX_CODEPOINTS
? cps.slice(0, SLUG_MAX_CODEPOINTS).join('')
: trimmed;
// Re-trim hyphens in case truncation landed on one.
const finalSlug = truncated.replace(/^-+|-+$/g, '');
return finalSlug.length === 0 ? 'untitled' : finalSlug;
}
export function composeFilename(input: {
id: string;
createdAt: string;
aiTitle: string | null;
}): string {
const date = input.createdAt.slice(0, 10);
const id8 = input.id.slice(0, 8);
const slug = slugifyTitle(input.aiTitle);
return `${date}-${id8}-${slug}.md`;
}
// ---------------------------------------------------------------------------
// YAML quoting helpers
// ---------------------------------------------------------------------------
const YAML_SPECIAL_CHARS = [':', "'", '"', '#', '[', ']', '{', '}'];
const YAML_LEADING_INDICATORS = ['-', '?', '!', '&', '*', '>'];
function needsQuoting(value: string): boolean {
if (value.length === 0) return true;
// Leading/trailing whitespace.
if (value !== value.trim()) return true;
// Embedded special chars that conflict with plain-scalar parsing.
for (const ch of YAML_SPECIAL_CHARS) {
if (value.includes(ch)) return true;
}
// Leading indicators.
const first = value.charAt(0);
if (YAML_LEADING_INDICATORS.includes(first)) return true;
return false;
}
function singleQuote(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
/**
* Format a string scalar for YAML.
* - Multiline → block scalar `|-` with given indent.
* - Otherwise → plain or single-quoted depending on contents.
*
* `indent` is the indent applied to body lines of the block scalar (default 2 spaces).
*/
function formatScalar(value: string, indent = 2): string {
if (value.includes('\n')) {
const pad = ' '.repeat(indent);
const lines = value.split('\n').map((l) => `${pad}${l}`);
return `|-\n${lines.join('\n')}`;
}
return needsQuoting(value) ? singleQuote(value) : value;
}
function formatTagName(name: string): string {
// Names can't be multiline in flow style — fall back to single-quote when needed.
if (name.includes('\n') || needsQuoting(name)) return singleQuote(name);
return name;
}
// ---------------------------------------------------------------------------
// composeFrontmatter
// ---------------------------------------------------------------------------
export function composeFrontmatter(note: ExportNote): string {
const lines: string[] = [];
lines.push('---');
lines.push(`id: ${note.id}`);
lines.push(`created_at: ${note.createdAt}`);
lines.push(`updated_at: ${note.updatedAt}`);
if (note.aiTitle !== null) {
lines.push(`title: ${formatScalar(note.aiTitle)}`);
lines.push(`title_source: ${note.titleEditedByUser ? 'user' : 'ai'}`);
}
if (note.aiSummary !== null) {
lines.push(`summary: ${formatScalar(note.aiSummary)}`);
lines.push(`summary_source: ${note.summaryEditedByUser ? 'user' : 'ai'}`);
}
if (note.tags.length > 0) {
lines.push('tags:');
for (const tag of note.tags) {
lines.push(` - { name: ${formatTagName(tag.name)}, source: ${tag.source} }`);
}
}
if (note.userIntent !== null) {
lines.push(`user_intent: ${formatScalar(note.userIntent)}`);
}
if (note.intentPromptedAt !== null) {
lines.push(`intent_prompted_at: ${note.intentPromptedAt}`);
}
if (note.aiProvider !== null) {
lines.push(`ai_provider: ${formatScalar(note.aiProvider)}`);
}
if (note.aiGeneratedAt !== null) {
lines.push(`ai_generated_at: ${note.aiGeneratedAt}`);
}
if (note.media.length > 0) {
lines.push('images:');
for (const m of note.media) {
lines.push(` - rel: ${formatScalar(m.rel)}`);
lines.push(` mime: ${formatScalar(m.mime)}`);
lines.push(` bytes: ${m.bytes}`);
}
}
lines.push('inkling_export_version: 1');
lines.push('---');
return lines.join('\n') + '\n';
}
// ---------------------------------------------------------------------------
// composeMarkdown
// ---------------------------------------------------------------------------
function extFromMime(mime: string): string {
switch (mime) {
case 'image/png':
return 'png';
case 'image/jpeg':
return 'jpg';
default:
return 'bin';
}
}
export function composeMarkdown(note: ExportNote): string {
const fm = composeFrontmatter(note);
const heading = `# ${note.aiTitle ?? '(제목 없음)'}`;
const id8 = note.id.slice(0, 8);
const sections: string[] = [];
sections.push(fm.trimEnd()); // remove trailing newline so we control spacing
sections.push(heading);
if (note.aiSummary !== null) {
sections.push(`> ${note.aiSummary}`);
}
sections.push(note.rawText);
if (note.media.length > 0) {
const imageLines = note.media.map((m, idx) => {
const n = idx + 1;
const ext = extFromMime(m.mime);
return `![](media/${id8}__${n}.${ext})`;
});
sections.push(imageLines.join('\n'));
}
return sections.join('\n\n') + '\n';
}
// ---------------------------------------------------------------------------
// composeIndexJsonl
// ---------------------------------------------------------------------------
export function composeIndexJsonl(
entries: Array<{ note: ExportNote; path: string }>
): string {
if (entries.length === 0) return '';
const lines = entries.map(({ note, path }) =>
JSON.stringify({
id: note.id,
path,
created_at: note.createdAt,
tags: note.tags.map((t) => t.name),
embedding_text: note.rawText
})
);
return lines.join('\n') + '\n';
}
// ---------------------------------------------------------------------------
// composeManifest
// ---------------------------------------------------------------------------
export function composeManifest(input: {
exportedAt: string;
noteCount: number;
mediaCount: number;
}): string {
return JSON.stringify(
{
inkling_export_version: 1,
exported_at: input.exportedAt,
note_count: input.noteCount,
media_count: input.mediaCount
},
null,
2
);
}