Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/obsidian-empty-tags-frontmatter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@inkeep/open-knowledge-core": patch
"@inkeep/open-knowledge": patch
---

Fix the Properties panel rejecting Obsidian-style empty `tags:` / `aliases:` frontmatter. Files that use Obsidian's default "no tags" shape (an empty `tags:` list whose only item is a blank `- `, or a bare `tags:` with no value) parse to YAML `null`, which the read schema previously rejected for the whole frontmatter block — so the panel showed nothing (or an error banner) and refused every edit, even to the file's other valid properties. The read path now coerces these empty shapes to empty values (`tags:\n- ` reads as an empty list, a bare `tags:` as empty text), so the panel reads and edits these files normally. Files are not rewritten on disk until you actually edit a property.
35 changes: 35 additions & 0 deletions packages/core/src/frontmatter/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test';
import {
FRONTMATTER_TYPES,
FrontmatterMapSchema,
frontmatterValuesEqual,
inferType,
isFrontmatterValueEmpty,
Expand Down Expand Up @@ -122,3 +123,37 @@ describe('frontmatterValuesEqual', () => {
expect(frontmatterValuesEqual({ a: 1, b: 2 }, { a: 1, c: 2 })).toBe(false);
});
});

describe('FrontmatterMapSchema — Obsidian null coercion', () => {
test('drops null sequence elements ([null] → [])', () => {
const result = FrontmatterMapSchema.safeParse({ tags: [null] });
expect(result.success).toBe(true);
if (result.success) expect(result.data).toEqual({ tags: [] });
});

test('drops null elements but keeps real items in a mixed sequence', () => {
const result = FrontmatterMapSchema.safeParse({ aliases: ['Real', null, 'Also'] });
expect(result.success).toBe(true);
if (result.success) expect(result.data).toEqual({ aliases: ['Real', 'Also'] });
});

test('coerces a bare-key null scalar to an empty string (key stays visible)', () => {
const result = FrontmatterMapSchema.safeParse({ tags: null, author: 'Alice' });
expect(result.success).toBe(true);
if (result.success) expect(result.data).toEqual({ tags: '', author: 'Alice' });
});

test('coerces null nested inside a mapping subtree', () => {
const result = FrontmatterMapSchema.safeParse({ metadata: { version: null, name: 'x' } });
expect(result.success).toBe(true);
if (result.success) expect(result.data).toEqual({ metadata: { version: '', name: 'x' } });
});

test('leaves null-free maps untouched (no behavior change for valid input)', () => {
const input = { title: 'Hello', tags: ['a', 'b'], meta: { n: 1 } };
const result = FrontmatterMapSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success)
expect(result.data).toEqual({ title: 'Hello', tags: ['a', 'b'], meta: { n: 1 } });
});
});
24 changes: 23 additions & 1 deletion packages/core/src/frontmatter/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,29 @@ export function inferType(value: FrontmatterValue): FrontmatterType {
return 'text';
}

export const FrontmatterMapSchema = z.record(z.string(), FrontmatterValueSchema);
function coerceNullFrontmatter(value: unknown): unknown {
if (Array.isArray(value)) {
const out: unknown[] = [];
for (const element of value) {
if (element === null) continue;
out.push(coerceNullFrontmatter(element));
}
return out;
}
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
out[key] = child === null ? '' : coerceNullFrontmatter(child);
}
return out;
}
return value;
}

export const FrontmatterMapSchema = z.preprocess(
coerceNullFrontmatter,
z.record(z.string(), FrontmatterValueSchema),
);
export type FrontmatterMap = Record<string, FrontmatterValue>;

export const FrontmatterPatchSchema = z.record(
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/frontmatter/tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ describe('extractFrontmatterTags', () => {
expect(extractFrontmatterTags('tags: null\n')).toEqual([]);
});

test("returns empty for Obsidian's empty-list shape (`tags:\\n- `)", () => {
expect(extractFrontmatterTags('tags:\n- \n')).toEqual([]);
});

test('drops a null entry but keeps real tags in a mixed block sequence', () => {
expect(extractFrontmatterTags('tags:\n - real\n - \n - also\n')).toEqual(['real', 'also']);
});

test('drops object array elements rather than stringifying them', () => {
const yaml = 'tags:\n - valid\n - {nested: "object"}\n - alsoValid\n';
expect(extractFrontmatterTags(yaml)).toEqual(['valid', 'alsoValid']);
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/frontmatter/yaml-codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ describe('parseFrontmatterYaml', () => {
expect(parseFrontmatterYaml('"just a string"').map).toBeNull();
});

test("coerces Obsidian's empty-list / bare-key null shapes instead of rejecting the map", () => {
expect(parseFrontmatterYaml('tags:\n- \n').map).toEqual({ tags: [] });
expect(parseFrontmatterYaml('tags:\n').map).toEqual({ tags: '' });
const mixed = parseFrontmatterYaml('plugin-id: dataview\ntags:\n- \npublish: true\n');
expect(mixed.parseError).toBeUndefined();
expect(mixed.map).toEqual({ 'plugin-id': 'dataview', tags: [], publish: true });
});

test('parses nested objects into a populated map with no parseError', () => {
const yaml = 'name: skill\nmetadata:\n version: 1.0.0\n author: Inkeep\n';
const { map, parseError } = parseFrontmatterYaml(yaml);
Expand Down
39 changes: 39 additions & 0 deletions packages/server/src/api-agent-frontmatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,45 @@ describe('POST /api/agent-write-md (write_document) — malformed-FM refusal (PR
}
});

test("Obsidian's empty `tags:` / `aliases:` null shapes write through (200), not refused at the gate", async () => {
const { contentDir, hocuspocus, sessionManager, cleanup } = setup();
try {
const session = await sessionManager.getSession('test-doc');

const obsidianShape = [
'---',
'plugin-id: dataview',
'tags:',
'- ',
'aliases:',
'- ',
'publish: true',
'---',
'',
'# Body',
'',
].join('\n');

const response = await callApi(
hocuspocus,
sessionManager,
contentDir,
'/api/agent-write-md',
{ docName: 'test-doc', markdown: obsidianShape, position: 'replace' },
);

expect(response.status).toBe(200);
expect(fmMap(session.dc.document)).toEqual({
'plugin-id': 'dataview',
tags: [],
aliases: [],
publish: true,
});
} finally {
await cleanup();
}
});

test('append AND prepend skip the gate even when the existing FM is already malformed', async () => {
const { contentDir, hocuspocus, sessionManager, cleanup } = setup();
try {
Expand Down