Files
narl-workspace/scripts/wiki.ts
T
nvrl 0eabcb2333 keys: one table drives dispatch, help, footers, the rail and the wiki
keymap.ts lists every key with its context, action and help line.
actions.ts holds what each one does, typed against the table so a key
without an action, or an action without a key, does not compile.
modes.ts stacks the contexts (always > sheet > drawer > workspace >
writing > focused tile > special > global) and writes the two-key
pending logic once. App.svelte's 480-line onkeydown is now one call.

Help, the panel footers (a shared Keys component), the rail's keycaps
and Commands.md all read the table; the wiki gains the card and
scratchpad key tables.

One fix on the way: Ctrl-S in the editor no longer saves twice, which
could make the second save fail as "changed somewhere else".
2026-10-22 03:42:19 +02:00

278 lines
11 KiB
TypeScript

/*
* The wiki, from the files it describes: `make wiki`.
*
* docs/wiki holds the pages. Three of them are not written by hand but read
* out of the code, so they cannot drift from it:
*
* - Commands.md, from the keymap in keymap.ts and the commands in
* commands.ts, the same lists `:help` draws;
* - Configuration.md, from the comments and values in .env.example;
* - API.md, from the API section of README.md.
*
* `bun scripts/wiki.ts` writes those three and checks that every link between
* pages and every image resolves. `--publish <dir>` also lays the wiki out for
* GitHub: links without `.md`, images beside the pages, a footer saying where
* the pages come from. CI does that on every push to main and pushes the result
* to the wiki repository; `--check` fails when a generated page is stale.
*/
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { rows, sections, type Section } from '../web/src/lib/keymap'
const root = resolve(import.meta.dir, '..')
const pages = join(root, 'docs/wiki')
const images = join(root, 'docs/images')
const repo = process.env.GITHUB_REPOSITORY ?? 'narrrl/narl-workspace'
const read = (path: string) => readFileSync(join(root, path), 'utf8')
const args = process.argv.slice(2)
const check = args.includes('--check')
const publish = args.includes('--publish') ? args[args.indexOf('--publish') + 1] : undefined
if (args.includes('--publish') && !publish) throw new Error('--publish needs a directory')
const generated = (source: string) =>
`<!-- Generated by scripts/wiki.ts from ${source}. Edit that, then run \`make wiki\`. -->\n\n`
/** A JS string literal's value: `'it\'s'` or `"it's"`. */
function unquote(literal: string): string {
return literal.slice(1, -1).replace(/\\(.)/g, '$1')
}
const STRING = `('(?:[^'\\\\]|\\\\.)*'|"(?:[^"\\\\]|\\\\.)*")`
/** `|` inside a table cell would end it, and a GitHub wiki reads `[[x]]` as a link to page x. */
const cell = (text: string) => text.replace(/\|/g, '\\|').replace(/\[\[/g, '\\[\\[')
/** A key or command as code, safe in a table even when it holds a backtick. */
const code = (text: string) => (text.includes('`') ? `<code>${cell(text).replace(/</g, '&lt;')}</code>` : `\`${cell(text)}\``)
// --- Commands.md ---------------------------------------------------------------
function keyTable(section: Section): string {
const entry = sections.find((candidate) => candidate.section === section)!
const table = ['| key | what it does |', '| --- | --- |', ...rows(section).map(({ key, description }) => `| ${code(key)} | ${cell(description)} |`)].join('\n')
// A rule is not a key but the sentence the help column starts with.
return entry.rule ? `${entry.rule[0].toUpperCase()}${entry.rule.slice(1)}.\n\n${table}` : table
}
interface Spec {
name: string
aliases: string[]
args?: string
group: string
help: string
bang?: string
}
function commandSpecs(source: string): Spec[] {
const start = source.indexOf('export const commands: CommandSpec[] = [')
const end = source.indexOf('\n]\n', start)
// Every command is an object literal opened on a line of its own, two in.
const blocks = source.slice(start, end).split('\n {\n').slice(1)
const field = (block: string, name: string, indent = 4) =>
new RegExp(`^${' '.repeat(indent)}${name}: ${STRING},?$`, 'm').exec(block)?.[1]
return blocks.map((block) => {
const name = field(block, 'name')
const group = field(block, 'group')
const help = field(block, 'help')
if (!name || !group || !help) throw new Error(`commands.ts: a command without a name, group or help:\n${block.slice(0, 200)}`)
const aliases = /^ aliases: \[(.*)\],$/m.exec(block)?.[1]
const bang = /^ bang: \{/m.test(block) ? field(block, 'help', 6) : undefined
const args = field(block, 'args')
return {
name: unquote(name),
group: unquote(group),
help: unquote(help),
aliases: aliases ? [...aliases.matchAll(new RegExp(STRING, 'g'))].map(([literal]) => unquote(literal)) : [],
args: args && unquote(args),
bang: bang && unquote(bang),
}
})
}
function commandsPage(): string {
const help = read('web/src/lib/Help.svelte')
const source = read('web/src/lib/commands.ts')
const groups = [.../export const groups = \[(.*)\] as const/.exec(source)![1].matchAll(new RegExp(STRING, 'g'))].map(([g]) => unquote(g))
const titles = Object.fromEntries(
[...help.matchAll(new RegExp(`^ (\\w+): ${STRING},$`, 'gm'))].map(([, group, title]) => [group, unquote(title)]),
)
const specs = commandSpecs(source)
const sections = groups.map((group) => {
const rows = specs
.filter((spec) => spec.group === group)
.flatMap((spec) => {
const names = [spec.name, ...spec.aliases].map((name) => `:${name}`).join(', ')
const usage = spec.args ? `${names} ${spec.args}` : names
const row = `| ${code(usage)} | ${cell(spec.help)} |`
return spec.bang ? [row, `| ${code(`:${spec.name}!`)} | ${cell(spec.bang)} |`] : [row]
})
const title = titles[group] ?? group
return `### ${title[0].toUpperCase()}${title.slice(1)}\n\n| command | what it does |\n| --- | --- |\n${rows.join('\n')}`
})
return (
generated('web/src/lib/keymap.ts and web/src/lib/commands.ts') +
`# Keys and commands
Everything here is also on \`?\` (\`:help\`) inside the app, which draws the same
lists. Keys act on whatever has focus: the row under the cursor in the list, or
the document in the focused tile. Inside the editor the full vim keymap is live,
and \`:\` opens the command line, where \`Tab\` completes commands and their
arguments and \`↑\` / \`↓\` recall earlier lines.
## Keys
### Browsing and reading
${keyTable('keys')}
### On a board
${keyTable('board')}
### On the back of a card
${keyTable('card')}
### In the agenda
${keyTable('agenda')}
### In the scratchpad
${keyTable('scratch')}
## Commands
A command takes a \`!\` for its forcing variant where one exists, the way vim's
do: \`:w!\`, \`:space! <name>\`, \`:import!\`.
${sections.join('\n\n')}
`
)
}
// --- Configuration.md -------------------------------------------------------------
function configurationPage(): string {
const lines = read('.env.example').split('\n')
const out: string[] = []
let comment: string[] = []
let table: string[] = []
const flush = () => {
if (table.length) out.push('| setting | example | what it does |', '| --- | --- | --- |', ...table, '')
table = []
}
for (const line of lines) {
const section = /^# --- (.+?) -+$/.exec(line)
const setting = /^(?:# )?(WORKSPACE_\w+)=(.*)$/.exec(line)
if (section) {
flush()
out.push(`## ${section[1]}`, '')
comment = []
} else if (setting) {
const [, name, value] = setting
// A comment above two settings belongs to both: USER and PASSWORD.
const text = comment.join(' ').replace(/\s+/g, ' ').trim() || 'See the row above.'
const unset = line.startsWith('#') && !/off until/i.test(text) ? ' Unset by default.' : ''
table.push(`| \`${name}\` | ${value ? `\`${cell(value)}\`` : ''} | ${cell(text)}${unset} |`)
comment = []
} else if (line.startsWith('#')) {
comment.push(line.replace(/^# ?/, ''))
} else if (line.trim() === '') {
// A blank line ends a paragraph that belonged to no single setting.
if (comment.length && table.length === 0 && out.length) out.push(comment.join(' ').trim(), '')
comment = []
}
}
flush()
return (
generated('.env.example') +
`# Configuration
Every setting is an environment variable, read at start, usually from a \`.env\`
file beside the binary (\`cp .env.example .env\`). The older \`DIARY_<NAME>\`
spelling of each is still accepted. A value that is set but cannot be read stops
the start with the reason rather than falling back to the default.
## Basics
${out.join('\n').trim()}
`
)
}
// --- API.md -----------------------------------------------------------------------
function apiPage(): string {
const readme = read('README.md')
const start = readme.indexOf('\n## API\n')
const end = readme.indexOf('\n## ', start + 1)
if (start < 0) throw new Error('README.md has no API section')
return generated('the API section of README.md') + '# API\n' + readme.slice(start + '\n## API\n'.length, end).trimEnd() + '\n'
}
// --- writing, checking, publishing -----------------------------------------------
const outputs: Record<string, string> = {
'Commands.md': commandsPage(),
'Configuration.md': configurationPage(),
'API.md': apiPage(),
}
let stale = false
for (const [name, text] of Object.entries(outputs)) {
const path = join(pages, name)
const before = existsSync(path) ? readFileSync(path, 'utf8') : ''
if (before === text) continue
if (check) {
console.error(`docs/wiki/${name} is out of date: run \`make wiki\``)
stale = true
} else {
writeFileSync(path, text)
console.log(`docs/wiki/${name}`)
}
}
const names = readdirSync(pages).filter((name) => name.endsWith('.md'))
const broken: string[] = []
for (const name of names) {
// Code shows links as examples; only the prose around it links.
const text = readFileSync(join(pages, name), 'utf8')
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`\n]*`/g, '')
for (const [, target] of text.matchAll(/\]\(([^)\s]+)\)/g)) {
if (/^(https?:|mailto:|#)/.test(target)) continue
const path = target.split('#')[0]
const exists = path.startsWith('../') ? existsSync(join(pages, path)) : names.includes(path)
if (!exists) broken.push(`docs/wiki/${name}: ${target}`)
}
}
if (broken.length) {
console.error(`links that lead nowhere:\n ${broken.join('\n ')}`)
process.exit(1)
}
if (stale) process.exit(1)
if (publish) {
const dir = resolve(publish)
// Everything but the wiki's own git directory is ours to replace.
if (existsSync(dir)) {
for (const entry of readdirSync(dir)) if (entry !== '.git') rmSync(join(dir, entry), { recursive: true })
}
mkdirSync(dir, { recursive: true })
cpSync(images, join(dir, 'images'), { recursive: true })
for (const name of names) {
const text = readFileSync(join(pages, name), 'utf8')
.replace(/\]\(\.\.\/images\//g, '](images/')
.replace(/\]\(\.\.\/\.\.\/([^)]+)\)/g, `](https://github.com/${repo}/blob/main/$1)`)
.replace(/\]\(([\w-]+)\.md(#[^)]*)?\)/g, (_, page, anchor = '') => `](${page}${anchor})`)
writeFileSync(join(dir, name), text)
}
writeFileSync(
join(dir, '_Footer.md'),
`Written in [\`docs/wiki\`](https://github.com/${repo}/tree/main/docs/wiki) and published by CI on every push to main; an edit made here is overwritten by the next one.\n`,
)
console.log(`wiki laid out in ${dir}`)
}