#!/usr/bin/env node /* * Balinyaar product-docs generator (zero dependency). * cd product && node build-docs.mjs * Walks every .md file, renders a brand-styled, cross-linked .html beside it, * builds the sidebar from NAV below, and rewrites internal `…/foo.md` links to `…/foo.html`. * Markdown is the source of truth — never hand-edit the generated .html. */ import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, relative, posix } from 'node:path'; const ROOT = dirname(fileURLToPath(import.meta.url)); const SEP = String.fromCharCode(0); // collision-proof placeholder delimiter for code spans /* ---- Sidebar manifest. Add/rename .md files here so they show in the nav. ---- */ const NAV = [ { label: 'Start here', items: [ { path: 'index.md', title: 'Docs home' }, { path: 'overview/platform-summary.md', title: 'Platform summary & ground truths' }, ]}, { label: 'Business requirements', items: [ { path: 'business/index.md', title: 'Overview & MVP scope' }, { path: 'business/01-actors-and-onboarding.md', title: '1. Actors & onboarding' }, { path: 'business/02-nurse-verification.md', title: '2. Nurse verification' }, { path: 'business/03-service-catalog-and-pricing.md', title: '3. Service catalog & pricing' }, { path: 'business/04-search-and-matching.md', title: '4. Search & matching' }, { path: 'business/05-booking-and-scheduling.md', title: '5. Booking & scheduling' }, { path: 'business/06-evv-and-service-delivery.md', title: '6. EVV / service delivery' }, { path: 'business/07-cancellation-and-refunds.md', title: '7. Cancellation & refunds' }, { path: 'business/08-payments-and-escrow.md', title: '8. Payments & escrow' }, { path: 'business/09-installments-bnpl.md', title: '9. Installments / BNPL' }, { path: 'business/10-payouts.md', title: '10. Payouts to nurses' }, { path: 'business/11-reviews-trust-and-safety.md', title: '11. Reviews, trust & safety' }, { path: 'business/12-messaging-and-emergencies.md', title: '12. Messaging & emergencies' }, { path: 'business/13-tax-invoicing-and-legal.md', title: '13. Tax, invoicing & legal' }, { path: 'business/14-notifications-and-admin.md', title: '14. Notifications & admin' }, ]}, { label: 'Database model', items: [ { path: 'data-model/index.md', title: 'Overview & decisions' }, { path: 'data-model/diagrams.md', title: 'Diagrams' }, { path: 'data-model/01-identity-and-access.md', title: '1. Identity & access' }, { path: 'data-model/02-geography.md', title: '2. Geography' }, { path: 'data-model/03-services-and-pricing.md', title: '3. Services & pricing' }, { path: 'data-model/04-verification-and-credentials.md', title: '4. Verification & credentials' }, { path: 'data-model/05-booking-and-scheduling.md', title: '5. Booking & scheduling' }, { path: 'data-model/06-payments-ledger-and-refunds.md', title: '6. Payments, ledger & refunds' }, { path: 'data-model/07-payouts.md', title: '7. Payouts' }, { path: 'data-model/08-bnpl.md', title: '8. BNPL / installments' }, { path: 'data-model/09-messaging.md', title: '9. Messaging' }, { path: 'data-model/10-reviews-and-records.md', title: '10. Reviews & records' }, { path: 'data-model/11-notifications.md', title: '11. Notifications' }, { path: 'data-model/12-audit-config-and-reference.md', title: '12. Audit, config & reference' }, { path: 'data-model/13-partner-centers-and-future.md', title: '13. Partner centers & future' }, ]}, { label: 'Payments deep-dive', items: [ { path: 'payments/index.md', title: 'Overview & exec summary' }, { path: 'payments/iranian-payment-reality.md', title: 'Iranian payment reality' }, { path: 'payments/escrow-ledger.md', title: 'Escrow as a ledger' }, { path: 'payments/bnpl-landscape.md', title: 'BNPL landscape & finding' }, { path: 'payments/cancellation-and-payout.md', title: 'Cancellation & nurse payout' }, { path: 'payments/integration-notes.md', title: 'Integration & schema touchpoints' }, { path: 'payments/sources.md', title: 'Recommendations & sources' }, ]}, { label: 'Research & strategy', items: [ { path: 'research/index.md', title: 'Overview & exec summary' }, { path: 'research/market-and-competitors.md', title: 'Market & competitors' }, { path: 'research/problems-and-risks.md', title: 'Problems & risks' }, { path: 'research/verification.md', title: 'Verification (research)' }, { path: 'research/legal-landscape.md', title: 'Legal landscape' }, { path: 'research/go-to-market.md', title: 'Go-to-market & sources' }, ]}, { label: 'Notes & more', items: [ { path: 'notes/open-questions.md', title: 'Open questions' }, { path: 'notes/future-ideas.md', title: 'Future ideas' }, { path: 'wireframes/index.html', title: 'Wireframes' }, { path: 'fa/index.html', title: 'Farsi documents' }, ]}, ]; /* ---------------------------- tiny markdown renderer ---------------------------- */ const esc = (s) => s.replace(/&/g, '&').replace(//g, '>'); const escAttr = (s) => esc(s).replace(/"/g, '"'); function slug(text) { return text.toLowerCase().trim() .replace(/[`*~]/g, '') .replace(/[^\w؀-ۿ\s-]/g, '') .replace(/\s+/g, '-').replace(/-+/g, '-'); } function rewriteHref(url) { if (/^(https?:|mailto:|#|\/\/)/.test(url)) return url; return url.replace(/\.md(#.*)?$/i, '.html$1'); } // Inline: protect code spans with NUL-delimited placeholders, escape, then links/emphasis/strike. function inline(text) { const codes = []; text = text.replace(/`([^`]+)`/g, (_, c) => { codes.push(c); return SEP + (codes.length - 1) + SEP; }); text = esc(text); text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => '' + label + ''); text = text.replace(/\*\*([^*]+?)\*\*/g, '$1'); text = text.replace(/(^|[^*])\*([^*\n]+?)\*(?!\*)/g, '$1$2'); text = text.replace(/~~([^~]+?)~~/g, '$1'); const restore = new RegExp(SEP + '(\\d+)' + SEP, 'g'); text = text.replace(restore, (_, i) => '' + esc(codes[+i]) + ''); return text; } function renderList(lines, i, indent) { const reItem = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; let html = ''; let ordered = null; while (i < lines.length) { const m = lines[i].match(reItem); if (!m) break; const ind = m[1].length; if (ind < indent) break; if (ind > indent) break; const thisOrdered = /\d/.test(m[2]); if (ordered === null) ordered = thisOrdered; let itemHtml = inline(m[3]); i++; if (i < lines.length) { const mn = lines[i].match(reItem); if (mn && mn[1].length > indent) { const [sub, ni] = renderList(lines, i, mn[1].length); itemHtml += sub; i = ni; } } html += '
  • ' + itemHtml + '
  • \n'; } const tag = ordered ? 'ol' : 'ul'; return ['<' + tag + '>\n' + html + '\n', i]; } function mdToHtml(md) { const lines = md.replace(/\r\n/g, '\n').split('\n'); let out = ''; let i = 0; const reItem = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; while (i < lines.length) { let line = lines[i]; if (line.trim() === '') { i++; continue; } const fence = line.match(/^```\s*(\w+)?\s*$/); if (fence) { const lang = (fence[1] || '').toLowerCase(); const buf = []; i++; while (i < lines.length && !/^```\s*$/.test(lines[i])) { buf.push(lines[i]); i++; } i++; const body = esc(buf.join('\n')); out += lang === 'mermaid' ? '
    ' + body + '
    \n' : '
    ' + body + '
    \n'; continue; } const h = line.match(/^(#{1,6})\s+(.*)$/); if (h) { const lvl = h[1].length; const txt = inline(h[2].replace(/\s+#+\s*$/, '')); const id = slug(h[2]); const anchor = lvl >= 2 && lvl <= 3 ? ' ' : ''; out += '' + txt + anchor + '\n'; i++; continue; } if (/^(\s*)(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { out += '
    \n'; i++; continue; } if (line.includes('|') && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1]) && lines[i + 1].includes('-')) { const splitRow = (r) => { let s = r.trim().replace(/^\|/, '').replace(/\|$/, ''); const cells = []; let cur = ''; let escd = false; for (const ch of s) { if (escd) { cur += ch; escd = false; } else if (ch === '\\') { escd = true; cur += ch; } else if (ch === '|') { cells.push(cur); cur = ''; } else cur += ch; } cells.push(cur); return cells.map((c) => c.trim()); }; const headers = splitRow(line); i += 2; let body = ''; while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') { const cells = splitRow(lines[i]); body += '' + cells.map((c) => '' + inline(c.replace(/\\\|/g, '|')) + '').join('') + '\n'; i++; } const head = '' + headers.map((c) => '' + inline(c.replace(/\\\|/g, '|')) + '').join('') + ''; out += '
    ' + head + '\n' + body + '
    \n'; continue; } if (/^\s*>/.test(line)) { const buf = []; while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; } out += '
    ' + mdToHtml(buf.join('\n')) + '
    \n'; continue; } if (reItem.test(line)) { const ind = line.match(reItem)[1].length; const [html, ni] = renderList(lines, i, ind); out += html; i = ni; continue; } const para = []; while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6}\s|```|\s*>|\s*(-{3,}|\*{3,}|_{3,})\s*$)/.test(lines[i]) && !reItem.test(lines[i]) && !(lines[i].includes('|') && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1]))) { para.push(lines[i]); i++; } if (para.length) out += '

    ' + inline(para.join(' ')) + '

    \n'; } return out; } /* ---------------------------- page template ---------------------------- */ function relHref(fromPath, toPath) { const rel = posix.relative(posix.dirname(fromPath), toPath); return rel || posix.basename(toPath); } function firstH1(md) { const m = md.match(/^#\s+(.+)$/m); return m ? m[1].replace(/[`*~]/g, '').trim() : 'Balinyaar'; } function sidebar(currentMdPath) { const currentHtml = currentMdPath.replace(/\.md$/, '.html'); let html = ''; for (const group of NAV) { html += '
    ' + esc(group.label) + '
    '; } return html; } function page(mdPath, md) { const htmlPath = mdPath.replace(/\.md$/, '.html'); const title = firstH1(md); const cssHref = escAttr(relHref(htmlPath, 'assets/doc.css')); const homeHref = escAttr(relHref(htmlPath, 'index.html')); const body = mdToHtml(md); const mermaidScript = body.includes('class="mermaid"') ? '' : ''; return '\n' + '\n\n' + '\n' + '\n' + '' + esc(title) + ' — Balinyaar docs\n' + '\n' + '\n\n' + '
    \n' + '\n' + '
    \n' + '
    \n' + body + ' ↑ Back to top\n' + '
    \n
    \n' + '\n' + mermaidScript + '\n' + '\n\n'; } /* ---------------------------- walk + build ---------------------------- */ function walk(dir, acc = []) { for (const name of readdirSync(dir)) { const full = join(dir, name); if (statSync(full).isDirectory()) { if (name === 'assets' || name === 'node_modules' || name.startsWith('.')) continue; walk(full, acc); } else if (name.endsWith('.md')) { acc.push(full); } } return acc; } const files = walk(ROOT); let n = 0; for (const full of files) { const rel = relative(ROOT, full).split('\\').join('/'); const md = readFileSync(full, 'utf8'); writeFileSync(full.replace(/\.md$/, '.html'), page(rel, md), 'utf8'); n++; } console.log('Generated ' + n + ' HTML pages from Markdown.');