Files
hanzo.ai/lib/legal.ts

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')