320 lines
14 KiB
JavaScript
320 lines
14 KiB
JavaScript
#!/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, '<').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) =>
|
|
'<a href="' + escAttr(rewriteHref(url.trim())) + '">' + label + '</a>');
|
|
text = text.replace(/\*\*([^*]+?)\*\*/g, '<strong>$1</strong>');
|
|
text = text.replace(/(^|[^*])\*([^*\n]+?)\*(?!\*)/g, '$1<em>$2</em>');
|
|
text = text.replace(/~~([^~]+?)~~/g, '<del>$1</del>');
|
|
const restore = new RegExp(SEP + '(\\d+)' + SEP, 'g');
|
|
text = text.replace(restore, (_, i) => '<code>' + esc(codes[+i]) + '</code>');
|
|
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 += '<li>' + itemHtml + '</li>\n';
|
|
}
|
|
const tag = ordered ? 'ol' : 'ul';
|
|
return ['<' + tag + '>\n' + html + '</' + tag + '>\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'
|
|
? '<pre class="mermaid">' + body + '</pre>\n'
|
|
: '<pre><code>' + body + '</code></pre>\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 ? ' <a class="anchor" href="#' + id + '" aria-hidden="true">#</a>' : '';
|
|
out += '<h' + lvl + ' id="' + id + '">' + txt + anchor + '</h' + lvl + '>\n';
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (/^(\s*)(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { out += '<hr>\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 += '<tr>' + cells.map((c) => '<td>' + inline(c.replace(/\\\|/g, '|')) + '</td>').join('') + '</tr>\n';
|
|
i++;
|
|
}
|
|
const head = '<tr>' + headers.map((c) => '<th>' + inline(c.replace(/\\\|/g, '|')) + '</th>').join('') + '</tr>';
|
|
out += '<div class="table-wrap"><table><thead>' + head + '</thead><tbody>\n' + body + '</tbody></table></div>\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 += '<blockquote>' + mdToHtml(buf.join('\n')) + '</blockquote>\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 += '<p>' + inline(para.join(' ')) + '</p>\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 += '<div class="group"><div class="label">' + esc(group.label) + '</div><ul>';
|
|
for (const it of group.items) {
|
|
const targetHtml = it.path.replace(/\.md$/, '.html');
|
|
const active = targetHtml === currentHtml ? ' class="active"' : '';
|
|
html += '<li><a' + active + ' href="' + escAttr(relHref(currentHtml, targetHtml)) + '">' + esc(it.title) + '</a></li>';
|
|
}
|
|
html += '</ul></div>';
|
|
}
|
|
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"')
|
|
? '<script type="module">\n'
|
|
+ " import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';\n"
|
|
+ " const dark = document.documentElement.getAttribute('data-theme') === 'dark';\n"
|
|
+ " mermaid.initialize({ startOnLoad: true, theme: dark ? 'dark' : 'neutral' });\n"
|
|
+ '</script>'
|
|
: '';
|
|
return '<!DOCTYPE html>\n'
|
|
+ '<html lang="en">\n<head>\n'
|
|
+ '<meta charset="utf-8">\n'
|
|
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
|
+ '<title>' + esc(title) + ' — Balinyaar docs</title>\n'
|
|
+ '<link rel="stylesheet" href="' + cssHref + '">\n'
|
|
+ '</head>\n<body>\n'
|
|
+ '<div class="layout">\n'
|
|
+ '<aside class="sidebar">\n'
|
|
+ ' <a class="brand" href="' + homeHref + '"><span class="dot"></span> Balinyaar docs</a>\n'
|
|
+ ' <p class="tagline">Trust-first home-nursing marketplace · Iran</p>\n'
|
|
+ ' <nav>' + sidebar(mdPath) + '</nav>\n'
|
|
+ '</aside>\n'
|
|
+ '<main class="main"><div class="content">\n'
|
|
+ ' <div class="topbar"><button class="theme-toggle" type="button" onclick="__t()">theme</button></div>\n'
|
|
+ body
|
|
+ ' <a class="back-to-top" href="#">↑ Back to top</a>\n'
|
|
+ '</div></main>\n</div>\n'
|
|
+ '<script>\n'
|
|
+ " (function(){var k='balinyaar-docs-theme';var s=localStorage.getItem(k);\n"
|
|
+ " if(s)document.documentElement.setAttribute('data-theme',s);\n"
|
|
+ " else if(matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.setAttribute('data-theme','dark');})();\n"
|
|
+ " function __t(){var d=document.documentElement;var n=d.getAttribute('data-theme')==='dark'?'light':'dark';\n"
|
|
+ " d.setAttribute('data-theme',n);localStorage.setItem('balinyaar-docs-theme',n);}\n"
|
|
+ '</script>\n'
|
|
+ mermaidScript + '\n'
|
|
+ '</body>\n</html>\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.');
|