Skip to content

Commit af19bf8

Browse files
committed
feat: validate and coerce date, path, and wikilink formats
- Coerce Date objects (from gray-matter/js-yaml YAML parsing) to YYYY-MM-DD strings in the markdown plugin read phase, before schema validation - Register real validators for date (YYYY-MM-DD), path (non-empty, no null bytes), and wikilink ([[...]]) formats in the AJV instance - Add wikilink as a format annotation alternative to the existing $ref-based definition — both options now work - Add shared test fixture schema (tests/fixtures/test-schema.json) covering all three formats, usable across future tests - Document custom format annotations and date coercion in docs/schemas.md Closes #79
1 parent 70ff203 commit af19bf8

11 files changed

Lines changed: 232 additions & 7 deletions

File tree

docs/schemas.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,38 @@ Main types:
4848

4949
This schema composes shared structural defs and strict metadata/rules from partials.
5050

51+
## Custom format annotations
52+
53+
`ost-tools` registers the following `format` annotations beyond standard JSON Schema. All apply to `string` properties and are validated at schema validation time.
54+
55+
| Format | Validates | Example |
56+
|--------|-----------|---------|
57+
| `date` | ISO 8601 date (`YYYY-MM-DD`) | `"2026-03-31"` |
58+
| `path` | Non-empty filesystem path — absolute, relative, or a plain name | `"notes"`, `"./subdir/file.md"`, `"/abs/path"` |
59+
| `wikilink` | Obsidian wikilink syntax (`[[...]]`) | `"[[Parent Node]]"` |
60+
61+
`path` and `wikilink` are also available as shared `$ref` definitions in `_ost_tools_base.json`:
62+
63+
```json
64+
{ "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink" }
65+
```
66+
67+
Using `format` directly is more concise when the full definition isn't needed:
68+
69+
```json
70+
{ "type": "string", "format": "wikilink" }
71+
```
72+
73+
### Date coercion
74+
75+
YAML parsers (gray-matter, js-yaml) coerce unquoted ISO dates to JavaScript `Date` objects:
76+
77+
```yaml
78+
published_date: 2026-03-31 # parsed as a Date object by gray-matter
79+
```
80+
81+
The markdown plugin automatically coerces `Date` objects to `YYYY-MM-DD` strings before validation, so unquoted dates in frontmatter and embedded YAML blocks work correctly with `format: "date"` fields.
82+
5183
## Metadata dialect
5284

5385
Schemas use this metaschema URL:

src/plugins/loader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export async function loadPlugins(
8989
): Promise<LoadedPlugin[]> {
9090
const builtinsByName = new Map(builtinPlugins.map((p) => [p.name, p]));
9191
const ajv = new Ajv();
92-
ajv.addFormat('path', () => true);
92+
ajv.addFormat('path', (value: string) => value.length > 0 && !value.includes('\0'));
9393
const loaded: LoadedPlugin[] = [];
9494

9595
// External plugins: entries in the map that are not built-in names

src/plugins/markdown/parse-embedded.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
SchemaMetadata,
1313
SharedEmbeddingFields,
1414
} from '../../plugin-api';
15-
import { applyFieldMap } from './util';
15+
import { applyFieldMap, coerceDates } from './util';
1616

1717
/** Type values that identify a space_on_a_page container (not themselves space nodes). */
1818
export const ON_A_PAGE_TYPES = ['ost_on_a_page', 'space_on_a_page'];
@@ -831,7 +831,7 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio
831831
const code = child as Code;
832832
const parsed = yamlLoad(code.value);
833833
if (parsed && !Array.isArray(parsed) && typeof parsed === 'object') {
834-
Object.assign(activeNode.schemaData, applyFieldMap(parsed as Record<string, unknown>, fieldMap));
834+
Object.assign(activeNode.schemaData, coerceDates(applyFieldMap(parsed as Record<string, unknown>, fieldMap)));
835835
} else if (Array.isArray(parsed)) {
836836
throw new Error(`YAML block must be an object at "${activeNode.label}".`);
837837
} else {

src/plugins/markdown/read-space.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { BaseNode } from '../../plugin-api';
66
import type { ParseResult, PluginContext } from '../util';
77
import type { MarkdownPluginConfig } from '.';
88
import { extractEmbeddedNodes, ON_A_PAGE_TYPES } from './parse-embedded';
9-
import { applyFieldMap } from './util';
9+
import { applyFieldMap, coerceDates } from './util';
1010

1111
type ReadSpaceDirectoryOptions = {
1212
includeOnAPageFiles?: boolean;
@@ -83,7 +83,7 @@ export async function readSpaceDirectory(
8383
continue;
8484
}
8585

86-
const data = applyFieldMap(parsed.data, fieldMap);
86+
const data = coerceDates(applyFieldMap(parsed.data, fieldMap));
8787

8888
if (!data.type) {
8989
nonSpace.push(file);

src/plugins/markdown/util.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
/**
2+
* Coerce Date objects in frontmatter/YAML data to ISO date strings (YYYY-MM-DD).
3+
* gray-matter and js-yaml parse unquoted ISO dates (e.g. `date: 2026-03-31`) as
4+
* JavaScript Date objects, which are not valid JSON and fail string type validation.
5+
*/
6+
export function coerceDates(data: Record<string, unknown>): Record<string, unknown> {
7+
const result: Record<string, unknown> = {};
8+
for (const [key, value] of Object.entries(data)) {
9+
if (value instanceof Date) {
10+
result[key] = value.toISOString().slice(0, 10);
11+
} else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
12+
result[key] = coerceDates(value as Record<string, unknown>);
13+
} else {
14+
result[key] = value;
15+
}
16+
}
17+
return result;
18+
}
19+
120
/**
221
* Apply field remapping to a data object.
322
* Renames keys according to fieldMap (file field name → canonical field name).

src/schema/schema.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ function compileValidator(
8484
schemaRefRegistry: Map<string, AnySchemaObject>,
8585
): ValidateFunction {
8686
const ajv = new Ajv();
87-
ajv.addFormat('path', () => true);
88-
ajv.addFormat('date', () => true);
87+
ajv.addFormat('path', (value: string) => value.length > 0 && !value.includes('\0'));
88+
ajv.addFormat('date', (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value));
89+
ajv.addFormat('wikilink', (value: string) => /^\[\[.+\]\]$/.test(value));
8990
ajv.addKeyword({
9091
keyword: '$metadata',
9192
schemaType: 'object',
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
type: note
3+
title: Article Note
4+
date: 2026-03-31
5+
---
6+
7+
A note with an unquoted YAML date in frontmatter.

tests/fixtures/test-schema.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"$id": "test://test-schema",
3+
"description": "Minimal schema for unit and integration tests. Extend with additional types as test coverage requires new scenarios.",
4+
"oneOf": [
5+
{
6+
"type": "object",
7+
"description": "A simple note type with an optional date field for testing date coercion and format validation.",
8+
"properties": {
9+
"type": { "const": "note" },
10+
"title": { "type": "string" },
11+
"date": {
12+
"type": "string",
13+
"format": "date",
14+
"description": "ISO 8601 date (YYYY-MM-DD)"
15+
},
16+
"path": {
17+
"type": "string",
18+
"format": "path",
19+
"description": "A filesystem path (absolute, relative, or plain filename)"
20+
},
21+
"related": {
22+
"type": "string",
23+
"format": "wikilink",
24+
"description": "An Obsidian wikilink, e.g. [[Other Note]]"
25+
}
26+
},
27+
"required": ["type", "title"],
28+
"additionalProperties": true
29+
}
30+
]
31+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { coerceDates } from '../../../src/plugins/markdown/util';
3+
4+
describe('coerceDates', () => {
5+
it('converts a Date object to YYYY-MM-DD string', () => {
6+
const result = coerceDates({ date: new Date('2026-03-31') });
7+
expect(result.date).toBe('2026-03-31');
8+
});
9+
10+
it('leaves string values unchanged', () => {
11+
const result = coerceDates({ date: '2026-03-31', title: 'hello' });
12+
expect(result.date).toBe('2026-03-31');
13+
expect(result.title).toBe('hello');
14+
});
15+
16+
it('leaves numbers and booleans unchanged', () => {
17+
const result = coerceDates({ count: 3, active: true });
18+
expect(result.count).toBe(3);
19+
expect(result.active).toBe(true);
20+
});
21+
22+
it('recurses into nested objects', () => {
23+
const result = coerceDates({ meta: { created: new Date('2025-01-01') } });
24+
expect((result.meta as Record<string, unknown>).created).toBe('2025-01-01');
25+
});
26+
27+
it('does not recurse into arrays', () => {
28+
const dates = [new Date('2025-01-01')];
29+
const result = coerceDates({ items: dates });
30+
expect(result.items).toBe(dates);
31+
});
32+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { beforeAll, describe, expect, it } from 'bun:test';
2+
import { join } from 'node:path';
3+
import { readSpaceDirectory } from '../../src/plugins/markdown/read-space';
4+
import { createValidator } from '../../src/schema/schema';
5+
import { makePluginContext } from '../helpers/context';
6+
7+
const TEST_SCHEMA_PATH = join(import.meta.dir, '../fixtures/test-schema.json');
8+
const VALID_DIR = join(import.meta.dir, '../fixtures/date-coercion/valid');
9+
10+
const validateNode = createValidator(TEST_SCHEMA_PATH);
11+
12+
describe('date coercion and format validation', () => {
13+
describe('readSpaceDirectory with unquoted YAML date', () => {
14+
let nodes: Awaited<ReturnType<typeof readSpaceDirectory>>['nodes'];
15+
16+
beforeAll(async () => {
17+
({ nodes } = await readSpaceDirectory(makePluginContext(VALID_DIR, TEST_SCHEMA_PATH)));
18+
});
19+
20+
it('produces one node', () => {
21+
expect(nodes).toHaveLength(1);
22+
});
23+
24+
it('coerces Date object to ISO date string', () => {
25+
expect(nodes[0]?.schemaData.date).toBe('2026-03-31');
26+
expect(nodes[0]?.schemaData.date).toBeTypeOf('string');
27+
});
28+
29+
it('coerced date passes schema validation', () => {
30+
expect(validateNode(nodes[0]?.schemaData)).toBe(true);
31+
});
32+
});
33+
34+
describe('format: "date" schema validation', () => {
35+
it('accepts a valid ISO date string', () => {
36+
expect(validateNode({ type: 'note', title: 'Test', date: '2026-03-31' })).toBe(true);
37+
});
38+
39+
it('rejects a non-date string', () => {
40+
expect(validateNode({ type: 'note', title: 'Test', date: 'not-a-date' })).toBe(false);
41+
});
42+
43+
it('rejects a datetime string for a date field', () => {
44+
expect(validateNode({ type: 'note', title: 'Test', date: '2026-03-31T00:00:00Z' })).toBe(false);
45+
});
46+
47+
it('rejects a Date object (unconverted)', () => {
48+
expect(validateNode({ type: 'note', title: 'Test', date: new Date('2026-03-31') })).toBe(false);
49+
});
50+
});
51+
});

0 commit comments

Comments
 (0)