mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
118 lines
4.0 KiB
TypeScript
118 lines
4.0 KiB
TypeScript
import { readFileSync, readdirSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { marked } from 'marked'
|
|
|
|
/**
|
|
* The legal documents, read from `content/legal/*.md` at build time.
|
|
*
|
|
* Those eleven files are the SOURCE — one authoritative version of each
|
|
* document, which every Hanzo surface links to at `hanzo.ai/legal/<slug>`
|
|
* rather than restating. They were written and committed months before
|
|
* anything rendered them, so until this module existed the site served none of
|
|
* them: `/trust` offered the DPA and the subprocessor register as documents "a
|
|
* reviewer reads without asking", and both addresses 404'd.
|
|
*
|
|
* Build-time only — it reads the filesystem, so nothing reaching a client
|
|
* bundle may import it.
|
|
*/
|
|
|
|
export interface LegalDoc {
|
|
slug: string
|
|
title: string
|
|
/** `agreement` | `policy` | `notice` | `register` | `index` — the doc's own word. */
|
|
kind: string
|
|
/** `draft` until counsel clears it. Rendered, never hidden. */
|
|
status: string
|
|
version: string
|
|
updated: string
|
|
summary: string
|
|
order: number
|
|
/** Where the previous version lives, when there is one. */
|
|
previous: string
|
|
/** One row per `##` section, in order; each has its anchor in `html`. */
|
|
toc: { id: string; text: string }[]
|
|
/** The body, already markdown-rendered. */
|
|
html: string
|
|
}
|
|
|
|
const DIR = join(process.cwd(), 'content', 'legal')
|
|
|
|
/**
|
|
* Front matter is a fixed shape we author, so it is split rather than parsed by
|
|
* a YAML library: `key: value` per line, optionally quoted. A value may contain
|
|
* a colon (summaries do), so the split is on the FIRST one only.
|
|
*/
|
|
function frontMatter(raw: string): [Record<string, string>, string] {
|
|
if (!raw.startsWith('---\n')) return [{}, raw]
|
|
const end = raw.indexOf('\n---', 4)
|
|
if (end < 0) return [{}, raw]
|
|
const meta: Record<string, string> = {}
|
|
for (const line of raw.slice(4, end).split('\n')) {
|
|
const at = line.indexOf(':')
|
|
if (at < 0) continue
|
|
const value = line.slice(at + 1).trim()
|
|
meta[line.slice(0, at).trim()] = value.replace(/^["'](.*)["']$/, '$1')
|
|
}
|
|
return [meta, raw.slice(raw.indexOf('\n', end + 1) + 1)]
|
|
}
|
|
|
|
/**
|
|
* Gives every `##` an anchor and returns the contents list that links to them.
|
|
* The id is the heading's words, lower-cased and hyphenated, so a section keeps
|
|
* its address when another is added above it.
|
|
*/
|
|
function anchors(html: string): [string, { id: string; text: string }[]] {
|
|
const toc: { id: string; text: string }[] = []
|
|
const out = html.replace(/<h2>(.*?)<\/h2>/g, (_, inner: string) => {
|
|
const text = inner.replace(/<[^>]+>/g, '')
|
|
const id = text
|
|
.toLowerCase()
|
|
.replace(/&[a-z#0-9]+;/g, '')
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
toc.push({ id, text })
|
|
return `<h2 id="${id}">${inner}</h2>`
|
|
})
|
|
return [out, toc]
|
|
}
|
|
|
|
function read(file: string): LegalDoc {
|
|
const [meta, body] = frontMatter(readFileSync(join(DIR, file), 'utf8'))
|
|
const [html, toc] = anchors(marked.parse(body, { async: false }) as string)
|
|
const slug = meta.slug || file.replace(/\.md$/, '')
|
|
return {
|
|
slug,
|
|
title: meta.title || slug,
|
|
kind: meta.kind || 'policy',
|
|
status: meta.status || 'draft',
|
|
version: meta.version || '',
|
|
updated: meta.updated || '',
|
|
summary: meta.summary || '',
|
|
order: Number(meta.order ?? 99),
|
|
previous: meta.previous || '',
|
|
toc,
|
|
html,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every file in the directory except the index and anything a leading
|
|
* underscore marks private — the implementation checklist is written to be
|
|
* read by engineers and is never a public route.
|
|
*/
|
|
function files(): string[] {
|
|
return readdirSync(DIR)
|
|
.filter((f) => f.endsWith('.md') && !f.startsWith('_') && f !== 'index.md')
|
|
.sort()
|
|
}
|
|
|
|
export const legalSlugs = (): string[] => files().map((f) => f.replace(/\.md$/, ''))
|
|
|
|
export const legalDocs = (): LegalDoc[] => files().map(read).sort((a, b) => a.order - b.order)
|
|
|
|
export function legalDoc(slug: string): LegalDoc | undefined {
|
|
return legalSlugs().includes(slug) ? read(`${slug}.md`) : undefined
|
|
}
|
|
|
|
export const legalIndex = (): LegalDoc => read('index.md')
|