Initial commit
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Lightweight markdown walker tuned to the HbM book layout.
|
||||
*
|
||||
* Supports two formats:
|
||||
*
|
||||
* 1. Legacy Google-Docs export (plain text, `_books/`):
|
||||
* ## Chapter heading
|
||||
* Section heading (plain text / bold)
|
||||
* Item Name
|
||||
* * Field: value
|
||||
*
|
||||
* 2. Native Obsidian/repo files (`rules/`, `disciplines/`, etc.):
|
||||
* --- ← YAML frontmatter (skipped)
|
||||
* ...
|
||||
* ---
|
||||
* ## Rozdział I - ... ← chapter (any heading level)
|
||||
* ### Sub-section ← section / sub-section
|
||||
* #### Spell Name ← item name
|
||||
* - [[#Anchor|Label]] ← Obsidian TOC bullet (skipped)
|
||||
* * Field: value
|
||||
*
|
||||
* The walker emits one `BookBlock` per item it can find. Section context
|
||||
* is carried through so parsers know e.g. which discipline a spell lives
|
||||
* under.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export interface BookBlock {
|
||||
/** The line directly preceding the bullet block — usually the item name. */
|
||||
name: string;
|
||||
/** Bullet keys/values, preserved in order. */
|
||||
fields: Array<{ key: string; value: string }>;
|
||||
/** Free-form paragraph text following the bullet block (description). */
|
||||
description: string;
|
||||
/** Most recent chapter (any `#+ ...`) heading. */
|
||||
chapter: string;
|
||||
/** Most recent non-bullet, non-empty line above the item name (sub-section). */
|
||||
section: string;
|
||||
/** 1-based line number of the item name in the source file. */
|
||||
line: number;
|
||||
}
|
||||
|
||||
const SEPARATOR = /^_{3,}$/;
|
||||
const BULLET = /^\*\s+([^:]+):\s*(.+)$/;
|
||||
// Match any markdown heading level (1-6 `#` chars).
|
||||
const HEADING = /^#{1,6}\s+(.+)$/;
|
||||
// Chapter headings are the two highest heading levels we encounter — heuristic:
|
||||
// treat ## headings as "chapter" and ### headings as "section".
|
||||
const CHAPTER_HEADING = /^#{1,2}\s+(.+)$/;
|
||||
const SECTION_HEADING = /^#{3,4}\s+(.+)$/;
|
||||
// Obsidian TOC bullets: `- [[#Anchor|Label]]` or `* [[#Anchor|Label]]`
|
||||
const OBSIDIAN_TOC_BULLET = /^[-*]\s+\[\[#/;
|
||||
// Frontmatter fence
|
||||
const FRONTMATTER_FENCE = /^---\s*$/;
|
||||
|
||||
/** Strip leading markdown heading markers and bold `**` from a raw line. */
|
||||
function stripHeadingMarkers(line: string): string {
|
||||
return line.replace(/^#{1,6}\s+/, '').replace(/^\*\*(.+)\*\*$/, '$1').trim();
|
||||
}
|
||||
|
||||
/** Skip YAML frontmatter block at start of file. Returns the index of the first non-frontmatter line. */
|
||||
function skipFrontmatter(lines: string[]): number {
|
||||
if (lines.length === 0 || !FRONTMATTER_FENCE.test(lines[0].trim())) return 0;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (FRONTMATTER_FENCE.test(lines[i].trim())) return i + 1;
|
||||
}
|
||||
return 0; // malformed — start from 0
|
||||
}
|
||||
|
||||
export interface WalkOptions {
|
||||
/**
|
||||
* Predicate that returns `true` when the line (after stripping heading markers)
|
||||
* is a known section header (e.g. matches a discipline / school / deity label).
|
||||
* Used by the walker to track the current section across separators and prose.
|
||||
*/
|
||||
isSectionHeader?: (line: string) => boolean;
|
||||
}
|
||||
|
||||
export function walkBook(path: string, opts: WalkOptions = {}): BookBlock[] {
|
||||
const text = readFileSync(path, 'utf8');
|
||||
const lines = text.split(/\r?\n/);
|
||||
const blocks: BookBlock[] = [];
|
||||
|
||||
let chapter = '';
|
||||
let section = '';
|
||||
const isSectionHeader = opts.isSectionHeader ?? (() => false);
|
||||
|
||||
const startIdx = skipFrontmatter(lines);
|
||||
|
||||
// Look ahead for bullet-block starts; track the line above as the name.
|
||||
for (let i = startIdx; i < lines.length; i++) {
|
||||
const rawLine = lines[i].trimEnd();
|
||||
|
||||
// Skip Obsidian TOC bullets — they look like `- [[#Section|Label]]`.
|
||||
if (OBSIDIAN_TOC_BULLET.test(rawLine.trim())) continue;
|
||||
|
||||
// Chapter headings: ## or # level (the two highest we honour).
|
||||
const chMatch = rawLine.match(CHAPTER_HEADING);
|
||||
if (chMatch) {
|
||||
chapter = stripHeadingMarkers(rawLine);
|
||||
// Reset section only for top-level (##) headings.
|
||||
if (rawLine.startsWith('## ') || rawLine.startsWith('# ')) section = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Section sub-headings: ### or #### level become section markers.
|
||||
const secMatch = rawLine.match(SECTION_HEADING);
|
||||
if (secMatch) {
|
||||
const bare = stripHeadingMarkers(rawLine);
|
||||
const isL3 = rawLine.startsWith('### ');
|
||||
if (isL3 || isSectionHeader(bare)) {
|
||||
section = bare;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Track plain-text section headers (legacy format).
|
||||
const trimmed = rawLine.trim();
|
||||
if (trimmed && !SEPARATOR.test(trimmed) && !BULLET.test(trimmed) && isSectionHeader(trimmed)) {
|
||||
section = trimmed;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (BULLET.test(rawLine)) {
|
||||
// Walk up to find the name line (first non-empty, non-separator, non-heading line above).
|
||||
let nameLine = '';
|
||||
let nameIdx = i - 1;
|
||||
while (nameIdx >= startIdx) {
|
||||
const candidate = lines[nameIdx].trim();
|
||||
// Skip empty lines, separators, TOC links, list items, and requirements lines
|
||||
if (
|
||||
candidate &&
|
||||
!SEPARATOR.test(candidate) &&
|
||||
!OBSIDIAN_TOC_BULLET.test(candidate) &&
|
||||
!/^[*-]\s+/.test(candidate) &&
|
||||
!/^(?:[*-]\s+)?Wymagania?:/i.test(candidate) &&
|
||||
!/^>/.test(candidate)
|
||||
) {
|
||||
// Strip any heading markers; skip pure chapter/section headings as the name
|
||||
// (they are already captured in `chapter`/`section`).
|
||||
nameLine = stripHeadingMarkers(candidate);
|
||||
break;
|
||||
}
|
||||
nameIdx--;
|
||||
}
|
||||
|
||||
// Collect the bullet block.
|
||||
const fields: Array<{ key: string; value: string }> = [];
|
||||
let j = i;
|
||||
while (j < lines.length) {
|
||||
const m = lines[j].trimEnd().match(BULLET);
|
||||
if (!m) break;
|
||||
fields.push({ key: m[1].trim(), value: m[2].trim() });
|
||||
j++;
|
||||
}
|
||||
|
||||
// Description = following lines until next bullet block, separator, or heading.
|
||||
const descLines: string[] = [];
|
||||
let k = j;
|
||||
while (k < lines.length) {
|
||||
const cur = lines[k].trimEnd();
|
||||
if (SEPARATOR.test(cur)) break;
|
||||
if (HEADING.test(cur)) break;
|
||||
if (BULLET.test(cur)) break;
|
||||
if (OBSIDIAN_TOC_BULLET.test(cur.trim())) break;
|
||||
descLines.push(cur);
|
||||
k++;
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
name: nameLine,
|
||||
fields,
|
||||
description: descLines.join('\n').trim(),
|
||||
chapter,
|
||||
section,
|
||||
line: nameIdx + 1,
|
||||
});
|
||||
|
||||
i = k - 1; // advance past description
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
Reference in New Issue
Block a user