mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
fix(import): coerce non-string frontmatter title/slug/type (#1939)
YAML `title: 2024-06-01` parses to a Date and `title: 1458` to a number; the old `(frontmatter.X as string)` cast was a compile-time lie, so downstream `.toLowerCase()` threw and (via the importer failure gate) could wedge sync indefinitely. parseMarkdown now coerces via coerceFrontmatterString (Date -> UTC ISO date, deterministic), and the pure assessContentSanity self-protects against a non-string title.
This commit is contained in:
@@ -376,14 +376,18 @@ export function assessContentSanity(opts: {
|
||||
// doesn't repeat the lowercase per literal.
|
||||
const bodyHead = body.slice(0, SCAN_HEAD_BYTES);
|
||||
const bodyHeadLower = bodyHead.toLowerCase();
|
||||
const titleLower = opts.title.toLowerCase();
|
||||
// Defensive coercion (issue #1939): this is a pure exported fn; lint.ts and
|
||||
// import-file both pass `parsed.title`, which a malformed YAML date/number
|
||||
// title could make non-string. Never throw on a bad title.
|
||||
const title = String(opts.title ?? '');
|
||||
const titleLower = title.toLowerCase();
|
||||
|
||||
const junk_pattern_matches: string[] = [];
|
||||
for (const p of BUILT_IN_JUNK_PATTERNS) {
|
||||
const scope = p.applies_to ?? 'both';
|
||||
let matched = false;
|
||||
if (scope === 'title' || scope === 'both') {
|
||||
if (p.pattern.test(opts.title)) matched = true;
|
||||
if (p.pattern.test(title)) matched = true;
|
||||
}
|
||||
if (!matched && (scope === 'body' || scope === 'both')) {
|
||||
if (p.pattern.test(bodyHead)) matched = true;
|
||||
|
||||
@@ -112,7 +112,8 @@ function nonRedundancy(page: Page): number {
|
||||
}
|
||||
|
||||
function hasTitle(page: Page): number {
|
||||
return page.title && page.title.trim().length > 0 ? 1 : 0;
|
||||
// Coerce (issue #1939): a malformed YAML date/number title could be non-string.
|
||||
return String(page.title ?? '').trim().length > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
function hasBody(page: Page): number {
|
||||
|
||||
+22
-3
@@ -49,6 +49,25 @@ export interface ParsedMarkdown {
|
||||
errors?: ParseValidationError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a raw YAML frontmatter value into a string.
|
||||
*
|
||||
* js-yaml parses unquoted scalars by type: `title: 2024-06-01` becomes a JS
|
||||
* `Date`, `title: 1458` becomes a `number`. The old `(frontmatter.X as string)`
|
||||
* cast was a compile-time lie — at runtime the value stayed a Date/number, so
|
||||
* any downstream `.toLowerCase()` / `.trim()` threw and (via the importer's
|
||||
* failure gate) could wedge sync indefinitely (issue #1939).
|
||||
*
|
||||
* Dates coerce to their UTC ISO date (`2024-06-01`) — deterministic across
|
||||
* machines and matching the on-disk source token, unlike `String(date)` which
|
||||
* renders a timezone-dependent long form. Everything else uses `String()`.
|
||||
*/
|
||||
export function coerceFrontmatterString(v: unknown): string {
|
||||
if (v == null) return '';
|
||||
if (v instanceof Date) return v.toISOString().slice(0, 10);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a markdown file with YAML frontmatter into its components.
|
||||
*
|
||||
@@ -105,12 +124,12 @@ export function parseMarkdown(
|
||||
|
||||
const { compiled_truth, timeline } = splitBody(body);
|
||||
|
||||
const type = (frontmatter.type as string) || (
|
||||
const type = coerceFrontmatterString(frontmatter.type) || (
|
||||
opts?.activePack ? inferTypeFromPack(filePath, opts.activePack) : inferType(filePath)
|
||||
);
|
||||
const title = (frontmatter.title as string) || inferTitle(filePath);
|
||||
const title = coerceFrontmatterString(frontmatter.title).trim() || inferTitle(filePath);
|
||||
const tags = extractTags(frontmatter);
|
||||
const slug = (frontmatter.slug as string) || inferSlug(filePath);
|
||||
const slug = coerceFrontmatterString(frontmatter.slug) || inferSlug(filePath);
|
||||
|
||||
const cleanFrontmatter = { ...frontmatter };
|
||||
delete cleanFrontmatter.type;
|
||||
|
||||
@@ -640,3 +640,28 @@ describe('assessContentSanity — confidence split (Q1=A)', () => {
|
||||
expect(r.shouldFlag).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// issue #1939 — assessContentSanity is a pure exported fn; lint.ts and
|
||||
// import-file both pass `parsed.title`, which a malformed YAML date/number title
|
||||
// could make non-string. It must coerce defensively and never throw.
|
||||
describe('issue #1939 — non-string title defensive coercion', () => {
|
||||
const base = { compiled_truth: 'some prose body here', timeline: '' };
|
||||
|
||||
test('Date title does not throw', () => {
|
||||
expect(() =>
|
||||
assessContentSanity({ ...base, title: new Date('2024-06-01') as unknown as string }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test('number title does not throw', () => {
|
||||
expect(() =>
|
||||
assessContentSanity({ ...base, title: 1458 as unknown as string }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test('null/undefined title does not throw and yields a normal result', () => {
|
||||
const r = assessContentSanity({ ...base, title: undefined as unknown as string });
|
||||
expect(r).toBeDefined();
|
||||
expect(typeof r.shouldQuarantine).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -302,3 +302,44 @@ Some content.`;
|
||||
expect(parseMarkdown('', 'projects/blog/writing/essay.md').type).toBe('writing');
|
||||
});
|
||||
});
|
||||
|
||||
// issue #1939 — js-yaml parses `title: 2024-06-01` as a Date and `title: 1458`
|
||||
// as a number. The old `(frontmatter.title as string)` cast was a compile-time
|
||||
// lie; at runtime downstream `.toLowerCase()` threw and wedged sync. Coercion
|
||||
// must be non-throwing AND deterministic (UTC ISO for dates, no timezone drift).
|
||||
describe('issue #1939 — non-string frontmatter coercion', () => {
|
||||
test('date title coerces to its UTC ISO date string', () => {
|
||||
const parsed = parseMarkdown('---\ntitle: 2024-06-01\n---\nbody\n', 'apple-notes/x.md');
|
||||
expect(parsed.title).toBe('2024-06-01');
|
||||
expect(typeof parsed.title).toBe('string');
|
||||
});
|
||||
|
||||
test('number title coerces to its string form', () => {
|
||||
const parsed = parseMarkdown('---\ntitle: 1458\n---\nbody\n', 'apple-notes/x.md');
|
||||
expect(parsed.title).toBe('1458');
|
||||
});
|
||||
|
||||
test('date title is timezone-independent (UTC) — repro file shape', () => {
|
||||
// sources/apple-notes/YC/Talks YC/2023-04-25 1458.md style page.
|
||||
const parsed = parseMarkdown('---\ntitle: 2023-04-25\n---\nnotes\n', 'apple-notes/2023-04-25 1458.md');
|
||||
expect(parsed.title).toBe('2023-04-25'); // never "Mon Apr 24 2023 ...GMT-0700"
|
||||
});
|
||||
|
||||
test('date/number slug + type coerce without throwing', () => {
|
||||
const parsed = parseMarkdown('---\nslug: 2024-06-01\ntype: 2024\n---\nbody\n', 'x.md');
|
||||
expect(typeof parsed.slug).toBe('string');
|
||||
expect(parsed.slug).toBe('2024-06-01');
|
||||
expect(typeof parsed.type).toBe('string');
|
||||
});
|
||||
|
||||
test('missing/empty title falls back to inferred title (no throw)', () => {
|
||||
const parsed = parseMarkdown('---\ntype: note\n---\nbody\n', 'people/alice-example.md');
|
||||
expect(typeof parsed.title).toBe('string');
|
||||
expect(parsed.title.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('string title still passes through unchanged', () => {
|
||||
const parsed = parseMarkdown('---\ntitle: A Normal Title\n---\nbody\n', 'x.md');
|
||||
expect(parsed.title).toBe('A Normal Title');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user