feat(frontmatter/file-io-and-serialize): implement parseTaskFile, parseTaskDirectory, and serializeFrontmatter

This commit is contained in:
2026-04-27 11:51:34 +00:00
parent 9ad0ec902c
commit 6da0cb12ce
6 changed files with 646 additions and 26 deletions

View File

@@ -0,0 +1,77 @@
// File I/O functions for frontmatter parsing.
//
// ⚠️ These functions depend on `node:fs/promises` and are only available in
// Node.js-compatible runtimes. Consumers targeting Deno, Bun, or browsers
// should use `parseFrontmatter` directly with their own file-reading mechanism.
import { readFile, readdir } from 'node:fs/promises';
import { join, extname } from 'node:path';
import { parseFrontmatter } from './parse.js';
import type { TaskInput as TaskInputType } from '../schema/task.js';
/**
* Read a markdown file and parse its YAML frontmatter into a validated
* `TaskInput` object.
*
* Delegates to `parseFrontmatter` for parsing and validation.
* Throws the underlying Node.js error for I/O failures (ENOENT, EACCES, etc.).
*
* @param filePath — Absolute or relative path to a `.md` file
* @returns Validated `TaskInput` object
* @throws Node.js system errors for I/O failures
* @throws {InvalidInputError} When frontmatter is missing or invalid
*
* @nodeOnly Uses `node:fs/promises.readFile`
*/
export async function parseTaskFile(filePath: string): Promise<TaskInputType> {
const content = await readFile(filePath, 'utf-8');
return parseFrontmatter(content);
}
/**
* Recursively scan a directory for `.md` files and parse each one that
* contains valid `---`-delimited YAML frontmatter.
*
* - Files without valid frontmatter are silently skipped (no error thrown,
* just omitted from the results).
* - Non-`.md` files are ignored.
* - Throws the underlying Node.js error for I/O failures (ENOENT, EACCES).
* - Uses `parseTaskFile` per file.
*
* @param dirPath — Absolute or relative path to a directory
* @returns Array of validated `TaskInput` objects (one per valid file)
* @throws Node.js system errors for I/O failures
*
* @nodeOnly Uses `node:fs/promises.readdir` and `node:fs/promises.stat`
*/
export async function parseTaskDirectory(dirPath: string): Promise<TaskInputType[]> {
const results: TaskInputType[] = [];
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dirPath, entry.name);
if (entry.isDirectory()) {
// Recurse into subdirectories
const subResults = await parseTaskDirectory(fullPath);
results.push(...subResults);
} else if (entry.isFile() && extname(entry.name).toLowerCase() === '.md') {
// Only process .md files; skip others silently
try {
const task = await parseTaskFile(fullPath);
results.push(task);
} catch (err) {
// Silently skip files without valid frontmatter (InvalidInputError
// from parseFrontmatter means no valid --- delimiters found, or
// the YAML/schema is invalid). Rethrow I/O errors.
if (err instanceof Error && err.name === 'InvalidInputError') {
continue;
}
throw err;
}
}
// Non-.md files and other entry types are silently skipped
}
return results;
}

View File

@@ -1,4 +1,5 @@
// Frontmatter submodule — parse and serialize
// Frontmatter submodule — parse, serialize, and file I/O
export { splitFrontmatter, parseFrontmatter, parseTaskFile, parseTaskDirectory } from './parse.js';
export { serializeFrontmatter } from './serialize.js';
export { splitFrontmatter, parseFrontmatter } from './parse.js';
export { serializeFrontmatter } from './serialize.js';
export { parseTaskFile, parseTaskDirectory } from './file-io.js';

View File

@@ -134,12 +134,3 @@ export function parseFrontmatter(markdown: string): TaskInputType {
return cleaned as TaskInputType;
}
export function parseTaskFile(_input: string): unknown {
// Stub — implementation pending
return {};
}
export function parseTaskDirectory(_dir: string): unknown[] {
// Stub — implementation pending
return [];
}

View File

@@ -1,6 +1,36 @@
// TaskInput → markdown with frontmatter
// TaskInput → markdown with YAML frontmatter
export function serializeFrontmatter(_input: unknown): string {
// Stub — implementation pending
return '';
import { stringify as yamlStringify } from 'yaml';
import type { TaskInput as TaskInputType } from '../schema/task.js';
/**
* Serialize a `TaskInput` object into a `---`-delimited markdown string.
*
* - Uses `yaml.stringify()` for the data portion (YAML 1.2 output).
* - Includes all `TaskInput` fields in the YAML (per schema convention).
* - Nullable fields set to `null` produce explicit `null` in YAML.
* - Absent (undefined) fields are omitted from the YAML output.
* - Appends optional body content (default: empty string) after the
* closing `---`.
*
* @param task — The `TaskInput` object to serialize
* @param body — Optional markdown body content after the frontmatter
* @returns A complete markdown string with frontmatter and optional body
*/
export function serializeFrontmatter(task: TaskInputType, body?: string): string {
// Build a clean object: include all defined fields, omit undefined ones.
// This ensures `yaml.stringify()` only writes keys that are present,
// keeping the output compact and matching what `parseFrontmatter` expects.
const data: Record<string, unknown> = {};
for (const [key, value] of Object.entries(task)) {
if (value !== undefined) {
data[key] = value;
}
}
const yaml = yamlStringify(data, { lineWidth: 0 });
const content = body ?? '';
return `---\n${yaml}---\n${content}`;
}