From 96bd14e7d6ec2c68d8fb4f2741d006b64b94962a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 05:03:15 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20unified=20hybrid=20parsing=20?= =?UTF-8?q?=E2=80=94=20embed=20sub-nodes=20in=20any=20typed=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge the two parsing architectures into a single unified model: - New `src/parse-embedded.ts` module contains all shared body-parsing logic (headings, typed bullets, YAML blocks, anchors) used by both `readOstPage` and `readSpace`. - `read-ost-page.ts` now handles hybrid files (non-ost_on_a_page types): the page itself is added as a node, then embedded children are extracted from the body. - `read-space.ts` overhauled: all typed files (including ost_on_a_page) have their body parsed for embedded nodes. The `includePageFiles` option is removed — ost_on_a_page embedded nodes are always included. Embedded nodes get compound labels (`filename#title`) and parent refs use `[[filename]]` or `[[filename#title]]` format. - `validate.ts`: new `labelToKey` utility handles compound labels; node index supports `[[File#Section]]` and `[[File#^anchor]]` refs. - Anchor support: headings may end with ` ^anchorname`; anchors named after OST types (e.g. `^mission`, `^goal1`) also imply the node type. New fixtures and comprehensive tests for all hybrid scenarios. Co-authored-by: Roger Barnes --- src/parse-embedded.ts | 364 ++++++++++++++++++++ src/read-ost-page.ts | 286 ++------------- src/read-space.ts | 34 +- src/validate.ts | 59 +++- tests/fixtures/hybrid-anchor-type.md | 14 + tests/fixtures/hybrid-page-valid.md | 15 + tests/fixtures/valid-ost/hybrid_solution.md | 5 + tests/fixtures/valid-ost/hybrid_vision.md | 15 + tests/read-ost-page.test.ts | 74 +++- tests/read-space.test.ts | 61 +++- tests/validate.test.ts | 142 +++++++- 11 files changed, 772 insertions(+), 297 deletions(-) create mode 100644 src/parse-embedded.ts create mode 100644 tests/fixtures/hybrid-anchor-type.md create mode 100644 tests/fixtures/hybrid-page-valid.md create mode 100644 tests/fixtures/valid-ost/hybrid_solution.md create mode 100644 tests/fixtures/valid-ost/hybrid_vision.md diff --git a/src/parse-embedded.ts b/src/parse-embedded.ts new file mode 100644 index 0000000..816140e --- /dev/null +++ b/src/parse-embedded.ts @@ -0,0 +1,364 @@ +import { load as yamlLoad } from 'js-yaml'; +import type { Code, Heading, List, ListItem, Paragraph, Root } from 'mdast'; +import { toString as mdastToString } from 'mdast-util-to-string'; +import remarkGfm from 'remark-gfm'; +import remarkParse from 'remark-parse'; +import { unified } from 'unified'; +import type { OstNode, OstPageDiagnostics } from './types.js'; + +export const OST_TYPES = ['vision', 'mission', 'goal', 'opportunity', 'solution'] as const; +export type OstType = (typeof OST_TYPES)[number]; + +export const DEFAULT_STATUS = 'identified'; + +export interface StackEntry { + depth: number; + title: string; + /** Empty string marks an untyped heading placeholder (hybrid mode only). */ + ostType: string; +} + +/** Extract [key:: value] bracketed inline fields, return cleaned text and fields. */ +export function extractBracketedFields(text: string): { + cleanText: string; + fields: Record; +} { + const fields: Record = {}; + const cleanText = text + .replace(/\[([^\]]+?):: *([^\]]*)\]/g, (_, key, value) => { + fields[key.trim()] = value.trim(); + return ''; + }) + .trim(); + return { cleanText, fields }; +} + +/** + * Extract unbracketed dataview fields (key:: value on own line). + * Keys must be identifier-style (letters, digits, hyphens, underscores — no spaces). + * Lines matching the pattern are consumed as fields; other lines kept as content. + */ +export function extractUnbracketedFields(text: string): { + remainingText: string; + fields: Record; +} { + const fields: Record = {}; + const remaining: string[] = []; + + for (const line of text.split('\n')) { + const match = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*):: *(.*)$/); + if (match) { + fields[match[1]!.trim()] = match[2]!.trim(); + } else { + remaining.push(line); + } + } + + return { remainingText: remaining.join('\n').trim(), fields }; +} + +/** + * Extract a trailing Obsidian block anchor from heading text. + * e.g. "My Title ^anchor-id" → { cleanText: "My Title", anchor: "anchor-id" } + */ +export function extractAnchor(text: string): { cleanText: string; anchor?: string } { + const match = text.match(/\s+\^([a-zA-Z0-9][a-zA-Z0-9_-]*)$/); + if (match) { + return { + cleanText: text.slice(0, text.length - match[0].length).trim(), + anchor: match[1], + }; + } + return { cleanText: text }; +} + +/** + * If the anchor name exactly matches an OST type (or an OST type followed by digits), + * return that type. Otherwise return undefined. + * Examples: "mission" → "mission", "goal1" → "goal", "myanchor" → undefined + */ +export function anchorToOstType(anchor: string): string | undefined { + for (const type of OST_TYPES) { + if (anchor === type || new RegExp(`^${type}\\d+$`).test(anchor)) { + return type; + } + } + return undefined; +} + +/** + * Returns the default OST type for a new heading based on its parent's effective type. + * The first heading in a document defaults to 'vision'; each child is the next in sequence. + */ +export function defaultOstType(stack: StackEntry[]): string { + if (stack.length === 0) return OST_TYPES[0]!; + const parentType = stack[stack.length - 1]?.ostType; + const idx = OST_TYPES.indexOf(parentType as OstType); + if (idx === -1 || idx >= OST_TYPES.length - 1) { + throw new Error(`No OST type follows "${parentType}" — cannot determine type for child heading`); + } + return OST_TYPES[idx + 1]!; +} + +function appendContent(node: OstNode, text: string): void { + if (!text) return; + const existing = node.data.content as string | undefined; + node.data.content = existing ? `${existing}\n${text}` : text; +} + +function processListItem( + item: ListItem, + parentRef: string | undefined, + contentTarget: OstNode, + nodes: OstNode[], + makeLabel: (title: string) => string, + makeParentRef: (title: string) => string, +): void { + const firstPara = item.children.find((c) => c.type === 'paragraph') as Paragraph | undefined; + + if (!firstPara) { + appendContent(contentTarget, `- ${mdastToString(item)}`); + return; + } + + const rawText = mdastToString(firstPara); + const { cleanText, fields } = extractBracketedFields(rawText); + + if (fields.type) { + const dashIdx = cleanText.indexOf(' - '); + const title = (dashIdx >= 0 ? cleanText.slice(0, dashIdx) : cleanText).trim(); + const summary = dashIdx >= 0 ? cleanText.slice(dashIdx + 3).trim() : undefined; + + const data: Record = { + title, + type: fields.type, + status: DEFAULT_STATUS, + ...fields, + }; + if (parentRef) data.parent = parentRef; + if (summary) data.summary = summary; + + const newNode: OstNode = { label: makeLabel(title), data }; + nodes.push(newNode); + + const nestedParentRef = makeParentRef(title); + for (const child of item.children) { + if (child.type === 'list') { + for (const subItem of (child as List).children) { + processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, makeParentRef); + } + } + } + } else { + appendContent(contentTarget, `- ${rawText}`); + } +} + +export interface ExtractEmbeddedOptions { + /** + * Title of the containing page. If provided (and pageType is non-`ost_on_a_page`), + * the page acts as a virtual depth-0 parent for first-level embedded headings. + */ + pageTitle?: string; + /** + * OST type of the containing page. + * - If set to a real OST type (not 'ost_on_a_page'): only headings with an explicit + * `[type:: x]` field or an OST-type anchor become nodes (hybrid mode). + * - If 'ost_on_a_page' or undefined: all headings become nodes with depth-based + * type inference (classic ost_on_a_page behaviour). + */ + pageType?: string; + /** + * Prefix prepended to node labels: `labelPrefix + headingTitle`. + * In space context use e.g. `"filename#"` so labels become `"filename#Heading Title"`. + * In standalone context leave empty — labels are just heading titles. + */ + labelPrefix?: string; +} + +export interface ExtractEmbeddedResult { + nodes: OstNode[]; + diagnostics: OstPageDiagnostics; +} + +/** + * Extract OST nodes from markdown body text. + * + * Shared by both readOstPage (single-file) and readSpace (directory) to find + * embedded sub-nodes within a page's content. + */ +export function extractEmbeddedNodes( + body: string, + options: ExtractEmbeddedOptions = {}, +): ExtractEmbeddedResult { + const { pageTitle, pageType, labelPrefix = '' } = options; + const isHybridMode = pageType !== undefined && pageType !== 'ost_on_a_page'; + + const nodes: OstNode[] = []; + // Preamble/root content sink — never added to nodes + const rootNode: OstNode = { label: '_root_', data: { type: 'ost_on_a_page' } }; + + const tree = unified().use(remarkParse).use(remarkGfm).parse(body) as Root; + + // In hybrid mode: stack starts with the page's own virtual entry (depth 0). + // In ost_on_a_page mode: stack starts empty (first heading has no parent). + const stack: StackEntry[] = + isHybridMode && pageTitle !== undefined + ? [{ depth: 0, title: pageTitle, ostType: pageType }] + : []; + + let currentContextNode: OstNode = rootNode; + + type ParseState = 'preamble' | 'active' | 'done'; + let parseState: ParseState = 'preamble'; + + const diagnostics: OstPageDiagnostics = { + preambleNodeCount: 0, + terminatedHeadings: [], + }; + + function makeLabel(title: string): string { + return labelPrefix ? `${labelPrefix}${title}` : title; + } + + /** + * Walk the stack backwards to find the deepest real OST node entry (ostType !== ''). + * Untyped-heading placeholders (ostType === '') are skipped so that typed headings + * beneath an untyped heading correctly inherit the last typed ancestor. + */ + function currentParentRef(): string | undefined { + for (let i = stack.length - 1; i >= 0; i--) { + const entry = stack[i]!; + if (entry.ostType === '') continue; // untyped placeholder + if (entry.depth === 0) { + // The page itself is the parent + return pageTitle ? `[[${pageTitle}]]` : undefined; + } + // An embedded heading is the parent + return labelPrefix ? `[[${labelPrefix}${entry.title}]]` : `[[${entry.title}]]`; + } + return undefined; + } + + function makeParentRef(title: string): string { + return labelPrefix ? `[[${labelPrefix}${title}]]` : `[[${title}]]`; + } + + for (const child of tree.children) { + if (parseState === 'done') { + if (child.type === 'heading') { + const rawTitle = mdastToString(child); + const { cleanText: afterBracketed } = extractBracketedFields(rawTitle); + const { cleanText: title } = extractAnchor(afterBracketed); + diagnostics.terminatedHeadings.push(title); + } + continue; + } + + if (child.type === 'thematicBreak') { + if (parseState === 'active') parseState = 'done'; + continue; + } + + if (child.type === 'heading') { + const heading = child as Heading; + const depth = heading.depth; + if (depth > 5) continue; + + parseState = 'active'; + + const rawText = mdastToString(heading); + const { cleanText: afterBracketed, fields: inlineFields } = extractBracketedFields(rawText); + const { cleanText: title, anchor } = extractAnchor(afterBracketed); + + const anchorType = anchor ? anchorToOstType(anchor) : undefined; + const hasExplicitType = !!inlineFields.type; + const hasImpliedType = !!anchorType; + + if (isHybridMode && !hasExplicitType && !hasImpliedType) { + // Untyped heading in hybrid mode: update depth stack but don't create a node. + while (stack.length > 0 && stack[stack.length - 1]!.depth >= depth) { + stack.pop(); + } + stack.push({ depth, title, ostType: '' }); + continue; + } + + // In ost_on_a_page mode, enforce the no-level-skip rule. + if (!isHybridMode && stack.length > 0) { + const topDepth = stack[stack.length - 1]!.depth; + if (depth > topDepth + 1) { + throw new Error( + `Heading level skipped: jumped from H${topDepth} to H${depth} at "${title}"`, + ); + } + } + + while (stack.length > 0 && stack[stack.length - 1]!.depth >= depth) { + stack.pop(); + } + + const type = inlineFields.type ?? anchorType ?? defaultOstType(stack); + const parentRef = currentParentRef(); + + const data: Record = { + title, + type, + status: DEFAULT_STATUS, + ...inlineFields, + }; + if (parentRef) data.parent = parentRef; + if (anchor) data.anchor = anchor; + + const headingNode: OstNode = { label: makeLabel(title), data }; + nodes.push(headingNode); + currentContextNode = headingNode; + stack.push({ depth, title, ostType: type }); + } else if (parseState !== 'active') { + diagnostics.preambleNodeCount++; + } else if (child.type === 'list') { + const parentRef = currentParentRef(); + for (const item of (child as List).children) { + processListItem(item, parentRef, currentContextNode, nodes, makeLabel, makeParentRef); + } + } else if (child.type === 'paragraph') { + const rawText = mdastToString(child); + const { cleanText: afterBracketed, fields: bracketedFields } = extractBracketedFields(rawText); + const { remainingText, fields: unbracketedFields } = extractUnbracketedFields(afterBracketed); + + const allFields = { ...unbracketedFields, ...bracketedFields }; + if ('type' in allFields) { + throw new Error( + `Type override via paragraph field is not supported at "${currentContextNode.data.title}". ` + + `Put [type:: ${allFields.type}] directly in the heading text.`, + ); + } + + Object.assign(currentContextNode.data, allFields); + if (remainingText) appendContent(currentContextNode, remainingText); + } else if (child.type === 'code') { + const code = child as Code; + if (code.lang?.trim() === 'yaml') { + const parsed = yamlLoad(code.value); + + if (Array.isArray(parsed)) { + throw new Error( + `YAML block must be an object (key-value properties for the current node), not an array. ` + + `Use typed bullets — e.g. "- [type:: solution] Title" — to define child nodes inline.`, + ); + } else if (parsed && typeof parsed === 'object') { + Object.assign(currentContextNode.data, parsed as Record); + } else { + appendContent(currentContextNode, code.value); + } + } else { + appendContent(currentContextNode, code.value); + } + } else { + const text = mdastToString(child); + appendContent(currentContextNode, text); + } + } + + return { nodes, diagnostics }; +} diff --git a/src/read-ost-page.ts b/src/read-ost-page.ts index 917a1db..56e3513 100644 --- a/src/read-ost-page.ts +++ b/src/read-ost-page.ts @@ -1,277 +1,43 @@ import { readFileSync } from 'node:fs'; +import { basename } from 'node:path'; import matter from 'gray-matter'; -import { load as yamlLoad } from 'js-yaml'; -import type { Code, Heading, List, ListItem, Paragraph, Root } from 'mdast'; -import { toString as mdastToString } from 'mdast-util-to-string'; -import remarkGfm from 'remark-gfm'; -import remarkParse from 'remark-parse'; -import { unified } from 'unified'; +import { extractEmbeddedNodes } from './parse-embedded.js'; import type { OstNode, OstPageReadResult } from './types.js'; -const OST_TYPES = ['vision', 'mission', 'goal', 'opportunity', 'solution'] as const; - -/** - * Returns the default OST type for a new heading based on its parent's effective type. - * The first heading in a document defaults to 'vision'; each child is the next in sequence. - */ -function defaultOstType(stack: StackEntry[]): string { - if (stack.length === 0) return OST_TYPES[0]!; - const parentType = stack[stack.length - 1]?.ostType; - const idx = OST_TYPES.indexOf(parentType as (typeof OST_TYPES)[number]); - if (idx === -1 || idx >= OST_TYPES.length - 1) { - throw new Error(`No OST type follows "${parentType}" — cannot determine type for child heading`); - } - return OST_TYPES[idx + 1]!; -} - -const DEFAULT_STATUS = 'identified'; - -/** Extract [key:: value] bracketed inline fields, return cleaned text and fields. */ -function extractBracketedFields(text: string): { - cleanText: string; - fields: Record; -} { - const fields: Record = {}; - const cleanText = text - .replace(/\[([^\]]+?):: *([^\]]*)\]/g, (_, key, value) => { - fields[key.trim()] = value.trim(); - return ''; - }) - .trim(); - return { cleanText, fields }; -} - -/** - * Extract unbracketed dataview fields (key:: value on own line). - * Keys must be identifier-style (letters, digits, hyphens, underscores — no spaces). - * Lines matching the pattern are consumed as fields; other lines kept as content. - */ -function extractUnbracketedFields(text: string): { - remainingText: string; - fields: Record; -} { - const fields: Record = {}; - const remaining: string[] = []; - - for (const line of text.split('\n')) { - const match = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*):: *(.*)$/); - if (match) { - fields[match[1]!.trim()] = match[2]!.trim(); - } else { - remaining.push(line); - } - } - - return { remainingText: remaining.join('\n').trim(), fields }; -} - -function appendContent(node: OstNode, text: string): void { - if (!text) return; - const existing = node.data.content as string | undefined; - node.data.content = existing ? `${existing}\n${text}` : text; -} - -/** - * Process a list item. Reads only the item's own paragraph (not nested lists) - * to extract the type and text. Typed items become nodes; untyped items append - * to contentTarget. Nested lists are processed recursively. - */ -function processListItem( - item: ListItem, - parentTitle: string | undefined, - contentTarget: OstNode, - nodes: OstNode[], -): void { - const firstPara = item.children.find((c) => c.type === 'paragraph') as Paragraph | undefined; - - if (!firstPara) { - // No paragraph child (e.g. a list that starts directly with a nested list). - // Recover by appending whatever text we can extract. - appendContent(contentTarget, `- ${mdastToString(item)}`); - return; - } - - const rawText = mdastToString(firstPara!); - const { cleanText, fields } = extractBracketedFields(rawText); - - if (fields.type) { - // Typed bullet → child node - const dashIdx = cleanText.indexOf(' - '); - const title = (dashIdx >= 0 ? cleanText.slice(0, dashIdx) : cleanText).trim(); - const summary = dashIdx >= 0 ? cleanText.slice(dashIdx + 3).trim() : undefined; - - const data: Record = { - title, - type: fields.type, - status: DEFAULT_STATUS, - ...fields, - }; - if (parentTitle) data.parent = `[[${parentTitle}]]`; - if (summary) data.summary = summary; - - const newNode: OstNode = { label: title, data }; - nodes.push(newNode); - - // Recursively process nested lists with newNode as both parent and content target - for (const child of item.children) { - if (child.type === 'list') { - for (const subItem of (child as List).children) { - processListItem(subItem, title, newNode, nodes); - } - } - } - } else { - // Untyped bullet → append to content target - appendContent(contentTarget, `- ${rawText}`); - } -} - -interface StackEntry { - depth: number; - title: string; - ostType: string; -} - export function readOstPage(filePath: string): OstPageReadResult { const raw = readFileSync(filePath, 'utf-8'); const { data: frontmatter, content: body } = matter(raw); - const nodes: OstNode[] = []; - - // Internal preamble content target — not returned as a node. - const rootNode: OstNode = { - label: filePath, - data: { ...frontmatter, type: 'ost_on_a_page' }, - }; - - const tree = unified().use(remarkParse).use(remarkGfm).parse(body) as Root; + const pageType = frontmatter.type as string | undefined; + const isHybrid = pageType !== undefined && pageType !== 'ost_on_a_page'; - // Heading ancestry stack - const stack: StackEntry[] = []; + if (isHybrid) { + // Hybrid file: the page itself is an OST node, plus it may contain embedded children. + const pageTitle = basename(filePath, '.md'); - // Tracks the most recent heading node; paragraphs and untyped bullets target this, - // not nodes[last] which may be a typed bullet child. - let currentContextNode: OstNode = rootNode; - - // 'preamble': before the first OST heading — non-heading content is ignored. - // 'active': inside the OST tree — content is parsed normally. - // 'done': after a thematic break (---) during active parsing — everything ignored. - type ParseState = 'preamble' | 'active' | 'done'; - let parseState: ParseState = 'preamble'; + const fileNode: OstNode = { + label: basename(filePath), + data: { title: pageTitle, ...frontmatter }, + }; - const diagnostics = { - preambleNodeCount: 0, - terminatedHeadings: [] as string[], - }; + const { nodes: embeddedNodes, diagnostics } = extractEmbeddedNodes(body, { + pageTitle, + pageType, + labelPrefix: '', + }); - function currentParentTitle(): string | undefined { - return stack.length > 0 ? stack[stack.length - 1]?.title : undefined; + return { + nodes: [fileNode, ...embeddedNodes], + diagnostics, + }; } - for (const child of tree.children) { - if (parseState === 'done') { - if (child.type === 'heading') { - const rawTitle = mdastToString(child!); - const { cleanText: title } = extractBracketedFields(rawTitle); - diagnostics.terminatedHeadings.push(title); - } - continue; - } - - if (child.type === 'thematicBreak') { - if (parseState === 'active') parseState = 'done'; - continue; - } - - if (child.type === 'heading') { - const heading = child as Heading; - const depth = heading.depth; - if (depth > 5) continue; - - parseState = 'active'; - - if (stack.length > 0) { - const topDepth = stack[stack.length - 1]!.depth; - if (depth > topDepth + 1) { - const rawTitle = mdastToString(heading); - throw new Error(`Heading level skipped: jumped from H${topDepth} to H${depth} at "${rawTitle}"`); - } - } - - while (stack.length > 0 && stack[stack.length - 1]!.depth >= depth) { - stack.pop(); - } - - const rawText = mdastToString(heading); - const { cleanText: title, fields: inlineFields } = extractBracketedFields(rawText); - const type = inlineFields.type ?? defaultOstType(stack); - const parentTitle = currentParentTitle(); - - const data: Record = { - title, - type, - status: DEFAULT_STATUS, - ...inlineFields, - }; - if (parentTitle) data.parent = `[[${parentTitle}]]`; - - const headingNode: OstNode = { label: title, data }; - nodes.push(headingNode); - currentContextNode = headingNode; - stack.push({ depth, title, ostType: type }); - } else if (parseState !== 'active') { - // Preamble content — ignore - diagnostics.preambleNodeCount++; - } else if (child.type === 'list') { - const parentTitle = currentParentTitle(); - for (const item of (child as List).children) { - processListItem(item, parentTitle, currentContextNode, nodes); - } - } else if (child.type === 'paragraph') { - const rawText = mdastToString(child!); - - // Extract bracketed fields first, then unbracketed fields from remaining text - const { cleanText: afterBracketed, fields: bracketedFields } = extractBracketedFields(rawText); - const { remainingText, fields: unbracketedFields } = extractUnbracketedFields(afterBracketed); - - const allFields = { ...unbracketedFields, ...bracketedFields }; - if ('type' in allFields) { - throw new Error( - `Type override via paragraph field is not supported at "${currentContextNode.data.title}". ` + - `Put [type:: ${allFields.type}] directly in the heading text.`, - ); - } - - Object.assign(currentContextNode.data, allFields); - if (remainingText) appendContent(currentContextNode, remainingText); - } else if (child.type === 'code') { - const code = child as Code; - if (code.lang?.trim() === 'yaml') { - const parsed = yamlLoad(code.value); - - if (Array.isArray(parsed)) { - throw new Error( - `YAML block must be an object (key-value properties for the current node), not an array. ` + - `Use typed bullets — e.g. "- [type:: solution] Title" — to define child nodes inline.`, - ); - } else if (parsed && typeof parsed === 'object') { - // Object dict → merge as properties into current node - Object.assign(currentContextNode.data, parsed as Record); - } else { - // Scalar or null YAML — preserve raw value as content - appendContent(currentContextNode, code.value); - } - } else { - // Non-YAML code block — treat as content - appendContent(currentContextNode, code.value); - } - } else { - // Any other node type (blockquote, table, html, image, etc.) — - // extract whatever text is available and preserve it as content - const text = mdastToString(child); - appendContent(currentContextNode, text); - } - } + // Classic ost_on_a_page (or no type): entire body is the OST structure. + const { nodes, diagnostics } = extractEmbeddedNodes(body, { + pageTitle: undefined, + pageType: undefined, + labelPrefix: '', + }); return { nodes, diagnostics }; } diff --git a/src/read-space.ts b/src/read-space.ts index bdc2c15..93d971e 100644 --- a/src/read-space.ts +++ b/src/read-space.ts @@ -2,9 +2,10 @@ import { readFileSync } from 'node:fs'; import { basename, join } from 'node:path'; import { glob } from 'glob'; import matter from 'gray-matter'; +import { extractEmbeddedNodes } from './parse-embedded.js'; import type { OstNode, SpaceReadResult } from './types.js'; -export async function readSpace(directory: string, options?: { includePageFiles?: boolean }): Promise { +export async function readSpace(directory: string): Promise { const files = await glob('**/*.md', { cwd: directory, absolute: false }); const nodes: OstNode[] = []; const skipped: string[] = []; @@ -24,14 +25,31 @@ export async function readSpace(directory: string, options?: { includePageFiles? continue; } - if (parsed.data.type === 'ost_on_a_page' && !options?.includePageFiles) { - continue; - } + const pageType = parsed.data.type as string; + const fileBase = basename(file, '.md'); - nodes.push({ - label: file, - data: { title: basename(file, '.md'), ...parsed.data }, - }); + if (pageType === 'ost_on_a_page') { + // Container page: the file itself is not a node — extract embedded nodes from body. + const { nodes: embedded } = extractEmbeddedNodes(parsed.content, { + pageTitle: undefined, + pageType: 'ost_on_a_page', + labelPrefix: `${fileBase}#`, + }); + nodes.push(...embedded); + } else { + // Regular OST node page: add the file as a node, then extract any embedded children. + nodes.push({ + label: file, + data: { title: fileBase, ...parsed.data }, + }); + + const { nodes: embedded } = extractEmbeddedNodes(parsed.content, { + pageTitle: fileBase, + pageType, + labelPrefix: `${fileBase}#`, + }); + nodes.push(...embedded); + } } return { nodes, skipped, nonOst }; diff --git a/src/validate.ts b/src/validate.ts index 39098c8..4768546 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -13,6 +13,34 @@ interface ValidationResult { nonOst: string[]; } +/** + * Convert a node label to the key used in the reference index. + * + * Handles both plain file labels and compound embedded-node labels: + * "Personal Vision.md" → "Personal Vision" + * "Personal Vision.md#Our Mission" → "Personal Vision#Our Mission" + * "Our Mission" → "Our Mission" (standalone / ost_on_a_page) + */ +export function labelToKey(label: string): string { + const hashIdx = label.indexOf('#'); + if (hashIdx >= 0) { + return label.slice(0, hashIdx).replace(/\.md$/, '') + label.slice(hashIdx); + } + return label.replace(/\.md$/, ''); +} + +/** + * Extract the lookup key from a wikilink string such as: + * [[Personal Vision]] → "Personal Vision" + * [[Personal Vision#Our Mission]] → "Personal Vision#Our Mission" + * [[Personal Vision#^ourmission]] → "Personal Vision#^ourmission" + */ +function wikilinkToKey(wikilink: string): string { + // Strip surrounding quotes if present (YAML sometimes keeps them) + const cleaned = wikilink.replace(/^"|"$/g, ''); + return cleaned.slice(2, -2); +} + export async function validate(path: string, options: { schema: string }): Promise { const schema = JSON.parse(readFileSync(options.schema, 'utf-8')); const ajv = new Ajv(); @@ -25,9 +53,7 @@ export async function validate(path: string, options: { schema: string }): Promi if (statSync(path).isFile()) { ({ nodes } = readOstPage(path)); } else { - ({ nodes, skipped, nonOst } = await readSpace(path, { - includePageFiles: true, - })); + ({ nodes, skipped, nonOst } = await readSpace(path)); } const result: ValidationResult = { @@ -53,24 +79,33 @@ export async function validate(path: string, options: { schema: string }): Promi } } - // Build index of all node labels (without .md extension) - const nodeIndex = new Map(nodes.map((n) => [n.label.replace(/\.md$/, ''), n])); - - function extractWikilinkFilename(wikilink: string): string { - const cleaned = wikilink.replace(/^"|"$/g, ''); - return cleaned.slice(2, -2); + // Build index: primary key (title / filename#title) + anchor-based keys. + const nodeIndex = new Map(); + for (const n of nodes) { + const key = labelToKey(n.label); + nodeIndex.set(key, n); + + // Also index by anchor so [[File#^anchorname]] resolves correctly. + if (n.data.anchor) { + const hashIdx = n.label.indexOf('#'); + const fileKey = + hashIdx >= 0 + ? n.label.slice(0, hashIdx).replace(/\.md$/, '') + : n.label.replace(/\.md$/, ''); + nodeIndex.set(`${fileKey}#^${n.data.anchor}`, n); + } } for (const node of nodes) { const parent = node.data.parent as string | undefined; if (!parent) continue; - const parentFile = extractWikilinkFilename(parent); - if (!nodeIndex.has(parentFile)) { + const parentKey = wikilinkToKey(parent); + if (!nodeIndex.has(parentKey)) { result.refErrors.push({ file: node.label, parent: parent, - error: `Parent node "${parentFile}" not found`, + error: `Parent node "${parentKey}" not found`, }); } } diff --git a/tests/fixtures/hybrid-anchor-type.md b/tests/fixtures/hybrid-anchor-type.md new file mode 100644 index 0000000..7f09b3e --- /dev/null +++ b/tests/fixtures/hybrid-anchor-type.md @@ -0,0 +1,14 @@ +--- +type: vision +status: active +--- + +# Preamble (ignored) + +## Our Mission ^mission + +Mission content. + +## Another Goal ^goal1 + +Goal content with anchor-implied type. diff --git a/tests/fixtures/hybrid-page-valid.md b/tests/fixtures/hybrid-page-valid.md new file mode 100644 index 0000000..92aba7a --- /dev/null +++ b/tests/fixtures/hybrid-page-valid.md @@ -0,0 +1,15 @@ +--- +type: vision +status: active +summary: Standalone hybrid vision for readOstPage tests +--- + +Vision body content. + +## [type:: mission] The Mission ^missionanchor + +Mission body content. + +### [type:: goal] The Goal + +Goal body content. diff --git a/tests/fixtures/valid-ost/hybrid_solution.md b/tests/fixtures/valid-ost/hybrid_solution.md new file mode 100644 index 0000000..22be574 --- /dev/null +++ b/tests/fixtures/valid-ost/hybrid_solution.md @@ -0,0 +1,5 @@ +--- +type: solution +status: identified +parent: "[[hybrid_vision#Embedded Goal]]" +--- diff --git a/tests/fixtures/valid-ost/hybrid_vision.md b/tests/fixtures/valid-ost/hybrid_vision.md new file mode 100644 index 0000000..517856e --- /dev/null +++ b/tests/fixtures/valid-ost/hybrid_vision.md @@ -0,0 +1,15 @@ +--- +type: vision +status: active +summary: A hybrid vision page with embedded sub-nodes +--- + +The vision body content. + +## [type:: mission] Embedded Mission ^embmission + +The mission body content. + +### [type:: goal] Embedded Goal + +The goal body content. diff --git a/tests/read-ost-page.test.ts b/tests/read-ost-page.test.ts index 4a2951e..f4e4dea 100644 --- a/tests/read-ost-page.test.ts +++ b/tests/read-ost-page.test.ts @@ -1,12 +1,14 @@ import { beforeAll, describe, expect, it } from 'bun:test'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { readOstPage } from '../src/read-ost-page.js'; import type { OstPageReadResult } from '../src/types.js'; const VALID_PAGE = join(import.meta.dir, 'fixtures/on-a-page-valid.md'); const SKIP_PAGE = join(import.meta.dir, 'fixtures/on-a-page-heading-skip.md'); +const HYBRID_PAGE = join(import.meta.dir, 'fixtures/hybrid-page-valid.md'); +const HYBRID_ANCHOR_PAGE = join(import.meta.dir, 'fixtures/hybrid-anchor-type.md'); -describe('readOstPage - on-a-page-valid.md', () => { +describe('readOstPage - on-a-page-valid.md (ost_on_a_page)', () => { let result: OstPageReadResult; beforeAll(() => { @@ -115,3 +117,71 @@ describe('readOstPage - on-a-page-valid.md', () => { }); }); }); + +describe('readOstPage - hybrid-page-valid.md (type: vision with embedded nodes)', () => { + let result: OstPageReadResult; + + beforeAll(() => { + result = readOstPage(HYBRID_PAGE); + }); + + it('includes the file itself as the first node', () => { + const fileNode = result.nodes[0]; + expect(fileNode?.label).toBe(basename(HYBRID_PAGE)); + expect(fileNode?.data.type).toBe('vision'); + expect(fileNode?.data.title).toBe('hybrid-page-valid'); + }); + + it('includes embedded mission as a node', () => { + const node = result.nodes.find((n) => n.label === 'The Mission'); + expect(node?.data.type).toBe('mission'); + expect(node?.data.title).toBe('The Mission'); + }); + + it('sets parent of embedded mission to the vision file title', () => { + const node = result.nodes.find((n) => n.label === 'The Mission'); + expect(node?.data.parent).toBe('[[hybrid-page-valid]]'); + }); + + it('stores anchor on embedded mission node', () => { + const node = result.nodes.find((n) => n.label === 'The Mission'); + expect(node?.data.anchor).toBe('missionanchor'); + }); + + it('includes embedded goal as a node nested under the mission', () => { + const node = result.nodes.find((n) => n.label === 'The Goal'); + expect(node?.data.type).toBe('goal'); + expect(node?.data.parent).toBe('[[The Mission]]'); + }); + + it('returns 3 nodes total (file + 2 embedded)', () => { + expect(result.nodes).toHaveLength(3); + }); +}); + +describe('readOstPage - hybrid-anchor-type.md (anchor-implied type, no [type::])', () => { + let result: OstPageReadResult; + + beforeAll(() => { + result = readOstPage(HYBRID_ANCHOR_PAGE); + }); + + it('infers type "mission" from ^mission anchor', () => { + const node = result.nodes.find((n) => n.label === 'Our Mission'); + expect(node?.data.type).toBe('mission'); + }); + + it('infers type "goal" from ^goal1 anchor', () => { + const node = result.nodes.find((n) => n.label === 'Another Goal'); + expect(node?.data.type).toBe('goal'); + }); + + it('stores anchors on the nodes', () => { + expect(result.nodes.find((n) => n.label === 'Our Mission')?.data.anchor).toBe('mission'); + expect(result.nodes.find((n) => n.label === 'Another Goal')?.data.anchor).toBe('goal1'); + }); + + it('untyped H1 preamble heading is not included as a node', () => { + expect(result.nodes.map((n) => n.label)).not.toContain('Preamble (ignored)'); + }); +}); diff --git a/tests/read-space.test.ts b/tests/read-space.test.ts index c952f53..ce3f61b 100644 --- a/tests/read-space.test.ts +++ b/tests/read-space.test.ts @@ -7,18 +7,18 @@ const VALID_DIR = join(import.meta.dir, 'fixtures/valid-ost'); const INVALID_DIR = join(import.meta.dir, 'fixtures/invalid-ost'); describe('readSpace', () => { - describe('valid-ost directory (default options)', () => { + describe('valid-ost directory', () => { let result: SpaceReadResult; beforeAll(async () => { result = await readSpace(VALID_DIR); }); - it('returns 5 OST nodes', () => { - expect(result.nodes).toHaveLength(5); + it('returns 9 OST nodes (5 original + 1 hybrid_vision file + 2 embedded + 1 hybrid_solution)', () => { + expect(result.nodes).toHaveLength(9); }); - it('injects title from filename', () => { + it('injects title from filename for file-based nodes', () => { const vision = result.nodes.find((n) => n.label === 'Personal Vision.md'); expect(vision?.data.title).toBe('Personal Vision'); }); @@ -47,8 +47,9 @@ describe('readSpace', () => { expect(ts?.data.priority).toBe('p3'); }); - it('excludes Community OST.md by default (type: ost_on_a_page)', () => { + it('Community OST.md (ost_on_a_page with no body) contributes no nodes', () => { expect(result.nodes.every((n) => n.label !== 'Community OST.md')).toBe(true); + expect(result.nodes.every((n) => !n.label.startsWith('Community OST#'))).toBe(true); }); it('Community OST.md does not appear in skipped or nonOst', () => { @@ -57,10 +58,52 @@ describe('readSpace', () => { }); }); - it('includes ost_on_a_page nodes when includePageFiles is true', async () => { - const result = await readSpace(VALID_DIR, { includePageFiles: true }); - expect(result.nodes.find((n) => n.label === 'Community OST.md')).toBeDefined(); - expect(result.nodes).toHaveLength(6); + describe('hybrid page support', () => { + let result: SpaceReadResult; + + beforeAll(async () => { + result = await readSpace(VALID_DIR); + }); + + it('includes hybrid_vision.md as its own node', () => { + const node = result.nodes.find((n) => n.label === 'hybrid_vision.md'); + expect(node).toBeDefined(); + expect(node?.data.type).toBe('vision'); + expect(node?.data.title).toBe('hybrid_vision'); + }); + + it('extracts embedded mission with compound label', () => { + const node = result.nodes.find((n) => n.label === 'hybrid_vision.md#Embedded Mission'); + expect(node).toBeDefined(); + expect(node?.data.type).toBe('mission'); + expect(node?.data.title).toBe('Embedded Mission'); + }); + + it('embedded mission parent points to the vision file', () => { + const node = result.nodes.find((n) => n.label === 'hybrid_vision.md#Embedded Mission'); + expect(node?.data.parent).toBe('[[hybrid_vision]]'); + }); + + it('stores anchor on embedded mission node', () => { + const node = result.nodes.find((n) => n.label === 'hybrid_vision.md#Embedded Mission'); + expect(node?.data.anchor).toBe('embmission'); + }); + + it('extracts nested embedded goal with compound label', () => { + const node = result.nodes.find((n) => n.label === 'hybrid_vision.md#Embedded Goal'); + expect(node).toBeDefined(); + expect(node?.data.type).toBe('goal'); + }); + + it('embedded goal parent points to the embedded mission via filename#title', () => { + const node = result.nodes.find((n) => n.label === 'hybrid_vision.md#Embedded Goal'); + expect(node?.data.parent).toBe('[[hybrid_vision#Embedded Mission]]'); + }); + + it('hybrid_solution.md references embedded goal as parent', () => { + const node = result.nodes.find((n) => n.label === 'hybrid_solution.md'); + expect(node?.data.parent).toBe('[[hybrid_vision#Embedded Goal]]'); + }); }); describe('invalid-ost directory', () => { diff --git a/tests/validate.test.ts b/tests/validate.test.ts index 436a79b..7c9b1bc 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -5,24 +5,43 @@ import Ajv from 'ajv'; import { readOstPage } from '../src/read-ost-page.js'; import { readSpace } from '../src/read-space.js'; import type { OstNode } from '../src/types.js'; +import { labelToKey } from '../src/validate.js'; const SCHEMA_PATH = join(import.meta.dir, '../schema.json'); const VALID_DIR = join(import.meta.dir, 'fixtures/valid-ost'); const INVALID_DIR = join(import.meta.dir, 'fixtures/invalid-ost'); const VALID_PAGE = join(import.meta.dir, 'fixtures/on-a-page-valid.md'); +const HYBRID_PAGE = join(import.meta.dir, 'fixtures/hybrid-page-valid.md'); const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf-8')); const ajv = new Ajv(); const validateNode = ajv.compile(schema); -// Inline ref-check helper — mirrors the logic in validate.ts +/** + * Inline ref-check helper — mirrors the logic in validate.ts. + * Handles plain labels, compound embedded-node labels (filename#title), + * and anchor-based wikilinks ([[File#^anchor]]). + */ function checkRefErrors(nodes: OstNode[]): Array<{ file: string; parent: string }> { - const titles = new Set(nodes.map((n) => n.label.replace(/\.md$/, ''))); + const index = new Set(nodes.map((n) => labelToKey(n.label))); + + // Also index by anchor so [[File#^anchorname]] resolves + for (const n of nodes) { + if (n.data.anchor) { + const hashIdx = n.label.indexOf('#'); + const fileKey = + hashIdx >= 0 + ? n.label.slice(0, hashIdx).replace(/\.md$/, '') + : n.label.replace(/\.md$/, ''); + index.add(`${fileKey}#^${n.data.anchor}`); + } + } + return nodes .filter((n) => n.data.parent) .filter((n) => { - const parentTitle = (n.data.parent as string).slice(2, -2); - return !titles.has(parentTitle); + const parentKey = (n.data.parent as string).slice(2, -2); + return !index.has(parentKey); }) .map((n) => ({ file: n.label, parent: n.data.parent as string })); } @@ -35,8 +54,8 @@ describe('Schema validation', () => { ({ nodes } = await readSpace(VALID_DIR)); }); - it('all 5 nodes pass schema validation', () => { - expect(nodes).toHaveLength(5); + it('all 9 nodes pass schema validation', () => { + expect(nodes).toHaveLength(9); for (const node of nodes) { expect(validateNode(node.data)).toBe(true); } @@ -62,6 +81,25 @@ describe('Schema validation', () => { }); }); + describe('hybrid-page-valid.md nodes (readOstPage on a hybrid file)', () => { + let nodes: OstNode[]; + + beforeAll(() => { + ({ nodes } = readOstPage(HYBRID_PAGE)); + }); + + it('all nodes pass schema validation', () => { + expect(nodes.length).toBeGreaterThan(0); + for (const node of nodes) { + expect(validateNode(node.data)).toBe(true); + } + }); + + it('has zero ref errors for internal refs in standalone context', () => { + expect(checkRefErrors(nodes)).toHaveLength(0); + }); + }); + describe('invalid-ost nodes (readSpace)', () => { let nodes: OstNode[]; @@ -93,6 +131,76 @@ describe('Schema validation', () => { }); }); + describe('labelToKey utility', () => { + it('strips .md extension from plain file labels', () => { + expect(labelToKey('Personal Vision.md')).toBe('Personal Vision'); + }); + + it('handles compound embedded node labels (filename.md#title)', () => { + expect(labelToKey('hybrid_vision.md#Embedded Mission')).toBe( + 'hybrid_vision#Embedded Mission', + ); + }); + + it('handles bare heading titles (no .md, no #)', () => { + expect(labelToKey('Personal Vision')).toBe('Personal Vision'); + }); + }); + + describe('ref resolution for anchor-based wikilinks', () => { + it('resolves [[filename#^anchor]] to a node with that anchor', () => { + const nodes: OstNode[] = [ + { + label: 'hybrid_vision.md#Embedded Mission', + data: { + title: 'Embedded Mission', + type: 'mission', + status: 'identified', + anchor: 'embmission', + parent: '[[hybrid_vision]]', + }, + }, + { + label: 'hybrid_vision.md', + data: { title: 'hybrid_vision', type: 'vision', status: 'active' }, + }, + { + label: 'some_goal.md', + data: { + title: 'some_goal', + type: 'goal', + status: 'identified', + parent: '[[hybrid_vision#^embmission]]', + }, + }, + ]; + + expect(checkRefErrors(nodes)).toHaveLength(0); + }); + + it('reports error when anchor-based wikilink points to nonexistent anchor', () => { + const nodes: OstNode[] = [ + { + label: 'hybrid_vision.md', + data: { title: 'hybrid_vision', type: 'vision', status: 'active' }, + }, + { + label: 'some_goal.md', + data: { + title: 'some_goal', + type: 'goal', + status: 'identified', + parent: '[[hybrid_vision#^noanchor]]', + }, + }, + ]; + + const errors = checkRefErrors(nodes); + expect(errors).toHaveLength(1); + expect(errors[0]?.parent).toBe('[[hybrid_vision#^noanchor]]'); + }); + }); + describe('schema shape assertions (inline data)', () => { it('accepts a valid vision node', () => { expect(validateNode({ title: 'My Vision', type: 'vision', status: 'active' })).toBe(true); @@ -145,5 +253,27 @@ describe('Schema validation', () => { }), ).toBe(false); }); + + it('accepts mission with filename#section wikilink as parent', () => { + expect( + validateNode({ + title: 'M', + type: 'mission', + status: 'active', + parent: '[[vision_page#Our Mission]]', + }), + ).toBe(true); + }); + + it('accepts goal with anchor-based wikilink as parent', () => { + expect( + validateNode({ + title: 'G', + type: 'goal', + status: 'active', + parent: '[[vision_page#^mission]]', + }), + ).toBe(true); + }); }); }); From 2112438d04ff48a5215ff07920a57d93432d309a Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Thu, 26 Feb 2026 20:15:21 +1100 Subject: [PATCH 2/7] fix: restore space/page separation and remove compound node labels - Restore readSpace to original: one node per file, ost_on_a_page files skipped (includePageFiles option retained). No embedded extraction in space mode, so a hybrid file is simply a regular typed node. - Remove labelPrefix from parse-embedded.ts; embedded nodes always get plain titles (e.g. "Our Mission"), not compound "filename#title" labels. - Update hybrid_solution.md fixture: parent now references the vision file directly ([[hybrid_vision]]) instead of a non-existent embedded node. - Update tests to reflect 7 nodes in valid-ost space (was 9 with embedded), rewrite hybrid section to assert no embedded extraction in space mode, and remove compound-label test cases from validate tests. Addresses owner feedback on issue #10. --- src/parse-embedded.ts | 14 ++--- src/read-ost-page.ts | 2 - src/read-space.ts | 37 ++++------- tests/fixtures/valid-ost/hybrid_solution.md | 2 +- tests/read-space.test.ts | 44 +++---------- tests/validate.test.ts | 69 ++------------------- 6 files changed, 30 insertions(+), 138 deletions(-) diff --git a/src/parse-embedded.ts b/src/parse-embedded.ts index 816140e..2a48cf9 100644 --- a/src/parse-embedded.ts +++ b/src/parse-embedded.ts @@ -168,12 +168,6 @@ export interface ExtractEmbeddedOptions { * type inference (classic ost_on_a_page behaviour). */ pageType?: string; - /** - * Prefix prepended to node labels: `labelPrefix + headingTitle`. - * In space context use e.g. `"filename#"` so labels become `"filename#Heading Title"`. - * In standalone context leave empty — labels are just heading titles. - */ - labelPrefix?: string; } export interface ExtractEmbeddedResult { @@ -191,7 +185,7 @@ export function extractEmbeddedNodes( body: string, options: ExtractEmbeddedOptions = {}, ): ExtractEmbeddedResult { - const { pageTitle, pageType, labelPrefix = '' } = options; + const { pageTitle, pageType } = options; const isHybridMode = pageType !== undefined && pageType !== 'ost_on_a_page'; const nodes: OstNode[] = []; @@ -218,7 +212,7 @@ export function extractEmbeddedNodes( }; function makeLabel(title: string): string { - return labelPrefix ? `${labelPrefix}${title}` : title; + return title; } /** @@ -235,13 +229,13 @@ export function extractEmbeddedNodes( return pageTitle ? `[[${pageTitle}]]` : undefined; } // An embedded heading is the parent - return labelPrefix ? `[[${labelPrefix}${entry.title}]]` : `[[${entry.title}]]`; + return `[[${entry.title}]]`; } return undefined; } function makeParentRef(title: string): string { - return labelPrefix ? `[[${labelPrefix}${title}]]` : `[[${title}]]`; + return `[[${title}]]`; } for (const child of tree.children) { diff --git a/src/read-ost-page.ts b/src/read-ost-page.ts index 56e3513..574c82a 100644 --- a/src/read-ost-page.ts +++ b/src/read-ost-page.ts @@ -23,7 +23,6 @@ export function readOstPage(filePath: string): OstPageReadResult { const { nodes: embeddedNodes, diagnostics } = extractEmbeddedNodes(body, { pageTitle, pageType, - labelPrefix: '', }); return { @@ -36,7 +35,6 @@ export function readOstPage(filePath: string): OstPageReadResult { const { nodes, diagnostics } = extractEmbeddedNodes(body, { pageTitle: undefined, pageType: undefined, - labelPrefix: '', }); return { nodes, diagnostics }; diff --git a/src/read-space.ts b/src/read-space.ts index 93d971e..788ee29 100644 --- a/src/read-space.ts +++ b/src/read-space.ts @@ -2,10 +2,12 @@ import { readFileSync } from 'node:fs'; import { basename, join } from 'node:path'; import { glob } from 'glob'; import matter from 'gray-matter'; -import { extractEmbeddedNodes } from './parse-embedded.js'; import type { OstNode, SpaceReadResult } from './types.js'; -export async function readSpace(directory: string): Promise { +export async function readSpace( + directory: string, + options?: { includePageFiles?: boolean }, +): Promise { const files = await glob('**/*.md', { cwd: directory, absolute: false }); const nodes: OstNode[] = []; const skipped: string[] = []; @@ -25,31 +27,14 @@ export async function readSpace(directory: string): Promise { continue; } - const pageType = parsed.data.type as string; - const fileBase = basename(file, '.md'); - - if (pageType === 'ost_on_a_page') { - // Container page: the file itself is not a node — extract embedded nodes from body. - const { nodes: embedded } = extractEmbeddedNodes(parsed.content, { - pageTitle: undefined, - pageType: 'ost_on_a_page', - labelPrefix: `${fileBase}#`, - }); - nodes.push(...embedded); - } else { - // Regular OST node page: add the file as a node, then extract any embedded children. - nodes.push({ - label: file, - data: { title: fileBase, ...parsed.data }, - }); - - const { nodes: embedded } = extractEmbeddedNodes(parsed.content, { - pageTitle: fileBase, - pageType, - labelPrefix: `${fileBase}#`, - }); - nodes.push(...embedded); + if (parsed.data.type === 'ost_on_a_page' && !options?.includePageFiles) { + continue; } + + nodes.push({ + label: file, + data: { title: basename(file, '.md'), ...parsed.data }, + }); } return { nodes, skipped, nonOst }; diff --git a/tests/fixtures/valid-ost/hybrid_solution.md b/tests/fixtures/valid-ost/hybrid_solution.md index 22be574..9fd5e74 100644 --- a/tests/fixtures/valid-ost/hybrid_solution.md +++ b/tests/fixtures/valid-ost/hybrid_solution.md @@ -1,5 +1,5 @@ --- type: solution status: identified -parent: "[[hybrid_vision#Embedded Goal]]" +parent: "[[hybrid_vision]]" --- diff --git a/tests/read-space.test.ts b/tests/read-space.test.ts index ce3f61b..88759af 100644 --- a/tests/read-space.test.ts +++ b/tests/read-space.test.ts @@ -14,8 +14,8 @@ describe('readSpace', () => { result = await readSpace(VALID_DIR); }); - it('returns 9 OST nodes (5 original + 1 hybrid_vision file + 2 embedded + 1 hybrid_solution)', () => { - expect(result.nodes).toHaveLength(9); + it('returns 7 OST nodes (5 original + hybrid_vision + hybrid_solution)', () => { + expect(result.nodes).toHaveLength(7); }); it('injects title from filename for file-based nodes', () => { @@ -47,9 +47,8 @@ describe('readSpace', () => { expect(ts?.data.priority).toBe('p3'); }); - it('Community OST.md (ost_on_a_page with no body) contributes no nodes', () => { + it('Community OST.md (ost_on_a_page) is excluded from nodes', () => { expect(result.nodes.every((n) => n.label !== 'Community OST.md')).toBe(true); - expect(result.nodes.every((n) => !n.label.startsWith('Community OST#'))).toBe(true); }); it('Community OST.md does not appear in skipped or nonOst', () => { @@ -58,51 +57,28 @@ describe('readSpace', () => { }); }); - describe('hybrid page support', () => { + describe('hybrid file support', () => { let result: SpaceReadResult; beforeAll(async () => { result = await readSpace(VALID_DIR); }); - it('includes hybrid_vision.md as its own node', () => { + it('includes hybrid_vision.md as a single node (no embedded extraction)', () => { const node = result.nodes.find((n) => n.label === 'hybrid_vision.md'); expect(node).toBeDefined(); expect(node?.data.type).toBe('vision'); expect(node?.data.title).toBe('hybrid_vision'); }); - it('extracts embedded mission with compound label', () => { - const node = result.nodes.find((n) => n.label === 'hybrid_vision.md#Embedded Mission'); - expect(node).toBeDefined(); - expect(node?.data.type).toBe('mission'); - expect(node?.data.title).toBe('Embedded Mission'); - }); - - it('embedded mission parent points to the vision file', () => { - const node = result.nodes.find((n) => n.label === 'hybrid_vision.md#Embedded Mission'); - expect(node?.data.parent).toBe('[[hybrid_vision]]'); + it('does not extract embedded nodes from hybrid files', () => { + expect(result.nodes.every((n) => !n.label.includes('#'))).toBe(true); }); - it('stores anchor on embedded mission node', () => { - const node = result.nodes.find((n) => n.label === 'hybrid_vision.md#Embedded Mission'); - expect(node?.data.anchor).toBe('embmission'); - }); - - it('extracts nested embedded goal with compound label', () => { - const node = result.nodes.find((n) => n.label === 'hybrid_vision.md#Embedded Goal'); - expect(node).toBeDefined(); - expect(node?.data.type).toBe('goal'); - }); - - it('embedded goal parent points to the embedded mission via filename#title', () => { - const node = result.nodes.find((n) => n.label === 'hybrid_vision.md#Embedded Goal'); - expect(node?.data.parent).toBe('[[hybrid_vision#Embedded Mission]]'); - }); - - it('hybrid_solution.md references embedded goal as parent', () => { + it('includes hybrid_solution.md with parent pointing to hybrid_vision', () => { const node = result.nodes.find((n) => n.label === 'hybrid_solution.md'); - expect(node?.data.parent).toBe('[[hybrid_vision#Embedded Goal]]'); + expect(node).toBeDefined(); + expect(node?.data.parent).toBe('[[hybrid_vision]]'); }); }); diff --git a/tests/validate.test.ts b/tests/validate.test.ts index 7c9b1bc..9f28a1b 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -19,8 +19,7 @@ const validateNode = ajv.compile(schema); /** * Inline ref-check helper — mirrors the logic in validate.ts. - * Handles plain labels, compound embedded-node labels (filename#title), - * and anchor-based wikilinks ([[File#^anchor]]). + * Handles plain labels (heading titles, filename.md) and wikilink parent refs. */ function checkRefErrors(nodes: OstNode[]): Array<{ file: string; parent: string }> { const index = new Set(nodes.map((n) => labelToKey(n.label))); @@ -54,8 +53,8 @@ describe('Schema validation', () => { ({ nodes } = await readSpace(VALID_DIR)); }); - it('all 9 nodes pass schema validation', () => { - expect(nodes).toHaveLength(9); + it('all 7 nodes pass schema validation', () => { + expect(nodes).toHaveLength(7); for (const node of nodes) { expect(validateNode(node.data)).toBe(true); } @@ -136,71 +135,11 @@ describe('Schema validation', () => { expect(labelToKey('Personal Vision.md')).toBe('Personal Vision'); }); - it('handles compound embedded node labels (filename.md#title)', () => { - expect(labelToKey('hybrid_vision.md#Embedded Mission')).toBe( - 'hybrid_vision#Embedded Mission', - ); - }); - - it('handles bare heading titles (no .md, no #)', () => { + it('handles bare heading titles (no .md)', () => { expect(labelToKey('Personal Vision')).toBe('Personal Vision'); }); }); - describe('ref resolution for anchor-based wikilinks', () => { - it('resolves [[filename#^anchor]] to a node with that anchor', () => { - const nodes: OstNode[] = [ - { - label: 'hybrid_vision.md#Embedded Mission', - data: { - title: 'Embedded Mission', - type: 'mission', - status: 'identified', - anchor: 'embmission', - parent: '[[hybrid_vision]]', - }, - }, - { - label: 'hybrid_vision.md', - data: { title: 'hybrid_vision', type: 'vision', status: 'active' }, - }, - { - label: 'some_goal.md', - data: { - title: 'some_goal', - type: 'goal', - status: 'identified', - parent: '[[hybrid_vision#^embmission]]', - }, - }, - ]; - - expect(checkRefErrors(nodes)).toHaveLength(0); - }); - - it('reports error when anchor-based wikilink points to nonexistent anchor', () => { - const nodes: OstNode[] = [ - { - label: 'hybrid_vision.md', - data: { title: 'hybrid_vision', type: 'vision', status: 'active' }, - }, - { - label: 'some_goal.md', - data: { - title: 'some_goal', - type: 'goal', - status: 'identified', - parent: '[[hybrid_vision#^noanchor]]', - }, - }, - ]; - - const errors = checkRefErrors(nodes); - expect(errors).toHaveLength(1); - expect(errors[0]?.parent).toBe('[[hybrid_vision#^noanchor]]'); - }); - }); - describe('schema shape assertions (inline data)', () => { it('accepts a valid vision node', () => { expect(validateNode({ title: 'My Vision', type: 'vision', status: 'active' })).toBe(true); From 35bacb4ffa0e28bb8452507a0e445720e94efee6 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Thu, 26 Feb 2026 20:21:55 +1100 Subject: [PATCH 3/7] fix: restore embedded extraction in space mode with plain node titles readSpace now correctly extracts embedded child nodes from hybrid typed pages (unchanged from original intent), while still skipping ost_on_a_page files as before. The previous commit incorrectly removed the extraction entirely. Embedded nodes get plain resolved titles ("Embedded Mission" not "hybrid_vision#Embedded Mission"), so parent refs use simple wikilinks: [[hybrid_vision]] for the file, [[Embedded Mission]] for the embedded node. Update hybrid_solution.md and tests accordingly. --- src/read-space.ts | 21 ++++++++--- tests/fixtures/valid-ost/hybrid_solution.md | 2 +- tests/read-space.test.ts | 39 ++++++++++++++++----- tests/validate.test.ts | 9 ++--- 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/read-space.ts b/src/read-space.ts index 788ee29..c043da9 100644 --- a/src/read-space.ts +++ b/src/read-space.ts @@ -2,12 +2,10 @@ import { readFileSync } from 'node:fs'; import { basename, join } from 'node:path'; import { glob } from 'glob'; import matter from 'gray-matter'; +import { extractEmbeddedNodes } from './parse-embedded.js'; import type { OstNode, SpaceReadResult } from './types.js'; -export async function readSpace( - directory: string, - options?: { includePageFiles?: boolean }, -): Promise { +export async function readSpace(directory: string, options?: { includePageFiles?: boolean }): Promise { const files = await glob('**/*.md', { cwd: directory, absolute: false }); const nodes: OstNode[] = []; const skipped: string[] = []; @@ -31,10 +29,23 @@ export async function readSpace( continue; } + const pageType = parsed.data.type as string; + const fileBase = basename(file, '.md'); + nodes.push({ label: file, - data: { title: basename(file, '.md'), ...parsed.data }, + data: { title: fileBase, ...parsed.data }, }); + + // Extract embedded child nodes from the page body (hybrid typed pages). + // ost_on_a_page files are already excluded above. + if (pageType !== 'ost_on_a_page') { + const { nodes: embedded } = extractEmbeddedNodes(parsed.content, { + pageTitle: fileBase, + pageType, + }); + nodes.push(...embedded); + } } return { nodes, skipped, nonOst }; diff --git a/tests/fixtures/valid-ost/hybrid_solution.md b/tests/fixtures/valid-ost/hybrid_solution.md index 9fd5e74..2feb8f4 100644 --- a/tests/fixtures/valid-ost/hybrid_solution.md +++ b/tests/fixtures/valid-ost/hybrid_solution.md @@ -1,5 +1,5 @@ --- type: solution status: identified -parent: "[[hybrid_vision]]" +parent: "[[Embedded Goal]]" --- diff --git a/tests/read-space.test.ts b/tests/read-space.test.ts index 88759af..d10af03 100644 --- a/tests/read-space.test.ts +++ b/tests/read-space.test.ts @@ -14,8 +14,8 @@ describe('readSpace', () => { result = await readSpace(VALID_DIR); }); - it('returns 7 OST nodes (5 original + hybrid_vision + hybrid_solution)', () => { - expect(result.nodes).toHaveLength(7); + it('returns 9 OST nodes (5 original + hybrid_vision + 2 embedded + hybrid_solution)', () => { + expect(result.nodes).toHaveLength(9); }); it('injects title from filename for file-based nodes', () => { @@ -64,22 +64,45 @@ describe('readSpace', () => { result = await readSpace(VALID_DIR); }); - it('includes hybrid_vision.md as a single node (no embedded extraction)', () => { + it('includes hybrid_vision.md as its own node', () => { const node = result.nodes.find((n) => n.label === 'hybrid_vision.md'); expect(node).toBeDefined(); expect(node?.data.type).toBe('vision'); expect(node?.data.title).toBe('hybrid_vision'); }); - it('does not extract embedded nodes from hybrid files', () => { - expect(result.nodes.every((n) => !n.label.includes('#'))).toBe(true); + it('extracts embedded mission with plain title', () => { + const node = result.nodes.find((n) => n.label === 'Embedded Mission'); + expect(node).toBeDefined(); + expect(node?.data.type).toBe('mission'); + expect(node?.data.title).toBe('Embedded Mission'); }); - it('includes hybrid_solution.md with parent pointing to hybrid_vision', () => { - const node = result.nodes.find((n) => n.label === 'hybrid_solution.md'); - expect(node).toBeDefined(); + it('embedded mission parent points to the vision file', () => { + const node = result.nodes.find((n) => n.label === 'Embedded Mission'); expect(node?.data.parent).toBe('[[hybrid_vision]]'); }); + + it('stores anchor on embedded mission node', () => { + const node = result.nodes.find((n) => n.label === 'Embedded Mission'); + expect(node?.data.anchor).toBe('embmission'); + }); + + it('extracts nested embedded goal with plain title', () => { + const node = result.nodes.find((n) => n.label === 'Embedded Goal'); + expect(node).toBeDefined(); + expect(node?.data.type).toBe('goal'); + }); + + it('embedded goal parent points to the embedded mission by plain title', () => { + const node = result.nodes.find((n) => n.label === 'Embedded Goal'); + expect(node?.data.parent).toBe('[[Embedded Mission]]'); + }); + + it('hybrid_solution.md references embedded goal as parent by plain title', () => { + const node = result.nodes.find((n) => n.label === 'hybrid_solution.md'); + expect(node?.data.parent).toBe('[[Embedded Goal]]'); + }); }); describe('invalid-ost directory', () => { diff --git a/tests/validate.test.ts b/tests/validate.test.ts index 9f28a1b..cfd31d3 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -28,10 +28,7 @@ function checkRefErrors(nodes: OstNode[]): Array<{ file: string; parent: string for (const n of nodes) { if (n.data.anchor) { const hashIdx = n.label.indexOf('#'); - const fileKey = - hashIdx >= 0 - ? n.label.slice(0, hashIdx).replace(/\.md$/, '') - : n.label.replace(/\.md$/, ''); + const fileKey = hashIdx >= 0 ? n.label.slice(0, hashIdx).replace(/\.md$/, '') : n.label.replace(/\.md$/, ''); index.add(`${fileKey}#^${n.data.anchor}`); } } @@ -53,8 +50,8 @@ describe('Schema validation', () => { ({ nodes } = await readSpace(VALID_DIR)); }); - it('all 7 nodes pass schema validation', () => { - expect(nodes).toHaveLength(7); + it('all 9 nodes pass schema validation', () => { + expect(nodes).toHaveLength(9); for (const node of nodes) { expect(validateNode(node.data)).toBe(true); } From 4ebd7e0d134f3661d2f36de2be677d67a222f01c Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Thu, 26 Feb 2026 21:49:36 +1100 Subject: [PATCH 4/7] Formatting --- src/parse-embedded.ts | 13 +++---------- src/validate.ts | 5 +---- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/parse-embedded.ts b/src/parse-embedded.ts index 2a48cf9..f3ea334 100644 --- a/src/parse-embedded.ts +++ b/src/parse-embedded.ts @@ -181,10 +181,7 @@ export interface ExtractEmbeddedResult { * Shared by both readOstPage (single-file) and readSpace (directory) to find * embedded sub-nodes within a page's content. */ -export function extractEmbeddedNodes( - body: string, - options: ExtractEmbeddedOptions = {}, -): ExtractEmbeddedResult { +export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptions = {}): ExtractEmbeddedResult { const { pageTitle, pageType } = options; const isHybridMode = pageType !== undefined && pageType !== 'ost_on_a_page'; @@ -197,9 +194,7 @@ export function extractEmbeddedNodes( // In hybrid mode: stack starts with the page's own virtual entry (depth 0). // In ost_on_a_page mode: stack starts empty (first heading has no parent). const stack: StackEntry[] = - isHybridMode && pageTitle !== undefined - ? [{ depth: 0, title: pageTitle, ostType: pageType }] - : []; + isHybridMode && pageTitle !== undefined ? [{ depth: 0, title: pageTitle, ostType: pageType }] : []; let currentContextNode: OstNode = rootNode; @@ -282,9 +277,7 @@ export function extractEmbeddedNodes( if (!isHybridMode && stack.length > 0) { const topDepth = stack[stack.length - 1]!.depth; if (depth > topDepth + 1) { - throw new Error( - `Heading level skipped: jumped from H${topDepth} to H${depth} at "${title}"`, - ); + throw new Error(`Heading level skipped: jumped from H${topDepth} to H${depth} at "${title}"`); } } diff --git a/src/validate.ts b/src/validate.ts index 4768546..ac78907 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -88,10 +88,7 @@ export async function validate(path: string, options: { schema: string }): Promi // Also index by anchor so [[File#^anchorname]] resolves correctly. if (n.data.anchor) { const hashIdx = n.label.indexOf('#'); - const fileKey = - hashIdx >= 0 - ? n.label.slice(0, hashIdx).replace(/\.md$/, '') - : n.label.replace(/\.md$/, ''); + const fileKey = hashIdx >= 0 ? n.label.slice(0, hashIdx).replace(/\.md$/, '') : n.label.replace(/\.md$/, ''); nodeIndex.set(`${fileKey}#^${n.data.anchor}`, n); } } From 7c1bbda0516a20ee561c82dd5ad0017589203d02 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Fri, 27 Feb 2026 08:33:19 +1100 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20rename=20readOstPage=E2=86=92re?= =?UTF-8?q?adOstOnAPage,=20add=20sourceFile=20for=20anchor=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - readOstOnAPage (was readOstPage) now strictly handles ost_on_a_page files; throws if given a typed node file. All callers updated. - OstPageReadResult → OstOnAPageReadResult in types.ts - includePageFiles → includeOnAPageFiles on readSpace options - OstNode gains sourceFile?: string; readSpace sets it on embedded nodes so validate can resolve [[file#^anchor]] parent refs - validate.ts indexes by data.title (resolved title) instead of labelToKey(label); anchor keys use "sourceFile#^anchor" pattern - isHybridMode renamed to isOnAPageMode with inverted logic in parse-embedded.ts - Fixture files renamed: hybrid_vision→vision_page, hybrid_solution→solution_page, hybrid-anchor-type→anchor_vision. "hybrid" terminology removed from all comments, test describes, and fixture references. - Tests: read-ost-page.test.ts → read-ost-on-a-page.test.ts (typed-file rejection test added). read-space.test.ts covers anchor-type inference and sourceFile assignment. validate.test.ts covers cross-file [[file#^anchor]] resolution with synthetic nodes. --- CLAUDE.md | 5 + README.md | 5 +- biome.json | 4 +- config.example.json | 2 +- package.json | 7 +- schema.json | 35 +----- smoke/spaces.test.ts | 27 +++++ src/diagram.ts | 4 +- src/dump.ts | 4 +- src/miro/sync.ts | 4 +- src/parse-embedded.ts | 20 ++-- src/read-ost-on-a-page.ts | 20 ++++ src/read-ost-page.ts | 41 ------- src/read-space.ts | 12 +- src/show.ts | 4 +- src/types.ts | 7 +- src/validate.ts | 39 ++----- tests/fixtures/hybrid-page-valid.md | 15 --- .../anchor_vision.md} | 0 .../{hybrid_solution.md => solution_page.md} | 0 .../{hybrid_vision.md => vision_page.md} | 2 +- ...age.test.ts => read-ost-on-a-page.test.ts} | 87 ++------------ tests/read-space.test.ts | 59 ++++++++-- tests/validate.test.ts | 109 ++++++++++++------ 24 files changed, 246 insertions(+), 266 deletions(-) create mode 100644 smoke/spaces.test.ts create mode 100644 src/read-ost-on-a-page.ts delete mode 100644 src/read-ost-page.ts delete mode 100644 tests/fixtures/hybrid-page-valid.md rename tests/fixtures/{hybrid-anchor-type.md => valid-ost/anchor_vision.md} (100%) rename tests/fixtures/valid-ost/{hybrid_solution.md => solution_page.md} (100%) rename tests/fixtures/valid-ost/{hybrid_vision.md => vision_page.md} (78%) rename tests/{read-ost-page.test.ts => read-ost-on-a-page.test.ts} (59%) diff --git a/CLAUDE.md b/CLAUDE.md index 590eedb..bcd9fc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,5 +24,10 @@ This project validates OST node markdown files against a JSON schema. - `config.json` — Space registry (alias → absolute path) - `schema.json` — Entity type definitions and validation rules +## Testing + +- `bun test` — unit tests (fixtures in `tests/`) +- `bun run smoke` — smoke tests that run `validate` against every space in `config.json` (`smoke/`) + ## Hooks A Stop hook runs linting, autoformatting and tests. If it reports issues related to change you made, address them. \ No newline at end of file diff --git a/README.md b/README.md index 3d832ea..5f2aa65 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,11 @@ bun run src/index.ts validate personal # Run diagram command bun run src/index.ts diagram personal -# Run tests, using a set of fixtures +# Run unit tests (fixtures in tests/) bun test + +# Run smoke tests against all configured spaces +bun run smoke ``` ## Schema diff --git a/biome.json b/biome.json index 950dc45..75cfbca 100644 --- a/biome.json +++ b/biome.json @@ -19,8 +19,8 @@ "rules": { "recommended": true, "style": { - "noNonNullAssertion": "off" - } + "noNonNullAssertion": "off" + } } }, "javascript": { diff --git a/config.example.json b/config.example.json index 43a5890..31d8ed9 100644 --- a/config.example.json +++ b/config.example.json @@ -8,4 +8,4 @@ } ], "templateDir": "/path/to/ProductX/Templates" -} \ No newline at end of file +} diff --git a/package.json b/package.json index f92c684..3e40162 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,10 @@ "scripts": { "validate": "bun run src/index.ts validate", "diagram": "bun run src/index.ts diagram", - "test": "bun test", - "lint": "biome check src/ tests/", - "lint:fix": "biome check --write src/ tests/" + "test": "bun test tests/", + "smoke": "bun test smoke/", + "lint": "biome check", + "lint:fix": "biome check --write" }, "devDependencies": { "@biomejs/biome": "^2.4.4", diff --git a/schema.json b/schema.json index de1762d..843b1fa 100644 --- a/schema.json +++ b/schema.json @@ -23,10 +23,7 @@ }, { "type": "object", - "allOf": [ - { "$ref": "#/$defs/baseNodeProps" }, - { "$ref": "#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "#/$defs/baseNodeProps" }, { "$ref": "#/$defs/ostEntityProps" }], "properties": { "type": { "const": "vision" } }, @@ -42,10 +39,7 @@ }, { "type": "object", - "allOf": [ - { "$ref": "#/$defs/baseNodeProps" }, - { "$ref": "#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "#/$defs/baseNodeProps" }, { "$ref": "#/$defs/ostEntityProps" }], "properties": { "type": { "const": "mission" }, "parent": { "$ref": "#/$defs/wikilink" } @@ -62,10 +56,7 @@ }, { "type": "object", - "allOf": [ - { "$ref": "#/$defs/baseNodeProps" }, - { "$ref": "#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "#/$defs/baseNodeProps" }, { "$ref": "#/$defs/ostEntityProps" }], "properties": { "type": { "const": "goal" }, "parent": { "$ref": "#/$defs/wikilink" }, @@ -84,10 +75,7 @@ }, { "type": "object", - "allOf": [ - { "$ref": "#/$defs/baseNodeProps" }, - { "$ref": "#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "#/$defs/baseNodeProps" }, { "$ref": "#/$defs/ostEntityProps" }], "properties": { "type": { "const": "opportunity" }, "parent": { "$ref": "#/$defs/wikilink" }, @@ -117,10 +105,7 @@ }, { "type": "object", - "allOf": [ - { "$ref": "#/$defs/baseNodeProps" }, - { "$ref": "#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "#/$defs/baseNodeProps" }, { "$ref": "#/$defs/ostEntityProps" }], "properties": { "type": { "const": "solution" }, "parent": { @@ -192,15 +177,7 @@ }, "status": { "type": "string", - "enum": [ - "identified", - "wondering", - "exploring", - "active", - "paused", - "completed", - "archived" - ] + "enum": ["identified", "wondering", "exploring", "active", "paused", "completed", "archived"] }, "priority": { "type": "string", diff --git a/smoke/spaces.test.ts b/smoke/spaces.test.ts new file mode 100644 index 0000000..d59a789 --- /dev/null +++ b/smoke/spaces.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'bun:test'; +import { join } from 'node:path'; +import { loadConfig } from '../src/config.js'; + +const ROOT = join(import.meta.dir, '..'); +const config = loadConfig(); + +describe('Smoke: validate all configured spaces', () => { + for (const space of config.spaces) { + it(`${space.alias} passes validation`, () => { + const result = Bun.spawnSync(['bun', 'run', 'src/index.ts', 'validate', space.alias], { + cwd: ROOT, + stdout: 'pipe', + stderr: 'pipe', + }); + + if (result.exitCode !== 0) { + const output = new TextDecoder().decode(result.stdout); + const errors = new TextDecoder().decode(result.stderr); + console.error(`\n--- ${space.alias} stdout ---\n${output}`); + if (errors) console.error(`--- ${space.alias} stderr ---\n${errors}`); + } + + expect(result.exitCode).toBe(0); + }); + } +}); diff --git a/src/diagram.ts b/src/diagram.ts index 4a8b80f..ce50446 100644 --- a/src/diagram.ts +++ b/src/diagram.ts @@ -1,6 +1,6 @@ import { readFileSync, statSync, writeFileSync } from 'node:fs'; import Ajv from 'ajv'; -import { readOstPage } from './read-ost-page.js'; +import { readOstOnAPage } from './read-ost-on-a-page.js'; import { readSpace } from './read-space.js'; import type { OstNode } from './types.js'; @@ -28,7 +28,7 @@ export async function diagram(path: string, options: { schema: string; output?: let nonOst: string[] = []; if (statSync(path).isFile()) { - ({ nodes: spaceNodes } = readOstPage(path)); + ({ nodes: spaceNodes } = readOstOnAPage(path)); } else { ({ nodes: spaceNodes, skipped, nonOst } = await readSpace(path)); } diff --git a/src/dump.ts b/src/dump.ts index 50c2063..c855bc5 100644 --- a/src/dump.ts +++ b/src/dump.ts @@ -1,10 +1,10 @@ import { statSync } from 'node:fs'; -import { readOstPage } from './read-ost-page.js'; +import { readOstOnAPage } from './read-ost-on-a-page.js'; import { readSpace } from './read-space.js'; export async function dump(path: string) { if (statSync(path).isFile()) { - const { nodes, diagnostics } = readOstPage(path); + const { nodes, diagnostics } = readOstOnAPage(path); console.log(JSON.stringify({ nodes, diagnostics }, null, 2)); } else { const { nodes, skipped, nonOst } = await readSpace(path); diff --git a/src/miro/sync.ts b/src/miro/sync.ts index 03f1fab..11ae1fd 100644 --- a/src/miro/sync.ts +++ b/src/miro/sync.ts @@ -1,6 +1,6 @@ import { statSync } from 'node:fs'; import { loadConfig, resolveSpacePath, updateSpaceField } from '../config.js'; -import { readOstPage } from '../read-ost-page.js'; +import { readOstOnAPage } from '../read-ost-on-a-page.js'; import { readSpace } from '../read-space.js'; import type { OstNode } from '../types.js'; import { computeMiroCardHash, computeNodeHash, loadCache, saveCache } from './cache.js'; @@ -56,7 +56,7 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi let nodes: OstNode[]; if (statSync(resolvedPath).isFile()) { - ({ nodes } = readOstPage(resolvedPath)); + ({ nodes } = readOstOnAPage(resolvedPath)); } else { ({ nodes } = await readSpace(resolvedPath)); } diff --git a/src/parse-embedded.ts b/src/parse-embedded.ts index f3ea334..fe56115 100644 --- a/src/parse-embedded.ts +++ b/src/parse-embedded.ts @@ -14,7 +14,7 @@ export const DEFAULT_STATUS = 'identified'; export interface StackEntry { depth: number; title: string; - /** Empty string marks an untyped heading placeholder (hybrid mode only). */ + /** Empty string marks an untyped heading placeholder (typed-page mode, i.e. not ost_on_a_page). */ ostType: string; } @@ -163,7 +163,7 @@ export interface ExtractEmbeddedOptions { /** * OST type of the containing page. * - If set to a real OST type (not 'ost_on_a_page'): only headings with an explicit - * `[type:: x]` field or an OST-type anchor become nodes (hybrid mode). + * `[type:: x]` field or an OST-type anchor become nodes (typed-page mode). * - If 'ost_on_a_page' or undefined: all headings become nodes with depth-based * type inference (classic ost_on_a_page behaviour). */ @@ -178,12 +178,12 @@ export interface ExtractEmbeddedResult { /** * Extract OST nodes from markdown body text. * - * Shared by both readOstPage (single-file) and readSpace (directory) to find - * embedded sub-nodes within a page's content. + * Shared by both readOstOnAPage (single ost_on_a_page file) and readSpace + * (directory) to find embedded sub-nodes within a page's content. */ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptions = {}): ExtractEmbeddedResult { const { pageTitle, pageType } = options; - const isHybridMode = pageType !== undefined && pageType !== 'ost_on_a_page'; + const isOnAPageMode = pageType === undefined || pageType === 'ost_on_a_page'; const nodes: OstNode[] = []; // Preamble/root content sink — never added to nodes @@ -191,10 +191,10 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio const tree = unified().use(remarkParse).use(remarkGfm).parse(body) as Root; - // In hybrid mode: stack starts with the page's own virtual entry (depth 0). + // In typed-page mode: stack starts with the page's own virtual entry (depth 0). // In ost_on_a_page mode: stack starts empty (first heading has no parent). const stack: StackEntry[] = - isHybridMode && pageTitle !== undefined ? [{ depth: 0, title: pageTitle, ostType: pageType }] : []; + !isOnAPageMode && pageTitle !== undefined ? [{ depth: 0, title: pageTitle, ostType: pageType }] : []; let currentContextNode: OstNode = rootNode; @@ -264,8 +264,8 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio const hasExplicitType = !!inlineFields.type; const hasImpliedType = !!anchorType; - if (isHybridMode && !hasExplicitType && !hasImpliedType) { - // Untyped heading in hybrid mode: update depth stack but don't create a node. + if (!isOnAPageMode && !hasExplicitType && !hasImpliedType) { + // Untyped heading in typed-page mode: update depth stack but don't create a node. while (stack.length > 0 && stack[stack.length - 1]!.depth >= depth) { stack.pop(); } @@ -274,7 +274,7 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio } // In ost_on_a_page mode, enforce the no-level-skip rule. - if (!isHybridMode && stack.length > 0) { + if (isOnAPageMode && stack.length > 0) { const topDepth = stack[stack.length - 1]!.depth; if (depth > topDepth + 1) { throw new Error(`Heading level skipped: jumped from H${topDepth} to H${depth} at "${title}"`); diff --git a/src/read-ost-on-a-page.ts b/src/read-ost-on-a-page.ts new file mode 100644 index 0000000..601b4a2 --- /dev/null +++ b/src/read-ost-on-a-page.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs'; +import matter from 'gray-matter'; +import { extractEmbeddedNodes } from './parse-embedded.js'; +import type { OstOnAPageReadResult } from './types.js'; + +export function readOstOnAPage(filePath: string): OstOnAPageReadResult { + const raw = readFileSync(filePath, 'utf-8'); + const { data: frontmatter, content: body } = matter(raw); + + const pageType = frontmatter.type as string | undefined; + if (pageType && pageType !== 'ost_on_a_page') { + throw new Error( + `Expected an ost_on_a_page file but got type "${pageType}" in ${filePath}. ` + + `Use a directory path to validate a space containing typed node files.`, + ); + } + + const { nodes, diagnostics } = extractEmbeddedNodes(body); + return { nodes, diagnostics }; +} diff --git a/src/read-ost-page.ts b/src/read-ost-page.ts deleted file mode 100644 index 574c82a..0000000 --- a/src/read-ost-page.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { basename } from 'node:path'; -import matter from 'gray-matter'; -import { extractEmbeddedNodes } from './parse-embedded.js'; -import type { OstNode, OstPageReadResult } from './types.js'; - -export function readOstPage(filePath: string): OstPageReadResult { - const raw = readFileSync(filePath, 'utf-8'); - const { data: frontmatter, content: body } = matter(raw); - - const pageType = frontmatter.type as string | undefined; - const isHybrid = pageType !== undefined && pageType !== 'ost_on_a_page'; - - if (isHybrid) { - // Hybrid file: the page itself is an OST node, plus it may contain embedded children. - const pageTitle = basename(filePath, '.md'); - - const fileNode: OstNode = { - label: basename(filePath), - data: { title: pageTitle, ...frontmatter }, - }; - - const { nodes: embeddedNodes, diagnostics } = extractEmbeddedNodes(body, { - pageTitle, - pageType, - }); - - return { - nodes: [fileNode, ...embeddedNodes], - diagnostics, - }; - } - - // Classic ost_on_a_page (or no type): entire body is the OST structure. - const { nodes, diagnostics } = extractEmbeddedNodes(body, { - pageTitle: undefined, - pageType: undefined, - }); - - return { nodes, diagnostics }; -} diff --git a/src/read-space.ts b/src/read-space.ts index c043da9..f07dc50 100644 --- a/src/read-space.ts +++ b/src/read-space.ts @@ -5,7 +5,10 @@ import matter from 'gray-matter'; import { extractEmbeddedNodes } from './parse-embedded.js'; import type { OstNode, SpaceReadResult } from './types.js'; -export async function readSpace(directory: string, options?: { includePageFiles?: boolean }): Promise { +export async function readSpace( + directory: string, + options?: { includeOnAPageFiles?: boolean }, +): Promise { const files = await glob('**/*.md', { cwd: directory, absolute: false }); const nodes: OstNode[] = []; const skipped: string[] = []; @@ -25,7 +28,7 @@ export async function readSpace(directory: string, options?: { includePageFiles? continue; } - if (parsed.data.type === 'ost_on_a_page' && !options?.includePageFiles) { + if (parsed.data.type === 'ost_on_a_page' && !options?.includeOnAPageFiles) { continue; } @@ -37,13 +40,16 @@ export async function readSpace(directory: string, options?: { includePageFiles? data: { title: fileBase, ...parsed.data }, }); - // Extract embedded child nodes from the page body (hybrid typed pages). + // Extract embedded child nodes from the page body (typed pages with embedded nodes). // ost_on_a_page files are already excluded above. if (pageType !== 'ost_on_a_page') { const { nodes: embedded } = extractEmbeddedNodes(parsed.content, { pageTitle: fileBase, pageType, }); + for (const node of embedded) { + node.sourceFile = fileBase; + } nodes.push(...embedded); } } diff --git a/src/show.ts b/src/show.ts index b1c36ac..a28fcfc 100644 --- a/src/show.ts +++ b/src/show.ts @@ -1,5 +1,5 @@ import { statSync } from 'node:fs'; -import { readOstPage } from './read-ost-page.js'; +import { readOstOnAPage } from './read-ost-on-a-page.js'; import { readSpace } from './read-space.js'; import type { OstNode } from './types.js'; @@ -7,7 +7,7 @@ export async function show(path: string) { let nodes: OstNode[]; if (statSync(path).isFile()) { - ({ nodes } = readOstPage(path)); + ({ nodes } = readOstOnAPage(path)); } else { ({ nodes } = await readSpace(path)); } diff --git a/src/types.ts b/src/types.ts index 103bc9e..81f9ae5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,11 @@ export interface OstNode { label: string; /** Schema-ready data: all fields including injected title */ data: Record; + /** + * For embedded nodes: the base filename (no .md) + * of the page they came from. Used to resolve [[file#^anchor]] parent refs. + */ + sourceFile?: string; } export interface OstPageDiagnostics { @@ -12,7 +17,7 @@ export interface OstPageDiagnostics { terminatedHeadings: string[]; } -export interface OstPageReadResult { +export interface OstOnAPageReadResult { nodes: OstNode[]; diagnostics: OstPageDiagnostics; } diff --git a/src/validate.ts b/src/validate.ts index ac78907..676f448 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,6 +1,6 @@ import { readFileSync, statSync } from 'node:fs'; import Ajv, { type ErrorObject } from 'ajv'; -import { readOstPage } from './read-ost-page.js'; +import { readOstOnAPage } from './read-ost-on-a-page.js'; import { readSpace } from './read-space.js'; import type { OstNode } from './types.js'; @@ -13,27 +13,11 @@ interface ValidationResult { nonOst: string[]; } -/** - * Convert a node label to the key used in the reference index. - * - * Handles both plain file labels and compound embedded-node labels: - * "Personal Vision.md" → "Personal Vision" - * "Personal Vision.md#Our Mission" → "Personal Vision#Our Mission" - * "Our Mission" → "Our Mission" (standalone / ost_on_a_page) - */ -export function labelToKey(label: string): string { - const hashIdx = label.indexOf('#'); - if (hashIdx >= 0) { - return label.slice(0, hashIdx).replace(/\.md$/, '') + label.slice(hashIdx); - } - return label.replace(/\.md$/, ''); -} - /** * Extract the lookup key from a wikilink string such as: * [[Personal Vision]] → "Personal Vision" * [[Personal Vision#Our Mission]] → "Personal Vision#Our Mission" - * [[Personal Vision#^ourmission]] → "Personal Vision#^ourmission" + * [[vision_page#^ourmission]] → "vision_page#^ourmission" */ function wikilinkToKey(wikilink: string): string { // Strip surrounding quotes if present (YAML sometimes keeps them) @@ -51,7 +35,7 @@ export async function validate(path: string, options: { schema: string }): Promi let nonOst: string[] = []; if (statSync(path).isFile()) { - ({ nodes } = readOstPage(path)); + ({ nodes } = readOstOnAPage(path)); } else { ({ nodes, skipped, nonOst } = await readSpace(path)); } @@ -79,17 +63,16 @@ export async function validate(path: string, options: { schema: string }): Promi } } - // Build index: primary key (title / filename#title) + anchor-based keys. + // Build index keyed by resolved title. + // File nodes: data.title is the filename without .md. + // Embedded nodes: data.title is the plain heading title. + // Anchor keys: "sourceFile#^anchor" for [[file#^anchor]] wikilinks. const nodeIndex = new Map(); for (const n of nodes) { - const key = labelToKey(n.label); - nodeIndex.set(key, n); - - // Also index by anchor so [[File#^anchorname]] resolves correctly. - if (n.data.anchor) { - const hashIdx = n.label.indexOf('#'); - const fileKey = hashIdx >= 0 ? n.label.slice(0, hashIdx).replace(/\.md$/, '') : n.label.replace(/\.md$/, ''); - nodeIndex.set(`${fileKey}#^${n.data.anchor}`, n); + nodeIndex.set(n.data.title as string, n); + + if (n.data.anchor && n.sourceFile) { + nodeIndex.set(`${n.sourceFile}#^${n.data.anchor}`, n); } } diff --git a/tests/fixtures/hybrid-page-valid.md b/tests/fixtures/hybrid-page-valid.md deleted file mode 100644 index 92aba7a..0000000 --- a/tests/fixtures/hybrid-page-valid.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -type: vision -status: active -summary: Standalone hybrid vision for readOstPage tests ---- - -Vision body content. - -## [type:: mission] The Mission ^missionanchor - -Mission body content. - -### [type:: goal] The Goal - -Goal body content. diff --git a/tests/fixtures/hybrid-anchor-type.md b/tests/fixtures/valid-ost/anchor_vision.md similarity index 100% rename from tests/fixtures/hybrid-anchor-type.md rename to tests/fixtures/valid-ost/anchor_vision.md diff --git a/tests/fixtures/valid-ost/hybrid_solution.md b/tests/fixtures/valid-ost/solution_page.md similarity index 100% rename from tests/fixtures/valid-ost/hybrid_solution.md rename to tests/fixtures/valid-ost/solution_page.md diff --git a/tests/fixtures/valid-ost/hybrid_vision.md b/tests/fixtures/valid-ost/vision_page.md similarity index 78% rename from tests/fixtures/valid-ost/hybrid_vision.md rename to tests/fixtures/valid-ost/vision_page.md index 517856e..0f1d1a3 100644 --- a/tests/fixtures/valid-ost/hybrid_vision.md +++ b/tests/fixtures/valid-ost/vision_page.md @@ -1,7 +1,7 @@ --- type: vision status: active -summary: A hybrid vision page with embedded sub-nodes +summary: A typed vision page with embedded sub-nodes --- The vision body content. diff --git a/tests/read-ost-page.test.ts b/tests/read-ost-on-a-page.test.ts similarity index 59% rename from tests/read-ost-page.test.ts rename to tests/read-ost-on-a-page.test.ts index f4e4dea..1e8e250 100644 --- a/tests/read-ost-page.test.ts +++ b/tests/read-ost-on-a-page.test.ts @@ -1,18 +1,16 @@ import { beforeAll, describe, expect, it } from 'bun:test'; -import { basename, join } from 'node:path'; -import { readOstPage } from '../src/read-ost-page.js'; -import type { OstPageReadResult } from '../src/types.js'; +import { join } from 'node:path'; +import { readOstOnAPage } from '../src/read-ost-on-a-page.js'; +import type { OstOnAPageReadResult } from '../src/types.js'; const VALID_PAGE = join(import.meta.dir, 'fixtures/on-a-page-valid.md'); const SKIP_PAGE = join(import.meta.dir, 'fixtures/on-a-page-heading-skip.md'); -const HYBRID_PAGE = join(import.meta.dir, 'fixtures/hybrid-page-valid.md'); -const HYBRID_ANCHOR_PAGE = join(import.meta.dir, 'fixtures/hybrid-anchor-type.md'); -describe('readOstPage - on-a-page-valid.md (ost_on_a_page)', () => { - let result: OstPageReadResult; +describe('readOstOnAPage - on-a-page-valid.md (ost_on_a_page)', () => { + let result: OstOnAPageReadResult; beforeAll(() => { - result = readOstPage(VALID_PAGE); + result = readOstOnAPage(VALID_PAGE); }); describe('heading type inference', () => { @@ -113,75 +111,14 @@ describe('readOstPage - on-a-page-valid.md (ost_on_a_page)', () => { describe('heading level skip error', () => { it('throws when heading level is skipped (H1 to H3)', () => { - expect(() => readOstPage(SKIP_PAGE)).toThrow(/Heading level skipped/); + expect(() => readOstOnAPage(SKIP_PAGE)).toThrow(/Heading level skipped/); }); }); -}); - -describe('readOstPage - hybrid-page-valid.md (type: vision with embedded nodes)', () => { - let result: OstPageReadResult; - - beforeAll(() => { - result = readOstPage(HYBRID_PAGE); - }); - - it('includes the file itself as the first node', () => { - const fileNode = result.nodes[0]; - expect(fileNode?.label).toBe(basename(HYBRID_PAGE)); - expect(fileNode?.data.type).toBe('vision'); - expect(fileNode?.data.title).toBe('hybrid-page-valid'); - }); - - it('includes embedded mission as a node', () => { - const node = result.nodes.find((n) => n.label === 'The Mission'); - expect(node?.data.type).toBe('mission'); - expect(node?.data.title).toBe('The Mission'); - }); - - it('sets parent of embedded mission to the vision file title', () => { - const node = result.nodes.find((n) => n.label === 'The Mission'); - expect(node?.data.parent).toBe('[[hybrid-page-valid]]'); - }); - - it('stores anchor on embedded mission node', () => { - const node = result.nodes.find((n) => n.label === 'The Mission'); - expect(node?.data.anchor).toBe('missionanchor'); - }); - - it('includes embedded goal as a node nested under the mission', () => { - const node = result.nodes.find((n) => n.label === 'The Goal'); - expect(node?.data.type).toBe('goal'); - expect(node?.data.parent).toBe('[[The Mission]]'); - }); - it('returns 3 nodes total (file + 2 embedded)', () => { - expect(result.nodes).toHaveLength(3); - }); -}); - -describe('readOstPage - hybrid-anchor-type.md (anchor-implied type, no [type::])', () => { - let result: OstPageReadResult; - - beforeAll(() => { - result = readOstPage(HYBRID_ANCHOR_PAGE); - }); - - it('infers type "mission" from ^mission anchor', () => { - const node = result.nodes.find((n) => n.label === 'Our Mission'); - expect(node?.data.type).toBe('mission'); - }); - - it('infers type "goal" from ^goal1 anchor', () => { - const node = result.nodes.find((n) => n.label === 'Another Goal'); - expect(node?.data.type).toBe('goal'); - }); - - it('stores anchors on the nodes', () => { - expect(result.nodes.find((n) => n.label === 'Our Mission')?.data.anchor).toBe('mission'); - expect(result.nodes.find((n) => n.label === 'Another Goal')?.data.anchor).toBe('goal1'); - }); - - it('untyped H1 preamble heading is not included as a node', () => { - expect(result.nodes.map((n) => n.label)).not.toContain('Preamble (ignored)'); + describe('typed file rejection', () => { + it('throws when given a typed node file instead of ost_on_a_page', () => { + const typedFile = join(import.meta.dir, 'fixtures/valid-ost/Personal Vision.md'); + expect(() => readOstOnAPage(typedFile)).toThrow(/Expected an ost_on_a_page file/); + }); }); }); diff --git a/tests/read-space.test.ts b/tests/read-space.test.ts index d10af03..554d93f 100644 --- a/tests/read-space.test.ts +++ b/tests/read-space.test.ts @@ -14,8 +14,8 @@ describe('readSpace', () => { result = await readSpace(VALID_DIR); }); - it('returns 9 OST nodes (5 original + hybrid_vision + 2 embedded + hybrid_solution)', () => { - expect(result.nodes).toHaveLength(9); + it('returns 12 OST nodes (5 original + vision_page + 2 embedded + solution_page + anchor_vision + 2 embedded)', () => { + expect(result.nodes).toHaveLength(12); }); it('injects title from filename for file-based nodes', () => { @@ -57,18 +57,18 @@ describe('readSpace', () => { }); }); - describe('hybrid file support', () => { + describe('embedded nodes in typed pages', () => { let result: SpaceReadResult; beforeAll(async () => { result = await readSpace(VALID_DIR); }); - it('includes hybrid_vision.md as its own node', () => { - const node = result.nodes.find((n) => n.label === 'hybrid_vision.md'); + it('includes vision_page.md as its own node', () => { + const node = result.nodes.find((n) => n.label === 'vision_page.md'); expect(node).toBeDefined(); expect(node?.data.type).toBe('vision'); - expect(node?.data.title).toBe('hybrid_vision'); + expect(node?.data.title).toBe('vision_page'); }); it('extracts embedded mission with plain title', () => { @@ -78,9 +78,9 @@ describe('readSpace', () => { expect(node?.data.title).toBe('Embedded Mission'); }); - it('embedded mission parent points to the vision file', () => { + it('embedded mission parent points to the containing page', () => { const node = result.nodes.find((n) => n.label === 'Embedded Mission'); - expect(node?.data.parent).toBe('[[hybrid_vision]]'); + expect(node?.data.parent).toBe('[[vision_page]]'); }); it('stores anchor on embedded mission node', () => { @@ -88,6 +88,11 @@ describe('readSpace', () => { expect(node?.data.anchor).toBe('embmission'); }); + it('sets sourceFile on embedded nodes', () => { + const node = result.nodes.find((n) => n.label === 'Embedded Mission'); + expect(node?.sourceFile).toBe('vision_page'); + }); + it('extracts nested embedded goal with plain title', () => { const node = result.nodes.find((n) => n.label === 'Embedded Goal'); expect(node).toBeDefined(); @@ -99,12 +104,46 @@ describe('readSpace', () => { expect(node?.data.parent).toBe('[[Embedded Mission]]'); }); - it('hybrid_solution.md references embedded goal as parent by plain title', () => { - const node = result.nodes.find((n) => n.label === 'hybrid_solution.md'); + it('solution_page.md references embedded goal as parent by plain title', () => { + const node = result.nodes.find((n) => n.label === 'solution_page.md'); expect(node?.data.parent).toBe('[[Embedded Goal]]'); }); }); + describe('anchor-implied type inference', () => { + let result: SpaceReadResult; + + beforeAll(async () => { + result = await readSpace(VALID_DIR); + }); + + it('infers type "mission" from ^mission anchor', () => { + const node = result.nodes.find((n) => n.label === 'Our Mission'); + expect(node?.data.type).toBe('mission'); + expect(node?.data.title).toBe('Our Mission'); + }); + + it('infers type "goal" from ^goal1 anchor', () => { + const node = result.nodes.find((n) => n.label === 'Another Goal'); + expect(node?.data.type).toBe('goal'); + expect(node?.data.title).toBe('Another Goal'); + }); + + it('stores anchors on anchor-typed nodes', () => { + expect(result.nodes.find((n) => n.label === 'Our Mission')?.data.anchor).toBe('mission'); + expect(result.nodes.find((n) => n.label === 'Another Goal')?.data.anchor).toBe('goal1'); + }); + + it('does not include untyped preamble heading as a node', () => { + expect(result.nodes.map((n) => n.label)).not.toContain('Preamble (ignored)'); + }); + + it('sets sourceFile on anchor-typed embedded nodes', () => { + const node = result.nodes.find((n) => n.label === 'Another Goal'); + expect(node?.sourceFile).toBe('anchor_vision'); + }); + }); + describe('invalid-ost directory', () => { it('returns all 3 nodes regardless of schema validity', async () => { const result = await readSpace(INVALID_DIR); diff --git a/tests/validate.test.ts b/tests/validate.test.ts index cfd31d3..98edecd 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -2,16 +2,14 @@ import { beforeAll, describe, expect, it } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import Ajv from 'ajv'; -import { readOstPage } from '../src/read-ost-page.js'; +import { readOstOnAPage } from '../src/read-ost-on-a-page.js'; import { readSpace } from '../src/read-space.js'; import type { OstNode } from '../src/types.js'; -import { labelToKey } from '../src/validate.js'; const SCHEMA_PATH = join(import.meta.dir, '../schema.json'); const VALID_DIR = join(import.meta.dir, 'fixtures/valid-ost'); const INVALID_DIR = join(import.meta.dir, 'fixtures/invalid-ost'); const VALID_PAGE = join(import.meta.dir, 'fixtures/on-a-page-valid.md'); -const HYBRID_PAGE = join(import.meta.dir, 'fixtures/hybrid-page-valid.md'); const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf-8')); const ajv = new Ajv(); @@ -19,17 +17,14 @@ const validateNode = ajv.compile(schema); /** * Inline ref-check helper — mirrors the logic in validate.ts. - * Handles plain labels (heading titles, filename.md) and wikilink parent refs. + * Indexes by data.title (resolved title). Anchor refs use node.sourceFile. */ function checkRefErrors(nodes: OstNode[]): Array<{ file: string; parent: string }> { - const index = new Set(nodes.map((n) => labelToKey(n.label))); + const index = new Set(nodes.map((n) => n.data.title as string)); - // Also index by anchor so [[File#^anchorname]] resolves for (const n of nodes) { - if (n.data.anchor) { - const hashIdx = n.label.indexOf('#'); - const fileKey = hashIdx >= 0 ? n.label.slice(0, hashIdx).replace(/\.md$/, '') : n.label.replace(/\.md$/, ''); - index.add(`${fileKey}#^${n.data.anchor}`); + if (n.data.anchor && n.sourceFile) { + index.add(`${n.sourceFile}#^${n.data.anchor}`); } } @@ -50,8 +45,8 @@ describe('Schema validation', () => { ({ nodes } = await readSpace(VALID_DIR)); }); - it('all 9 nodes pass schema validation', () => { - expect(nodes).toHaveLength(9); + it('all 12 nodes pass schema validation', () => { + expect(nodes).toHaveLength(12); for (const node of nodes) { expect(validateNode(node.data)).toBe(true); } @@ -62,11 +57,11 @@ describe('Schema validation', () => { }); }); - describe('on-a-page-valid.md nodes (readOstPage)', () => { + describe('on-a-page-valid.md nodes (readOstOnAPage)', () => { let nodes: OstNode[]; beforeAll(() => { - ({ nodes } = readOstPage(VALID_PAGE)); + ({ nodes } = readOstOnAPage(VALID_PAGE)); }); it('all nodes pass schema validation', () => { @@ -77,25 +72,6 @@ describe('Schema validation', () => { }); }); - describe('hybrid-page-valid.md nodes (readOstPage on a hybrid file)', () => { - let nodes: OstNode[]; - - beforeAll(() => { - ({ nodes } = readOstPage(HYBRID_PAGE)); - }); - - it('all nodes pass schema validation', () => { - expect(nodes.length).toBeGreaterThan(0); - for (const node of nodes) { - expect(validateNode(node.data)).toBe(true); - } - }); - - it('has zero ref errors for internal refs in standalone context', () => { - expect(checkRefErrors(nodes)).toHaveLength(0); - }); - }); - describe('invalid-ost nodes (readSpace)', () => { let nodes: OstNode[]; @@ -127,13 +103,70 @@ describe('Schema validation', () => { }); }); - describe('labelToKey utility', () => { - it('strips .md extension from plain file labels', () => { - expect(labelToKey('Personal Vision.md')).toBe('Personal Vision'); + describe('cross-file anchor ref resolution', () => { + it('resolves [[file#^anchor]] to the embedded node with that anchor', () => { + // Represents what readSpace produces from anchor_vision.md + a sibling file + const nodes: OstNode[] = [ + { + label: 'anchor_vision.md', + data: { title: 'anchor_vision', type: 'vision', status: 'active' }, + }, + { + label: 'Another Goal', + sourceFile: 'anchor_vision', + data: { + title: 'Another Goal', + type: 'goal', + status: 'identified', + anchor: 'goal1', + parent: '[[Our Mission]]', + }, + }, + { + label: 'Our Mission', + sourceFile: 'anchor_vision', + data: { + title: 'Our Mission', + type: 'mission', + status: 'identified', + anchor: 'mission', + parent: '[[anchor_vision]]', + }, + }, + { + label: 'some-solution.md', + data: { + title: 'some-solution', + type: 'solution', + status: 'identified', + parent: '[[anchor_vision#^goal1]]', + }, + }, + ]; + + expect(checkRefErrors(nodes)).toHaveLength(0); }); - it('handles bare heading titles (no .md)', () => { - expect(labelToKey('Personal Vision')).toBe('Personal Vision'); + it('reports error when anchor-based wikilink points to nonexistent anchor', () => { + const nodes: OstNode[] = [ + { + label: 'anchor_vision.md', + data: { title: 'anchor_vision', type: 'vision', status: 'active' }, + }, + { + label: 'some-solution.md', + data: { + title: 'some-solution', + type: 'solution', + status: 'identified', + parent: '[[anchor_vision#^noanchor]]', + }, + }, + ]; + + const errors = checkRefErrors(nodes); + expect(errors).toHaveLength(1); + expect(errors[0]?.parent).toBe('[[anchor_vision#^noanchor]]'); }); }); From 7b4b56ab16310795f80b37c693c497e48c6e0c56 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Fri, 27 Feb 2026 13:54:01 +1100 Subject: [PATCH 6/7] Tidy up testing setup and introduce concepts.md doc --- .claude/hooks/lint-and-test.sh | 2 +- .github/workflows/test.yml | 2 +- CLAUDE.md | 8 +- README.md | 23 ++--- docs/concepts.md | 150 +++++++++++++++++++++++++++++++++ package.json | 3 +- 6 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 docs/concepts.md diff --git a/.claude/hooks/lint-and-test.sh b/.claude/hooks/lint-and-test.sh index 93e8bc2..304b9f1 100755 --- a/.claude/hooks/lint-and-test.sh +++ b/.claude/hooks/lint-and-test.sh @@ -15,7 +15,7 @@ fi echo "" >&2 echo "→ Running tests..." >&2 -if ! bun test --only-failures; then +if ! bun run test --only-failures; then STATUS=1 fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b3a68c0..b7fcced 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,4 +15,4 @@ jobs: bun-version: latest - run: bun install --frozen-lockfile - run: bun run lint - - run: bun test + - run: bun run test diff --git a/CLAUDE.md b/CLAUDE.md index bcd9fc1..a2b3433 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,8 @@ Space aliases (e.g. `personal`, `politics`) are resolved via `config.json`. This project validates OST node markdown files against a JSON schema. +Before starting new work, review [docs/concepts.md](docs/concepts.md) for canonical terminology. Use and maintain the definitions there as the source of truth when naming things in code, tests, comments, and documentation. + ## Tooling - `gray-matter` - Parse YAML frontmatter from markdown @@ -26,8 +28,8 @@ This project validates OST node markdown files against a JSON schema. ## Testing -- `bun test` — unit tests (fixtures in `tests/`) -- `bun run smoke` — smoke tests that run `validate` against every space in `config.json` (`smoke/`) +- `bun run test` — unit tests (fixtures in `tests/`) +- `bun run test:smoke` — smoke tests that run `validate` against every space in `config.json` (`smoke/`) ## Hooks -A Stop hook runs linting, autoformatting and tests. If it reports issues related to change you made, address them. \ No newline at end of file +A Stop hook runs linting, autoformatting and unit tests. If it reports issues related to change you made, address them. \ No newline at end of file diff --git a/README.md b/README.md index 5f2aa65..2f92478 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ bun install ## Concepts -### Entities in OST +See [docs/concepts.md](docs/concepts.md) for the full terminology reference, including definitions of OST nodes, embedded nodes, spaces, schemas, rules, and more. -- **Vision**: The aspirational outcome at the top of a tree. -- **Mission**: Strategic direction supporting a vision. -- **Goal**: Concrete, measurable targets. -- **Opportunity**: Identified chance to make progress. -- **Solution**: Proposed approach to address an opportunity. -- **Dashboard**: Index node for organizing and displaying tree structure. + +### Schemas + +A JSON-schema file defines the set of entities that a space adheres to. This allows for customisation and extension. + +Currently a single schema is included that combines a basic vision/mission/goals hierarchy with a hierarchy _similar_ to Opportunity Solution Trees. It is designed to be a bit more flexible to allow rapid initial adoption. The plan is to extend the set of schemas available to include some more opinionated and strict examples, and to support composability. ### Spaces @@ -113,10 +113,13 @@ bun run src/index.ts validate personal bun run src/index.ts diagram personal # Run unit tests (fixtures in tests/) -bun test +bun run test + +# Run validation smoke tests against all locally configured spaces +bun run test:smoke -# Run smoke tests against all configured spaces -bun run smoke +# Run all tests +bun run test:all ``` ## Schema diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..1cfa2bc --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,150 @@ +# OST Tools: Concepts and Terminology + +This document is the canonical reference for concepts and terminology used in this project. It focuses on the meta-concepts the project supports, not the content of specific frameworks modelled in schemas. Before naming things in code, tests, comments, or documentation, check definitions here for consistency, and update them here when the project's "world view" changes, avoiding blurry terms as much as possible. + +--- + +## Space + +A **space** is a named collection of nodes organised according to a schema. Spaces are the primary unit of organisation — a space has a backing format (a `space directory` or an `OST on a page` file) and may be registered in `config.json` with an alias for convenient access. + +```json +{ "alias": "personal", "path": "/path/to/planning directory" } +``` + +A space carries optional configuration alongside its alias: schema path, template directory, and integration settings (e.g. Miro board ID). + +> The term "space" is preferred over "OST" or "tree" because the tooling is not limited to a specific framework, and future schemas may not be strictly tree-shaped. + +### Space directory + +A **space directory** is a directory of markdown files that backs a `space`. Each file may represent an `OST node`, embed child nodes in its body, or be an unrelated file that the tooling ignores. + +Parsing behaviour for a space directory: +- Files declaring an `OST node` type via frontmatter are included as nodes. +- Such files may also contain `embedded nodes` in their body, which are extracted and included. +- Files declaring a `tooling type` (e.g. `ost_on_a_page`, `dashboard`) are excluded from the node set. +- Files without frontmatter, or without a `type` field, are excluded from the node set. +- Non-markdown files are not scanned. + +### OST on a page + +**OST on a page** is a single-file backing format for a `space`. An entire planning tree is represented in one markdown document, using heading hierarchy, bullet point annotations, and `anchor` syntax. No separate per-node files are used. This format is most useful for the early development stages of a space, keeping information together in one file with less "boilerplate". + +A file in this format carries `type: ost_on_a_page` in its frontmatter. It is not itself an `OST node` — it is a container. + +Key properties: +- Heading hierarchy determines node depth and infers `OST node` type (depth-based type inference). +- Heading levels must not skip — each level must be exactly one deeper than its parent. +- A horizontal rule (`---`) terminates parsing; headings below it are ignored. + +> The name "OST on a page" may be revised as the tooling moves toward space-centric terminology — see [GitHub issue #22](https://github.com/mindsocket/ost-tools/issues/22). + +#### Preamble + +**Preamble** is content in an `OST on a page` document that appears before the first heading. It is parsed but discarded — not associated with any node. + +--- + +## OST node + +An **OST node** is a single entity in a `space` — a named, typed item defined in the schema. `OST nodes` are the primary content of a space. + +Node types are defined by the schema in use and may vary across schemas. Examples from the default schema: `vision`, `mission`, `goal`, `opportunity`, `solution`. The tooling is not prescriptive about which types exist — schemas are designed to be extended and replaced. + +> `ost_on_a_page` and `dashboard` are not `OST node` types — they are `tooling types`. + +> The "OST" prefix reflects the project's origins. As the tooling evolves toward broader planning support, this term may be revised — see [GitHub issue #22](https://github.com/mindsocket/ost-tools/issues/22). + +### Embedded node + +An **embedded node** is an `OST node` defined *within* a containing document rather than as its own file. Embedded nodes are declared using markdown heading syntax with inline field annotations (e.g. `[type:: goal]`) or `anchor-implied types`, and are extracted at parse time. + +A `typed page` may contain embedded nodes in its body. Those nodes become full members of the parsed node set, with `parent references` wired to their containing page or enclosing heading. + +### Type alias + +A **type alias** is an alternative name accepted in the `type` field for a given `OST node` type. Aliases allow teams to use their own vocabulary while still receiving schema validation. For example, a schema might accept `outcome` as an alias for `goal`. + +*(Type alias support is planned — see [GitHub issue #14](https://github.com/mindsocket/ost-tools/issues/14).)* + +--- + +## Typed page + +A **typed page** is a markdown file whose frontmatter declares an `OST node` type (e.g. `type: goal`). The file itself represents one node, and its body may additionally contain `embedded nodes`. + +Typed pages are distinct from `OST on a page` files: a typed page *is* an `OST node`; an `ost_on_a_page` file is merely a container. + +--- + +## Schema + +A **schema** defines the valid structure for `OST nodes` in a `space`: the fields, types, constraints, and descriptive `rules` for each entity type. A space uses the default schema unless a custom one is declared in its config. + +The schema handles structural validation. It does not encode qualitative or cross-node checks — those are handled by `rules`, which may be embedded within the schema or applied separately. + +Schemas are designed to be composable: shared building blocks (common field sets, scoring models, constraint overlays) can be referenced across schema files, letting teams tailor a schema without forking its foundations. *(Schema composability is under active development — see [GitHub issues #13](https://github.com/mindsocket/ost-tools/issues/13), [#17](https://github.com/mindsocket/ost-tools/issues/17).)* + +### Rules + +**Rules** are descriptive, and potentially executable, checks applied to `OST nodes` beyond what structural schema validation can express. Rules encode qualitative guidance and best practices alongside the schema, making them available to both tooling and agent skills. + +Rules may be: +- **Descriptive** — human-readable guidance, useful as documentation and as structured input to agent skills +- **Executable** — mechanically evaluable expressions (e.g. "no more than one `active` node of a given type at a time") +- **Quantitative** — numeric thresholds or counts applied to node sets +- **Stage-based** — triggered only when a node's `status` meets a condition +- **Qualitative** — checks on content and framing (e.g. ensuring an opportunity is stated in the user's voice, not as a business goal) +- **Cross-entity** — checks spanning multiple nodes or levels of the tree +- **Coherence** — verifying that statements across related nodes credibly support one another +- **Best-practice** — guidance encoded as checks (e.g. flagging solution-framing in problem descriptions) + +Rules are distinct from schema validation: the schema checks structure; rules check meaning and quality. + +*(Rules support is planned — see [GitHub issue #16](https://github.com/mindsocket/ost-tools/issues/16).)* + +--- + +## Tooling types + +**Tooling types** are `type` values recognised by the schema and tooling but not treated as `OST nodes`. They serve organisational or display purposes: + +- **`ost_on_a_page`** — a container file for an `OST on a page`. Not itself a node. +- **`dashboard`** — a summary view for a `space directory`. Conceptually similar to `OST on a page` in that it presents a high-level, single-document view of a space — but rather than defining the space, it reflects it, querying and assembling information from the space's node files. Useful after a space has "graduated" from a single `OST on a page` file to a `space directory`, as a way to preserve that top-level overview. The dashboard concept may evolve to surface more operational information over time, but there is no concrete design for that yet. + +--- + +## Parent reference + +A **parent reference** is the `parent` field on an `OST node` — a `wikilink` pointing to the node's direct parent in the tree. Root-level node types (such as `vision` in the default schema) carry no parent. Other node types carry one optionally, allowing for orphaned nodes — useful while drafting a tree or when explicitly capturing ideas like "solutions looking for a problem". + +Parent references are validated during ref-checking: each `parent` wikilink must resolve to a known node title in the parsed node set. + +### Wikilink + +A **wikilink** is the `[[Title]]` linking syntax (compatible with Obsidian) used to express `parent references` between `OST nodes`. The `parent` field of a node holds a wikilink to its parent. + +Two forms are supported: + +| Form | Example | Resolves to | +|---|---|---| +| Plain title | `[[My Goal]]` | The `OST node` whose title equals `My Goal` | +| Anchor ref | `[[vision_page#^goal1]]` | The `embedded node` with `anchor` `goal1` inside `vision_page.md` | + +### Anchor + +An **anchor** is a block anchor (e.g. `^goal1`) appended to a heading in a `typed page`, using Obsidian block anchor syntax. Anchors serve two purposes: + +1. **Cross-file references** — other files can reference an `embedded node` by `[[filename#^anchor]]`. +2. **Anchor-implied type** — if the anchor name matches a node type name or a node type name followed by digits (e.g. `^mission`, `^goal1`), the node's type is inferred from the anchor, making an explicit inline annotation unnecessary. + +--- + +## Status + +**Status** is a lifecycle field on `OST nodes` indicating a node's current stage. The valid values and their semantics are defined by the schema in use. Examples from the default schema (in rough progression): + +`identified` → `wondering` → `exploring` → `active` → `paused` → `completed` → `archived` + +Status is required on all `OST node` types at _validation_ time. Note however that currently the `On A Page` parser chooses to apply a default. diff --git a/package.json b/package.json index 3e40162..5a84cef 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "validate": "bun run src/index.ts validate", "diagram": "bun run src/index.ts diagram", "test": "bun test tests/", - "smoke": "bun test smoke/", + "test:smoke": "bun test smoke/", + "test:all": "bun test", "lint": "biome check", "lint:fix": "biome check --write" }, From 3a692b511327373c0c735f1299920290875ed8c3 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Fri, 27 Feb 2026 14:43:42 +1100 Subject: [PATCH 7/7] refactor parent link resolution and node navigation targets --- src/diagram.ts | 18 ++-- src/miro/cache.ts | 12 +-- src/miro/layout.ts | 4 +- src/miro/styles.ts | 14 +-- src/miro/sync.ts | 22 ++-- src/parse-embedded.ts | 112 ++++++++++++------- src/read-ost-on-a-page.ts | 6 +- src/read-space.ts | 8 +- src/resolve-links.ts | 60 +++++++++++ src/show.ts | 11 +- src/types.ts | 13 ++- src/validate.ts | 39 +++---- tests/fixtures/valid-ost/solution_page.md | 2 +- tests/fixtures/valid-ost/vision_page.md | 2 +- tests/parse-embedded.test.ts | 10 ++ tests/read-ost-on-a-page.test.ts | 43 ++++---- tests/read-space.test.ts | 71 ++++++------ tests/validate.test.ts | 125 ++++++++++++++-------- 18 files changed, 356 insertions(+), 216 deletions(-) create mode 100644 src/resolve-links.ts create mode 100644 tests/parse-embedded.test.ts diff --git a/src/diagram.ts b/src/diagram.ts index ce50446..d7005bd 100644 --- a/src/diagram.ts +++ b/src/diagram.ts @@ -12,12 +12,6 @@ interface DiagramNode { priority?: string; } -// Parse [[wikilink]] to just the text inside -function parseWikilink(wikilink: string): string { - const match = wikilink.match(/^\[\[(.+)\]\]$/); - return match ? match[1]! : wikilink; -} - export async function diagram(path: string, options: { schema: string; output?: string }): Promise { const schema = JSON.parse(readFileSync(options.schema, 'utf-8')); const ajv = new Ajv(); @@ -36,20 +30,20 @@ export async function diagram(path: string, options: { schema: string; output?: const invalid: string[] = []; for (const node of spaceNodes) { - const valid = validateFunc(node.data); + const valid = validateFunc(node.schemaData); if (!valid) { invalid.push(node.label); continue; } - const parent = node.data.parent ? parseWikilink(node.data.parent as string) : undefined; + const parent = node.resolvedParent; nodes.push({ - id: node.data.title as string, - type: node.data.type as string, - status: node.data.status as string, + id: node.schemaData.title as string, + type: node.schemaData.type as string, + status: node.schemaData.status as string, parent, - priority: node.data.priority as string | undefined, + priority: node.schemaData.priority as string | undefined, }); } diff --git a/src/miro/cache.ts b/src/miro/cache.ts index 7c25e06..05329f8 100644 --- a/src/miro/cache.ts +++ b/src/miro/cache.ts @@ -49,12 +49,12 @@ export function saveCache(cache: SyncCache): void { export function computeNodeHash(node: OstNode): string { const relevant = { - title: node.data.title, - type: node.data.type, - status: node.data.status, - summary: node.data.summary, - priority: node.data.priority, - parent: node.data.parent, + title: node.schemaData.title, + type: node.schemaData.type, + status: node.schemaData.status, + summary: node.schemaData.summary, + priority: node.schemaData.priority, + parent: node.resolvedParent ?? node.schemaData.parent, }; return createHash('sha256').update(JSON.stringify(relevant)).digest('hex').slice(0, 16); } diff --git a/src/miro/layout.ts b/src/miro/layout.ts index c069f64..02a6f7b 100644 --- a/src/miro/layout.ts +++ b/src/miro/layout.ts @@ -44,7 +44,7 @@ export function layoutNewCards( // Group new nodes by depth const byDepth = new Map(); for (const node of newNodes) { - const depth = TYPE_DEPTH[node.data.type as string] ?? 4; + const depth = TYPE_DEPTH[node.schemaData.type as string] ?? 4; if (!byDepth.has(depth)) byDepth.set(depth, []); byDepth.get(depth)?.push(node); } @@ -59,7 +59,7 @@ export function layoutNewCards( let x = -totalWidth / 2 + CARD_WIDTH / 2; for (const node of nodes) { - const title = node.data.title as string; + const title = node.schemaData.title as string; positions.set(title, { x, y: rowY }); x += CARD_WIDTH + H_GAP; } diff --git a/src/miro/styles.ts b/src/miro/styles.ts index b540556..5adf926 100644 --- a/src/miro/styles.ts +++ b/src/miro/styles.ts @@ -23,9 +23,9 @@ export function getCardColor(type: string): string { } export function buildCardTitle(node: OstNode): string { - const title = node.data.title as string; - const status = node.data.status as string | undefined; - const priority = node.data.priority as string | undefined; + const title = node.schemaData.title as string; + const status = node.schemaData.status as string | undefined; + const priority = node.schemaData.priority as string | undefined; const icon = status ? (STATUS_ICONS[status] ?? status) : ''; const prefix = icon ? `[${icon}] ` : ''; @@ -37,15 +37,15 @@ export function buildCardTitle(node: OstNode): string { export function buildCardDescription(node: OstNode): string { const parts: string[] = []; - const type = node.data.type as string; - const status = node.data.status as string | undefined; + const type = node.schemaData.type as string; + const status = node.schemaData.status as string | undefined; parts.push(`Type: ${type}`); if (status) parts.push(`Status: ${status}`); - const summary = node.data.summary as string | undefined; + const summary = node.schemaData.summary as string | undefined; if (summary) parts.push(`\n${summary}`); - const content = node.data.content as string | undefined; + const content = node.schemaData.content as string | undefined; if (content) parts.push(`\n${content}`); return parts.join('\n'); diff --git a/src/miro/sync.ts b/src/miro/sync.ts index 11ae1fd..c76fd22 100644 --- a/src/miro/sync.ts +++ b/src/miro/sync.ts @@ -14,11 +14,6 @@ interface SyncOptions { verbose?: boolean; } -function parseWikilink(wikilink: string): string { - const match = wikilink.match(/^\[\[(.+)\]\]$/); - return match ? match[1]! : wikilink; -} - export async function miroSync(spaceOrPath: string, options: SyncOptions): Promise { const token = process.env.MIRO_TOKEN; if (!token) { @@ -163,7 +158,7 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi let skippedCount = 0; for (const node of nodes) { - const title = node.data.title as string; + const title = node.schemaData.title as string; // Compute what we expect to be in Miro (using the same build functions) const expectedTitle = buildCardTitle(node); const expectedDesc = buildCardDescription(node); @@ -206,8 +201,8 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi // 7. Create new cards let createdCount = 0; for (const node of newNodes) { - const title = node.data.title as string; - const type = node.data.type as string; + const title = node.schemaData.title as string; + const type = node.schemaData.type as string; let pos = newPositions.get(title) ?? { x: 0, y: 0 }; // Apply offset if we created a new frame (to center layout in frame) @@ -251,7 +246,7 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi // 8. Update changed cards let updatedCount = 0; for (const { node, cardId } of updatedNodes) { - const title = node.data.title as string; + const title = node.schemaData.title as string; if (options.dryRun) { console.log(`[dry-run] Update card: "${title}"`); @@ -277,7 +272,7 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi if (e instanceof MiroNotFoundError) { // Card was deleted from Miro — recreate it console.log(`Card "${title}" missing from Miro, recreating...`); - const type = node.data.type as string; + const type = node.schemaData.type as string; const card = await client.createCard({ data: { title: buildCardTitle(node), @@ -308,10 +303,9 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi // Only include edges where both endpoints have verified cards on the board const desiredEdges = new Map(); for (const node of nodes) { - const parentRaw = node.data.parent as string | undefined; - if (!parentRaw) continue; - const parentTitle = parseWikilink(parentRaw); - const childTitle = node.data.title as string; + const parentTitle = node.resolvedParent; + if (!parentTitle) continue; + const childTitle = node.schemaData.title as string; // Both endpoints must have verified cards on the board if (verifiedCardIds.has(parentTitle) && verifiedCardIds.has(childTitle)) { const key = `${parentTitle}\u2192${childTitle}`; diff --git a/src/parse-embedded.ts b/src/parse-embedded.ts index fe56115..9d828c6 100644 --- a/src/parse-embedded.ts +++ b/src/parse-embedded.ts @@ -16,6 +16,8 @@ export interface StackEntry { title: string; /** Empty string marks an untyped heading placeholder (typed-page mode, i.e. not ost_on_a_page). */ ostType: string; + /** Preferred wikilink key used when this heading acts as a parent. */ + refTarget: string; } /** Extract [key:: value] bracketed inline fields, return cleaned text and fields. */ @@ -35,7 +37,7 @@ export function extractBracketedFields(text: string): { /** * Extract unbracketed dataview fields (key:: value on own line). - * Keys must be identifier-style (letters, digits, hyphens, underscores — no spaces). + * Keys must be identifier-style (letters, digits, hyphens, underscores - no spaces). * Lines matching the pattern are consumed as fields; other lines kept as content. */ export function extractUnbracketedFields(text: string): { @@ -59,7 +61,7 @@ export function extractUnbracketedFields(text: string): { /** * Extract a trailing Obsidian block anchor from heading text. - * e.g. "My Title ^anchor-id" → { cleanText: "My Title", anchor: "anchor-id" } + * e.g. "My Title ^anchor-id" -> { cleanText: "My Title", anchor: "anchor-id" } */ export function extractAnchor(text: string): { cleanText: string; anchor?: string } { const match = text.match(/\s+\^([a-zA-Z0-9][a-zA-Z0-9_-]*)$/); @@ -75,7 +77,7 @@ export function extractAnchor(text: string): { cleanText: string; anchor?: strin /** * If the anchor name exactly matches an OST type (or an OST type followed by digits), * return that type. Otherwise return undefined. - * Examples: "mission" → "mission", "goal1" → "goal", "myanchor" → undefined + * Examples: "mission" -> "mission", "goal1" -> "goal", "myanchor" -> undefined */ export function anchorToOstType(anchor: string): string | undefined { for (const type of OST_TYPES) { @@ -86,6 +88,18 @@ export function anchorToOstType(anchor: string): string | undefined { return undefined; } +/** + * Turn a full heading string into an Obsidian section-target key component. + * - normalizes observed Obsidian separators (#, ^, :, \) to spaces + * - compresses whitespace runs to single spaces + */ +export function normalizeHeadingSectionTarget(rawHeadingText: string): string { + return rawHeadingText + .replace(/[#^:\\]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + /** * Returns the default OST type for a new heading based on its parent's effective type. * The first heading in a document defaults to 'vision'; each child is the next in sequence. @@ -95,15 +109,15 @@ export function defaultOstType(stack: StackEntry[]): string { const parentType = stack[stack.length - 1]?.ostType; const idx = OST_TYPES.indexOf(parentType as OstType); if (idx === -1 || idx >= OST_TYPES.length - 1) { - throw new Error(`No OST type follows "${parentType}" — cannot determine type for child heading`); + throw new Error(`No OST type follows "${parentType}" - cannot determine type for child heading`); } return OST_TYPES[idx + 1]!; } function appendContent(node: OstNode, text: string): void { if (!text) return; - const existing = node.data.content as string | undefined; - node.data.content = existing ? `${existing}\n${text}` : text; + const existing = node.schemaData.content as string | undefined; + node.schemaData.content = existing ? `${existing}\n${text}` : text; } function processListItem( @@ -112,7 +126,7 @@ function processListItem( contentTarget: OstNode, nodes: OstNode[], makeLabel: (title: string) => string, - makeParentRef: (title: string) => string, + buildLinkTargets: (title: string) => string[], ): void { const firstPara = item.children.find((c) => c.type === 'paragraph') as Paragraph | undefined; @@ -129,23 +143,24 @@ function processListItem( const title = (dashIdx >= 0 ? cleanText.slice(0, dashIdx) : cleanText).trim(); const summary = dashIdx >= 0 ? cleanText.slice(dashIdx + 3).trim() : undefined; - const data: Record = { + const schemaData: Record = { title, type: fields.type, status: DEFAULT_STATUS, ...fields, }; - if (parentRef) data.parent = parentRef; - if (summary) data.summary = summary; + if (parentRef) schemaData.parent = parentRef; + if (summary) schemaData.summary = summary; - const newNode: OstNode = { label: makeLabel(title), data }; + const linkTargets = buildLinkTargets(title); + const newNode: OstNode = { label: makeLabel(title), schemaData, linkTargets }; nodes.push(newNode); - const nestedParentRef = makeParentRef(title); + const nestedParentRef = `[[${linkTargets[0] ?? title}]]`; for (const child of item.children) { if (child.type === 'list') { for (const subItem of (child as List).children) { - processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, makeParentRef); + processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, buildLinkTargets); } } } @@ -186,15 +201,17 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio const isOnAPageMode = pageType === undefined || pageType === 'ost_on_a_page'; const nodes: OstNode[] = []; - // Preamble/root content sink — never added to nodes - const rootNode: OstNode = { label: '_root_', data: { type: 'ost_on_a_page' } }; + // Preamble/root content sink - never added to nodes + const rootNode: OstNode = { label: '_root_', schemaData: { type: 'ost_on_a_page' }, linkTargets: [] }; const tree = unified().use(remarkParse).use(remarkGfm).parse(body) as Root; // In typed-page mode: stack starts with the page's own virtual entry (depth 0). // In ost_on_a_page mode: stack starts empty (first heading has no parent). const stack: StackEntry[] = - !isOnAPageMode && pageTitle !== undefined ? [{ depth: 0, title: pageTitle, ostType: pageType }] : []; + !isOnAPageMode && pageTitle !== undefined + ? [{ depth: 0, title: pageTitle, ostType: pageType, refTarget: pageTitle }] + : []; let currentContextNode: OstNode = rootNode; @@ -218,19 +235,35 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio function currentParentRef(): string | undefined { for (let i = stack.length - 1; i >= 0; i--) { const entry = stack[i]!; - if (entry.ostType === '') continue; // untyped placeholder - if (entry.depth === 0) { - // The page itself is the parent - return pageTitle ? `[[${pageTitle}]]` : undefined; - } - // An embedded heading is the parent - return `[[${entry.title}]]`; + if (entry.ostType === '') continue; + return `[[${entry.refTarget}]]`; } return undefined; } - function makeParentRef(title: string): string { - return `[[${title}]]`; + function buildHeadingLinkTargets(rawHeadingText: string, title: string, anchor?: string): string[] { + if (!pageTitle) { + return [title]; + } + + const targets: string[] = []; + + const sectionTarget = normalizeHeadingSectionTarget(rawHeadingText); + if (sectionTarget) { + targets.push(`${pageTitle}#${sectionTarget}`); + } + + if (anchor) { + targets.push(`${pageTitle}#^${anchor}`); + } + + return targets.length > 0 ? targets : [title]; + } + + function buildListItemLinkTargets(title: string): string[] { + if (!pageTitle) return [title]; + const normalized = normalizeHeadingSectionTarget(title); + return normalized ? [`${pageTitle}#${normalized}`] : [title]; } for (const child of tree.children) { @@ -269,7 +302,7 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio while (stack.length > 0 && stack[stack.length - 1]!.depth >= depth) { stack.pop(); } - stack.push({ depth, title, ostType: '' }); + stack.push({ depth, title, ostType: '', refTarget: title }); continue; } @@ -288,25 +321,27 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio const type = inlineFields.type ?? anchorType ?? defaultOstType(stack); const parentRef = currentParentRef(); - const data: Record = { + const schemaData: Record = { title, type, status: DEFAULT_STATUS, ...inlineFields, }; - if (parentRef) data.parent = parentRef; - if (anchor) data.anchor = anchor; + if (parentRef) schemaData.parent = parentRef; - const headingNode: OstNode = { label: makeLabel(title), data }; + const linkTargets = buildHeadingLinkTargets(rawText, title, anchor); + const headingNode: OstNode = { label: makeLabel(title), schemaData, linkTargets }; nodes.push(headingNode); currentContextNode = headingNode; - stack.push({ depth, title, ostType: type }); + + const refTarget = linkTargets[0] ?? title; + stack.push({ depth, title, ostType: type, refTarget }); } else if (parseState !== 'active') { diagnostics.preambleNodeCount++; } else if (child.type === 'list') { const parentRef = currentParentRef(); for (const item of (child as List).children) { - processListItem(item, parentRef, currentContextNode, nodes, makeLabel, makeParentRef); + processListItem(item, parentRef, currentContextNode, nodes, makeLabel, buildListItemLinkTargets); } } else if (child.type === 'paragraph') { const rawText = mdastToString(child); @@ -315,13 +350,14 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio const allFields = { ...unbracketedFields, ...bracketedFields }; if ('type' in allFields) { + const title = currentContextNode.schemaData.title as string | undefined; throw new Error( - `Type override via paragraph field is not supported at "${currentContextNode.data.title}". ` + + `Type override via paragraph field is not supported at "${title ?? currentContextNode.label}". ` + `Put [type:: ${allFields.type}] directly in the heading text.`, ); } - Object.assign(currentContextNode.data, allFields); + Object.assign(currentContextNode.schemaData, allFields); if (remainingText) appendContent(currentContextNode, remainingText); } else if (child.type === 'code') { const code = child as Code; @@ -331,10 +367,12 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio if (Array.isArray(parsed)) { throw new Error( `YAML block must be an object (key-value properties for the current node), not an array. ` + - `Use typed bullets — e.g. "- [type:: solution] Title" — to define child nodes inline.`, + `Use typed bullets - e.g. "- [type:: solution] Title" - to define child nodes inline.`, ); - } else if (parsed && typeof parsed === 'object') { - Object.assign(currentContextNode.data, parsed as Record); + } + + if (parsed && typeof parsed === 'object') { + Object.assign(currentContextNode.schemaData, parsed as Record); } else { appendContent(currentContextNode, code.value); } diff --git a/src/read-ost-on-a-page.ts b/src/read-ost-on-a-page.ts index 601b4a2..47f0e5f 100644 --- a/src/read-ost-on-a-page.ts +++ b/src/read-ost-on-a-page.ts @@ -1,6 +1,8 @@ import { readFileSync } from 'node:fs'; +import { basename } from 'node:path'; import matter from 'gray-matter'; import { extractEmbeddedNodes } from './parse-embedded.js'; +import { resolveParentLinks } from './resolve-links.js'; import type { OstOnAPageReadResult } from './types.js'; export function readOstOnAPage(filePath: string): OstOnAPageReadResult { @@ -15,6 +17,8 @@ export function readOstOnAPage(filePath: string): OstOnAPageReadResult { ); } - const { nodes, diagnostics } = extractEmbeddedNodes(body); + const pageTitle = basename(filePath, '.md'); + const { nodes, diagnostics } = extractEmbeddedNodes(body, { pageTitle, pageType: 'ost_on_a_page' }); + resolveParentLinks(nodes); return { nodes, diagnostics }; } diff --git a/src/read-space.ts b/src/read-space.ts index f07dc50..03d5b90 100644 --- a/src/read-space.ts +++ b/src/read-space.ts @@ -3,6 +3,7 @@ import { basename, join } from 'node:path'; import { glob } from 'glob'; import matter from 'gray-matter'; import { extractEmbeddedNodes } from './parse-embedded.js'; +import { resolveParentLinks } from './resolve-links.js'; import type { OstNode, SpaceReadResult } from './types.js'; export async function readSpace( @@ -37,7 +38,8 @@ export async function readSpace( nodes.push({ label: file, - data: { title: fileBase, ...parsed.data }, + schemaData: { title: fileBase, ...parsed.data }, + linkTargets: [fileBase], }); // Extract embedded child nodes from the page body (typed pages with embedded nodes). @@ -47,12 +49,10 @@ export async function readSpace( pageTitle: fileBase, pageType, }); - for (const node of embedded) { - node.sourceFile = fileBase; - } nodes.push(...embedded); } } + resolveParentLinks(nodes); return { nodes, skipped, nonOst }; } diff --git a/src/resolve-links.ts b/src/resolve-links.ts new file mode 100644 index 0000000..aca6047 --- /dev/null +++ b/src/resolve-links.ts @@ -0,0 +1,60 @@ +import type { OstNode } from './types.js'; + +function addTarget(index: Map, target: string, node: OstNode): void { + const normalized = target.trim(); + if (!normalized) return; + + const existing = index.get(normalized); + if (existing === undefined) { + index.set(normalized, node); + return; + } + + if (existing !== node) { + index.set(normalized, null); + } +} + +function buildTargetIndex(nodes: OstNode[]): Map { + const index = new Map(); + for (const node of nodes) { + for (const target of node.linkTargets) { + addTarget(index, target, node); + } + } + return index; +} + +/** + * Extract the lookup key from a wikilink string such as: + * [[Personal Vision]] → "Personal Vision" + * [[Personal Vision#Our Mission]] → "Personal Vision#Our Mission" + * [[vision_page#^ourmission]] → "vision_page#^ourmission" + */ +export function wikilinkToTarget(wikilink: string): string { + const cleaned = wikilink.replace(/^"|"$/g, '').trim(); + if (!cleaned.startsWith('[[') || !cleaned.endsWith(']]')) { + return cleaned; + } + return cleaned.slice(2, -2).trim(); +} + +export function resolveParentLinks(nodes: OstNode[]): void { + const targetIndex = buildTargetIndex(nodes); + + for (const node of nodes) { + node.resolvedParent = undefined; + + const rawParent = node.schemaData.parent; + if (typeof rawParent !== 'string') continue; + + const parentTarget = wikilinkToTarget(rawParent); + const parentNode = targetIndex.get(parentTarget); + if (!parentNode) continue; + + const parentTitle = parentNode.schemaData.title; + if (typeof parentTitle !== 'string') continue; + + node.resolvedParent = parentTitle; + } +} diff --git a/src/show.ts b/src/show.ts index a28fcfc..ef1894c 100644 --- a/src/show.ts +++ b/src/show.ts @@ -15,17 +15,16 @@ export async function show(path: string) { // Build children map (parent title → child nodes in document order) const children = new Map(); for (const node of nodes) { - children.set(node.data.title as string, []); + children.set(node.schemaData.title as string, []); } const roots: OstNode[] = []; for (const node of nodes) { - const parent = node.data.parent as string | undefined; + const parent = node.resolvedParent; if (!parent) { roots.push(node); } else { - const parentTitle = parent.replace(/^"|"$/g, '').slice(2, -2); - const siblings = children.get(parentTitle); + const siblings = children.get(parent); if (siblings) { siblings.push(node); } else { @@ -36,8 +35,8 @@ export async function show(path: string) { function printNode(node: OstNode, depth: number) { const indent = ' '.repeat(depth); - const type = node.data.type as string; - const title = node.data.title as string; + const type = node.schemaData.type as string; + const title = node.schemaData.title as string; console.log(`${indent}- ${type}: ${title}`); for (const child of children.get(title) ?? []) { printNode(child, depth + 1); diff --git a/src/types.ts b/src/types.ts index 81f9ae5..897c3a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,13 +1,12 @@ export interface OstNode { /** Source identifier for error messages (filename or heading title) */ label: string; - /** Schema-ready data: all fields including injected title */ - data: Record; - /** - * For embedded nodes: the base filename (no .md) - * of the page they came from. Used to resolve [[file#^anchor]] parent refs. - */ - sourceFile?: string; + /** Fields that are validated by schema.json. */ + schemaData: Record; + /** Valid navigation targets this node can be linked to (wikilink key without [[ ]]). */ + linkTargets: string[]; + /** Resolved canonical parent title (derived from schemaData.parent + linkTargets). */ + resolvedParent?: string; } export interface OstPageDiagnostics { diff --git a/src/validate.ts b/src/validate.ts index 676f448..7e4aecf 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -2,6 +2,7 @@ import { readFileSync, statSync } from 'node:fs'; import Ajv, { type ErrorObject } from 'ajv'; import { readOstOnAPage } from './read-ost-on-a-page.js'; import { readSpace } from './read-space.js'; +import { wikilinkToTarget } from './resolve-links.js'; import type { OstNode } from './types.js'; interface ValidationResult { @@ -13,18 +14,6 @@ interface ValidationResult { nonOst: string[]; } -/** - * Extract the lookup key from a wikilink string such as: - * [[Personal Vision]] → "Personal Vision" - * [[Personal Vision#Our Mission]] → "Personal Vision#Our Mission" - * [[vision_page#^ourmission]] → "vision_page#^ourmission" - */ -function wikilinkToKey(wikilink: string): string { - // Strip surrounding quotes if present (YAML sometimes keeps them) - const cleaned = wikilink.replace(/^"|"$/g, ''); - return cleaned.slice(2, -2); -} - export async function validate(path: string, options: { schema: string }): Promise { const schema = JSON.parse(readFileSync(options.schema, 'utf-8')); const ajv = new Ajv(); @@ -50,7 +39,7 @@ export async function validate(path: string, options: { schema: string }): Promi }; for (const node of nodes) { - const valid = validateFunc(node.data); + const valid = validateFunc(node.schemaData); if (valid) { result.schemaValidCount++; @@ -63,24 +52,26 @@ export async function validate(path: string, options: { schema: string }): Promi } } - // Build index keyed by resolved title. - // File nodes: data.title is the filename without .md. - // Embedded nodes: data.title is the plain heading title. - // Anchor keys: "sourceFile#^anchor" for [[file#^anchor]] wikilinks. + // Parent refs are resolved to canonical titles on node.resolvedParent in read-* code. const nodeIndex = new Map(); for (const n of nodes) { - nodeIndex.set(n.data.title as string, n); - - if (n.data.anchor && n.sourceFile) { - nodeIndex.set(`${n.sourceFile}#^${n.data.anchor}`, n); - } + nodeIndex.set(n.schemaData.title as string, n); } for (const node of nodes) { - const parent = node.data.parent as string | undefined; + const parent = node.schemaData.parent as string | undefined; if (!parent) continue; - const parentKey = wikilinkToKey(parent); + const parentKey = node.resolvedParent; + if (!parentKey) { + result.refErrors.push({ + file: node.label, + parent: parent, + error: `Parent link target "${wikilinkToTarget(parent)}" not found`, + }); + continue; + } + if (!nodeIndex.has(parentKey)) { result.refErrors.push({ file: node.label, diff --git a/tests/fixtures/valid-ost/solution_page.md b/tests/fixtures/valid-ost/solution_page.md index 2feb8f4..96b7691 100644 --- a/tests/fixtures/valid-ost/solution_page.md +++ b/tests/fixtures/valid-ost/solution_page.md @@ -1,5 +1,5 @@ --- type: solution status: identified -parent: "[[Embedded Goal]]" +parent: "[[vision_page#^embgoal]]" --- diff --git a/tests/fixtures/valid-ost/vision_page.md b/tests/fixtures/valid-ost/vision_page.md index 0f1d1a3..f0ad937 100644 --- a/tests/fixtures/valid-ost/vision_page.md +++ b/tests/fixtures/valid-ost/vision_page.md @@ -10,6 +10,6 @@ The vision body content. The mission body content. -### [type:: goal] Embedded Goal +### [type:: goal] Embedded Goal ^embgoal The goal body content. diff --git a/tests/parse-embedded.test.ts b/tests/parse-embedded.test.ts new file mode 100644 index 0000000..24c02a1 --- /dev/null +++ b/tests/parse-embedded.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'bun:test'; +import { normalizeHeadingSectionTarget } from '../src/parse-embedded.js'; + +describe('normalizeHeadingSectionTarget', () => { + it('matches observed Obsidian bookmark normalization for special separators', () => { + const input = 'T!e@s#t$ %h^e&a*d(i)n-g+ _w=i[t]h{ } ;e"x:t\'r,a. >c?h/a\\r`s~'; + const expected = 'T!e@s t$ %h e&a*d(i)n-g+ _w=i[t]h{ } ;e"x t\'r,a. >c?h/a r`s~'; + expect(normalizeHeadingSectionTarget(input)).toBe(expected); + }); +}); diff --git a/tests/read-ost-on-a-page.test.ts b/tests/read-ost-on-a-page.test.ts index 1e8e250..0e36d51 100644 --- a/tests/read-ost-on-a-page.test.ts +++ b/tests/read-ost-on-a-page.test.ts @@ -16,62 +16,66 @@ describe('readOstOnAPage - on-a-page-valid.md (ost_on_a_page)', () => { describe('heading type inference', () => { it('infers H1 as vision with no parent', () => { const node = result.nodes.find((n) => n.label === 'Personal Vision'); - expect(node?.data.type).toBe('vision'); - expect(node?.data.parent).toBeUndefined(); + expect(node?.schemaData.type).toBe('vision'); + expect(node?.schemaData.parent).toBeUndefined(); }); it('infers H2 as mission with parent from H1', () => { const node = result.nodes.find((n) => n.label === 'Personal Mission'); - expect(node?.data.type).toBe('mission'); - expect(node?.data.parent).toBe('[[Personal Vision]]'); + expect(node?.schemaData.type).toBe('mission'); + expect(node?.resolvedParent).toBe('Personal Vision'); + expect(node?.schemaData.parent).toContain('[[on-a-page-valid#'); }); it('infers H3 as goal with parent from H2', () => { const node = result.nodes.find((n) => n.label === 'Career Growth'); - expect(node?.data.type).toBe('goal'); - expect(node?.data.parent).toBe('[[Personal Mission]]'); + expect(node?.schemaData.type).toBe('goal'); + expect(node?.resolvedParent).toBe('Personal Mission'); + expect(node?.schemaData.parent).toContain('[[on-a-page-valid#'); }); it('infers H4 as opportunity with parent from H3', () => { const node = result.nodes.find((n) => n.label === 'Technical Skills'); - expect(node?.data.type).toBe('opportunity'); - expect(node?.data.parent).toBe('[[Career Growth]]'); + expect(node?.schemaData.type).toBe('opportunity'); + expect(node?.resolvedParent).toBe('Career Growth'); + expect(node?.schemaData.parent).toContain('[[on-a-page-valid#'); }); it('infers H5 as solution with parent from H4', () => { const node = result.nodes.find((n) => n.label === 'Build a Side Project'); - expect(node?.data.type).toBe('solution'); - expect(node?.data.parent).toBe('[[Technical Skills]]'); + expect(node?.schemaData.type).toBe('solution'); + expect(node?.resolvedParent).toBe('Technical Skills'); + expect(node?.schemaData.parent).toContain('[[on-a-page-valid#'); }); }); describe('default status', () => { it('applies DEFAULT_STATUS to heading nodes without explicit status', () => { const node = result.nodes.find((n) => n.label === 'Build a Side Project'); - expect(node?.data.status).toBe('identified'); + expect(node?.schemaData.status).toBe('identified'); }); }); describe('inline bracketed fields', () => { it('extracts [priority:: p2] from Career Growth heading and strips it from title', () => { const node = result.nodes.find((n) => n.label === 'Career Growth'); - expect(node?.data.priority).toBe('p2'); - expect(node?.data.title).toBe('Career Growth'); + expect(node?.schemaData.priority).toBe('p2'); + expect(node?.schemaData.title).toBe('Career Growth'); }); }); describe('unbracketed paragraph fields', () => { it('extracts status:: active on Personal Vision overriding DEFAULT_STATUS', () => { const node = result.nodes.find((n) => n.label === 'Personal Vision'); - expect(node?.data.status).toBe('active'); + expect(node?.schemaData.status).toBe('active'); }); }); describe('YAML code block', () => { it('merges YAML block fields into Personal Mission', () => { const node = result.nodes.find((n) => n.label === 'Personal Mission'); - expect(node?.data.status).toBe('active'); - expect(node?.data.summary).toBe('A mission-level summary set via YAML block'); + expect(node?.schemaData.status).toBe('active'); + expect(node?.schemaData.summary).toBe('A mission-level summary set via YAML block'); }); }); @@ -84,13 +88,14 @@ describe('readOstOnAPage - on-a-page-valid.md (ost_on_a_page)', () => { it('sets parent and summary on Learn TypeScript from dash separator', () => { const node = result.nodes.find((n) => n.label === 'Learn TypeScript'); - expect(node?.data.parent).toBe('[[Technical Skills]]'); - expect(node?.data.summary).toBe('Master TypeScript for tool development'); + expect(node?.resolvedParent).toBe('Technical Skills'); + expect(node?.schemaData.parent).toContain('[[on-a-page-valid#'); + expect(node?.schemaData.summary).toBe('Master TypeScript for tool development'); }); it('applies DEFAULT_STATUS to typed bullet without explicit override', () => { const node = result.nodes.find((n) => n.label === 'Read OSTS Book'); - expect(node?.data.status).toBe('identified'); + expect(node?.schemaData.status).toBe('identified'); }); }); diff --git a/tests/read-space.test.ts b/tests/read-space.test.ts index 554d93f..96fc684 100644 --- a/tests/read-space.test.ts +++ b/tests/read-space.test.ts @@ -20,7 +20,7 @@ describe('readSpace', () => { it('injects title from filename for file-based nodes', () => { const vision = result.nodes.find((n) => n.label === 'Personal Vision.md'); - expect(vision?.data.title).toBe('Personal Vision'); + expect(vision?.schemaData.title).toBe('Personal Vision'); }); it('skips no-frontmatter.md', () => { @@ -41,10 +41,10 @@ describe('readSpace', () => { it('preserves numeric frontmatter fields on Technical Skills', () => { const ts = result.nodes.find((n) => n.label === 'Technical Skills.md'); - expect(ts?.data.impact).toBe(4); - expect(ts?.data.feasibility).toBe(3); - expect(ts?.data.resources).toBe(2); - expect(ts?.data.priority).toBe('p3'); + expect(ts?.schemaData.impact).toBe(4); + expect(ts?.schemaData.feasibility).toBe(3); + expect(ts?.schemaData.resources).toBe(2); + expect(ts?.schemaData.priority).toBe('p3'); }); it('Community OST.md (ost_on_a_page) is excluded from nodes', () => { @@ -67,46 +67,45 @@ describe('readSpace', () => { it('includes vision_page.md as its own node', () => { const node = result.nodes.find((n) => n.label === 'vision_page.md'); expect(node).toBeDefined(); - expect(node?.data.type).toBe('vision'); - expect(node?.data.title).toBe('vision_page'); + expect(node?.schemaData.type).toBe('vision'); + expect(node?.schemaData.title).toBe('vision_page'); }); it('extracts embedded mission with plain title', () => { const node = result.nodes.find((n) => n.label === 'Embedded Mission'); expect(node).toBeDefined(); - expect(node?.data.type).toBe('mission'); - expect(node?.data.title).toBe('Embedded Mission'); + expect(node?.schemaData.type).toBe('mission'); + expect(node?.schemaData.title).toBe('Embedded Mission'); }); it('embedded mission parent points to the containing page', () => { const node = result.nodes.find((n) => n.label === 'Embedded Mission'); - expect(node?.data.parent).toBe('[[vision_page]]'); + expect(node?.schemaData.parent).toBe('[[vision_page]]'); + expect(node?.resolvedParent).toBe('vision_page'); }); - it('stores anchor on embedded mission node', () => { + it('stores navigation targets for embedded mission', () => { const node = result.nodes.find((n) => n.label === 'Embedded Mission'); - expect(node?.data.anchor).toBe('embmission'); - }); - - it('sets sourceFile on embedded nodes', () => { - const node = result.nodes.find((n) => n.label === 'Embedded Mission'); - expect(node?.sourceFile).toBe('vision_page'); + expect(node?.linkTargets).toContain('vision_page#^embmission'); + expect(node?.linkTargets).toContain('vision_page#[type mission] Embedded Mission embmission'); }); it('extracts nested embedded goal with plain title', () => { const node = result.nodes.find((n) => n.label === 'Embedded Goal'); expect(node).toBeDefined(); - expect(node?.data.type).toBe('goal'); + expect(node?.schemaData.type).toBe('goal'); }); - it('embedded goal parent points to the embedded mission by plain title', () => { + it('embedded goal parent is stored as an implied section target and resolved to title', () => { const node = result.nodes.find((n) => n.label === 'Embedded Goal'); - expect(node?.data.parent).toBe('[[Embedded Mission]]'); + expect(node?.schemaData.parent).toBe('[[vision_page#[type mission] Embedded Mission embmission]]'); + expect(node?.resolvedParent).toBe('Embedded Mission'); }); - it('solution_page.md references embedded goal as parent by plain title', () => { + it('solution_page.md keeps source parent link and resolves to embedded goal title', () => { const node = result.nodes.find((n) => n.label === 'solution_page.md'); - expect(node?.data.parent).toBe('[[Embedded Goal]]'); + expect(node?.schemaData.parent).toBe('[[vision_page#^embgoal]]'); + expect(node?.resolvedParent).toBe('Embedded Goal'); }); }); @@ -119,28 +118,36 @@ describe('readSpace', () => { it('infers type "mission" from ^mission anchor', () => { const node = result.nodes.find((n) => n.label === 'Our Mission'); - expect(node?.data.type).toBe('mission'); - expect(node?.data.title).toBe('Our Mission'); + expect(node?.schemaData.type).toBe('mission'); + expect(node?.schemaData.title).toBe('Our Mission'); }); it('infers type "goal" from ^goal1 anchor', () => { const node = result.nodes.find((n) => n.label === 'Another Goal'); - expect(node?.data.type).toBe('goal'); - expect(node?.data.title).toBe('Another Goal'); + expect(node?.schemaData.type).toBe('goal'); + expect(node?.schemaData.title).toBe('Another Goal'); }); - it('stores anchors on anchor-typed nodes', () => { - expect(result.nodes.find((n) => n.label === 'Our Mission')?.data.anchor).toBe('mission'); - expect(result.nodes.find((n) => n.label === 'Another Goal')?.data.anchor).toBe('goal1'); + it('stores both section and anchor navigation targets when heading has a block anchor', () => { + const mission = result.nodes.find((n) => n.label === 'Our Mission'); + const goal = result.nodes.find((n) => n.label === 'Another Goal'); + expect(mission?.linkTargets).toContain('anchor_vision#^mission'); + expect(mission?.linkTargets).toContain('anchor_vision#Our Mission mission'); + expect(goal?.linkTargets).toContain('anchor_vision#^goal1'); + expect(goal?.linkTargets).toContain('anchor_vision#Another Goal goal1'); }); it('does not include untyped preamble heading as a node', () => { expect(result.nodes.map((n) => n.label)).not.toContain('Preamble (ignored)'); }); - it('sets sourceFile on anchor-typed embedded nodes', () => { - const node = result.nodes.find((n) => n.label === 'Another Goal'); - expect(node?.sourceFile).toBe('anchor_vision'); + it('resolves section/anchor parent links to canonical parent titles without mutating source links', () => { + const goal = result.nodes.find((n) => n.label === 'Embedded Goal'); + const solutionPage = result.nodes.find((n) => n.label === 'solution_page.md'); + expect(goal?.schemaData.parent).toBe('[[vision_page#[type mission] Embedded Mission embmission]]'); + expect(goal?.resolvedParent).toBe('Embedded Mission'); + expect(solutionPage?.schemaData.parent).toBe('[[vision_page#^embgoal]]'); + expect(solutionPage?.resolvedParent).toBe('Embedded Goal'); }); }); diff --git a/tests/validate.test.ts b/tests/validate.test.ts index 98edecd..1448f2d 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import Ajv from 'ajv'; import { readOstOnAPage } from '../src/read-ost-on-a-page.js'; import { readSpace } from '../src/read-space.js'; +import { resolveParentLinks } from '../src/resolve-links.js'; import type { OstNode } from '../src/types.js'; const SCHEMA_PATH = join(import.meta.dir, '../schema.json'); @@ -15,26 +16,18 @@ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf-8')); const ajv = new Ajv(); const validateNode = ajv.compile(schema); -/** - * Inline ref-check helper — mirrors the logic in validate.ts. - * Indexes by data.title (resolved title). Anchor refs use node.sourceFile. - */ +/** Inline ref-check helper - mirrors the logic in validate.ts. */ function checkRefErrors(nodes: OstNode[]): Array<{ file: string; parent: string }> { - const index = new Set(nodes.map((n) => n.data.title as string)); - - for (const n of nodes) { - if (n.data.anchor && n.sourceFile) { - index.add(`${n.sourceFile}#^${n.data.anchor}`); - } - } + const index = new Set(nodes.map((n) => n.schemaData.title as string)); return nodes - .filter((n) => n.data.parent) + .filter((n) => n.schemaData.parent) .filter((n) => { - const parentKey = (n.data.parent as string).slice(2, -2); + const parentKey = n.resolvedParent; + if (!parentKey) return true; return !index.has(parentKey); }) - .map((n) => ({ file: n.label, parent: n.data.parent as string })); + .map((n) => ({ file: n.label, parent: n.schemaData.parent as string })); } describe('Schema validation', () => { @@ -48,7 +41,7 @@ describe('Schema validation', () => { it('all 12 nodes pass schema validation', () => { expect(nodes).toHaveLength(12); for (const node of nodes) { - expect(validateNode(node.data)).toBe(true); + expect(validateNode(node.schemaData)).toBe(true); } }); @@ -67,7 +60,7 @@ describe('Schema validation', () => { it('all nodes pass schema validation', () => { expect(nodes.length).toBeGreaterThan(0); for (const node of nodes) { - expect(validateNode(node.data)).toBe(true); + expect(validateNode(node.schemaData)).toBe(true); } }); }); @@ -82,19 +75,19 @@ describe('Schema validation', () => { it('missing-status.md fails schema validation (no status field)', () => { const node = nodes.find((n) => n.label === 'missing-status.md'); expect(node).toBeDefined(); - expect(validateNode(node?.data)).toBe(false); + expect(validateNode(node?.schemaData)).toBe(false); }); it('vision-with-parent.md fails schema validation (vision forbids parent)', () => { const node = nodes.find((n) => n.label === 'vision-with-parent.md'); expect(node).toBeDefined(); - expect(validateNode(node?.data)).toBe(false); + expect(validateNode(node?.schemaData)).toBe(false); }); it('dangling-parent.md passes schema validation (ref is a separate check)', () => { const node = nodes.find((n) => n.label === 'dangling-parent.md'); expect(node).toBeDefined(); - expect(validateNode(node?.data)).toBe(true); + expect(validateNode(node?.schemaData)).toBe(true); }); it('detects dangling parent ref error for Nonexistent Node', () => { @@ -103,71 +96,117 @@ describe('Schema validation', () => { }); }); - describe('cross-file anchor ref resolution', () => { - it('resolves [[file#^anchor]] to the embedded node with that anchor', () => { - // Represents what readSpace produces from anchor_vision.md + a sibling file + describe('link-target parent resolution', () => { + it('resolves anchor/section wikilinks to canonical parent titles', () => { const nodes: OstNode[] = [ { label: 'anchor_vision.md', - data: { title: 'anchor_vision', type: 'vision', status: 'active' }, - }, - { - label: 'Another Goal', - sourceFile: 'anchor_vision', - data: { - title: 'Another Goal', - type: 'goal', - status: 'identified', - anchor: 'goal1', - parent: '[[Our Mission]]', - }, + schemaData: { title: 'anchor_vision', type: 'vision', status: 'active' }, + linkTargets: ['anchor_vision'], }, { label: 'Our Mission', - sourceFile: 'anchor_vision', - data: { + schemaData: { title: 'Our Mission', type: 'mission', status: 'identified', - anchor: 'mission', parent: '[[anchor_vision]]', }, + linkTargets: ['anchor_vision#Our Mission mission', 'anchor_vision#^mission'], }, { - label: 'some-solution.md', - data: { - title: 'some-solution', + label: 'Another Goal', + schemaData: { + title: 'Another Goal', + type: 'goal', + status: 'identified', + parent: '[[anchor_vision#^mission]]', + }, + linkTargets: ['anchor_vision#Another Goal goal1', 'anchor_vision#^goal1'], + }, + { + label: 'solution_page.md', + schemaData: { + title: 'solution_page', type: 'solution', status: 'identified', parent: '[[anchor_vision#^goal1]]', }, + linkTargets: ['solution_page'], }, ]; + resolveParentLinks(nodes); + + expect(nodes.find((n) => n.label === 'Another Goal')?.schemaData.parent).toBe('[[anchor_vision#^mission]]'); + expect(nodes.find((n) => n.label === 'Another Goal')?.resolvedParent).toBe('Our Mission'); + expect(nodes.find((n) => n.label === 'solution_page.md')?.schemaData.parent).toBe('[[anchor_vision#^goal1]]'); + expect(nodes.find((n) => n.label === 'solution_page.md')?.resolvedParent).toBe('Another Goal'); expect(checkRefErrors(nodes)).toHaveLength(0); }); - it('reports error when anchor-based wikilink points to nonexistent anchor', () => { + it('keeps unresolved parent links untouched when no link target matches', () => { const nodes: OstNode[] = [ { label: 'anchor_vision.md', - data: { title: 'anchor_vision', type: 'vision', status: 'active' }, + schemaData: { title: 'anchor_vision', type: 'vision', status: 'active' }, + linkTargets: ['anchor_vision'], }, { label: 'some-solution.md', - data: { + schemaData: { title: 'some-solution', type: 'solution', status: 'identified', parent: '[[anchor_vision#^noanchor]]', }, + linkTargets: ['some-solution'], }, ]; + resolveParentLinks(nodes); + const errors = checkRefErrors(nodes); expect(errors).toHaveLength(1); expect(errors[0]?.parent).toBe('[[anchor_vision#^noanchor]]'); }); + + it('does not resolve bare embedded-node title links when no page exists', () => { + const nodes: OstNode[] = [ + { + label: 'vision_page.md', + schemaData: { title: 'vision_page', type: 'vision', status: 'active' }, + linkTargets: ['vision_page'], + }, + { + label: 'Embedded Goal', + schemaData: { + title: 'Embedded Goal', + type: 'goal', + status: 'identified', + parent: '[[vision_page]]', + }, + linkTargets: ['vision_page#Embedded Goal'], + }, + { + label: 'solution_page.md', + schemaData: { + title: 'solution_page', + type: 'solution', + status: 'identified', + parent: '[[Embedded Goal]]', + }, + linkTargets: ['solution_page'], + }, + ]; + + resolveParentLinks(nodes); + + expect(nodes.find((n) => n.label === 'solution_page.md')?.resolvedParent).toBeUndefined(); + const errors = checkRefErrors(nodes); + expect(errors).toHaveLength(1); + expect(errors[0]?.parent).toBe('[[Embedded Goal]]'); + }); }); describe('schema shape assertions (inline data)', () => {