Establish the canonical résumé authoring layer, the heart of the MiTerm redesign toward a single source of truth. - resume.org: canonical résumé as org-roam nodes (one :ID: heading per experience/project/education/org/publication item + profile + skills). - scripts/org-to-json.mjs: contract-first, dependency-free parser that emits a deterministic resume.json declared mirror; --check is the drift canary. - resume.json: generated mirror consumed (next phase) by the terminal and the plain résumé page. - prebuild/predev generate the mirror; .github/workflows/resume-canary.yml fails CI when resume.json drifts from resume.org. - docs/decisions/0001: MADR record of the source/pipeline/topology/minimalism decisions. docs/resume-pipeline.org: reference + mirror table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nt6ycjAMdUMqGTGtbKocrspull/59/head
parent
1f2e543bdb
commit
73f1a30faa
@ -0,0 +1,20 @@
|
|||||||
|
# Drift canary for the résumé declared mirror (praxis: Mirrors and Canaries).
|
||||||
|
# Fails if resume.json is out of sync with its canonical source resume.org.
|
||||||
|
name: resume-canary
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths: ['resume.org', 'resume.json', 'scripts/org-to-json.mjs']
|
||||||
|
pull_request:
|
||||||
|
paths: ['resume.org', 'resume.json', 'scripts/org-to-json.mjs']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- name: Verify resume.json matches resume.org
|
||||||
|
run: node scripts/org-to-json.mjs resume.org resume.json --check
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
:PROPERTIES:
|
||||||
|
:ID: 77df5ef7-abf4-416f-a0fa-82119ab40e66
|
||||||
|
:END:
|
||||||
|
#+TITLE: ADR 0001 — MiTerm Redesign: Source, Pipeline, Topology, Minimalism
|
||||||
|
#+FILETAGS: :adr:miterm:
|
||||||
|
|
||||||
|
Status: accepted (2026-07-16)
|
||||||
|
Deciders: Mihir Talati (navigator), agent (driver)
|
||||||
|
|
||||||
|
Bundled MADR-style record for the four load-bearing decisions that open the
|
||||||
|
MiTerm redesign. Praxis references point at the canonical praxis graph
|
||||||
|
(Emacs-Vault, refactor-v2 branch).
|
||||||
|
|
||||||
|
* Context
|
||||||
|
MiTerm (the terminal résumé site) and its GitHub Pages deployment had drifted
|
||||||
|
into two full, diverging Next.js apps, with résumé content duplicated between
|
||||||
|
~content.json~ (terminal) and a hand-written ~public/resume.html~ (plain page).
|
||||||
|
CRT effects, a dead login overlay, and a per-second clock had accrued. Goal:
|
||||||
|
return to a clean, minimal LiveTerm-like terminal usable by technical managers
|
||||||
|
and non-technical recruiters, with a single, extensible authoring backend.
|
||||||
|
|
||||||
|
* Decision 1 — Canonical résumé source: one ~resume.org~ in the site repo
|
||||||
|
Author the résumé as a single Org file, structured as org-roam nodes (one
|
||||||
|
~:ID:~ heading per item). It is the Single Source of Truth; ~resume.json~ is a
|
||||||
|
generated, declared mirror consumed by both the terminal and the plain page.
|
||||||
|
- Alternatives: org-roam nodes inside the Emacs-Vault graph (rejected now:
|
||||||
|
couples the public build to a private repo — deferred, see Consequences);
|
||||||
|
keep editing ~content.json~ (rejected: no org authoring, weak SSOT).
|
||||||
|
- Praxis: Single Source of Truth; Homoiconicity / Text Is the System;
|
||||||
|
Declared Mirrors.
|
||||||
|
|
||||||
|
* Decision 2 — Build pipeline: standalone Node parser, no Emacs in CI
|
||||||
|
~scripts/org-to-json.mjs~ parses ~resume.org~ → ~resume.json~ as a prebuild
|
||||||
|
step. Flow is one-way; a ~--check~ canary (regenerate + diff) fails CI on drift.
|
||||||
|
- Alternatives: Emacs batch export (rejected: Emacs dependency in CI, slower);
|
||||||
|
manual local export (rejected: mirror silently drifts).
|
||||||
|
- New Code Is a Liability: mature JS org parsers (~orga~, ~uniorg~) were
|
||||||
|
considered and rejected because the schema is small and self-owned — a
|
||||||
|
dependency tree would be more liability than a ~120-line contract-first
|
||||||
|
reader. Divergence from the consensus "use a library" default is deliberate
|
||||||
|
and recorded here.
|
||||||
|
- Praxis: Contract-First Modules; Tangle Discipline; Mirrors and Canaries.
|
||||||
|
|
||||||
|
* Decision 3 — Repo topology: MiTerm canonical, github.io = build output
|
||||||
|
MiTerm holds all source (app, ~resume.org~, parser, config). The static export
|
||||||
|
is published to ~Mihir-Null.github.io~, which serves GitHub Pages (CNAME
|
||||||
|
nullis.me) and holds build output only.
|
||||||
|
- Alternatives: github.io canonical, retire MiTerm (rejected: contradicts the
|
||||||
|
"MiTerm deploys to github.io" model); keep both apps sharing JSON (rejected:
|
||||||
|
maximum duplication).
|
||||||
|
- Consequence: cross-repo publish needs a deploy token/PAT secret — tracked as
|
||||||
|
the one operational dependency, wired in the deploy phase.
|
||||||
|
|
||||||
|
* Decision 4 — Minimalism scope
|
||||||
|
Remove: all CRT effects (barrel-distortion generator, ~crt~ command,
|
||||||
|
warp/curve, CRT CSS); the dead ~.login-*~ overlay CSS; the per-second age/clock
|
||||||
|
ticker (render once, statically). Reduce ~ls~/~sumfetch~ from boxed widgets to
|
||||||
|
flat text — tree as the default listing — while keeping hover highlighting and
|
||||||
|
clickable elements. Keep the joke and web-search commands (low cost, personality).
|
||||||
|
- Praxis: New Code Is a Liability (delete before adding); Atomicity.
|
||||||
|
|
||||||
|
* Consequences
|
||||||
|
- ~resume.org~ Profile is the canonical home for name/contact facts;
|
||||||
|
~config.json~'s duplicate identity fields are slated for removal in the
|
||||||
|
wiring phase so there is one home (SSOT). Until then the overlap is a
|
||||||
|
declared, temporary mirror.
|
||||||
|
- Indexing ~resume.org~ into the Emacs-Vault org-roam graph later stays open
|
||||||
|
via multi-directory org-roam or ~:ROAM_REFS:~ — no lock-in from Decision 1.
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
:PROPERTIES:
|
||||||
|
:ID: 1592cfdd-398a-4604-a843-eed9d0f7d1bb
|
||||||
|
:END:
|
||||||
|
#+TITLE: Résumé Pipeline (Reference)
|
||||||
|
#+FILETAGS: :miterm:reference:
|
||||||
|
|
||||||
|
Diátaxis: reference. How résumé content flows from authoring to the rendered
|
||||||
|
site. See [[file:decisions/0001-miterm-redesign-decisions.org][ADR 0001]] for why.
|
||||||
|
|
||||||
|
* Data flow
|
||||||
|
#+begin_example
|
||||||
|
resume.org canonical source (you edit ONLY this)
|
||||||
|
│ node scripts/org-to-json.mjs resume.org resume.json (one-way, prebuild)
|
||||||
|
▼
|
||||||
|
resume.json declared mirror — generated, never hand-edited
|
||||||
|
│
|
||||||
|
├─► terminal ls / sumfetch / summary read resume.json
|
||||||
|
└─► resume.html plain page reads resume.json
|
||||||
|
│ next build && export
|
||||||
|
▼
|
||||||
|
docs/ (github.io) build output — GitHub Pages, CNAME nullis.me
|
||||||
|
#+end_example
|
||||||
|
|
||||||
|
* Mirror table (praxis: Mirrors and Canaries)
|
||||||
|
| Fact | Canonical home | Mirror(s) | Canary |
|
||||||
|
|------------------------------+-----------------------+------------------+--------------------------------|
|
||||||
|
| résumé content + profile | resume.org | resume.json | ~yarn resume:check~ (regen+diff) |
|
||||||
|
| rendered terminal / plain | resume.json | DOM at runtime | build reads mirror, no copy |
|
||||||
|
|
||||||
|
A mirror without a canary is a defect. ~resume.json~'s canary is the
|
||||||
|
~--check~ mode of the parser, run in CI and before build.
|
||||||
|
|
||||||
|
* How to edit the résumé
|
||||||
|
1. Edit ~resume.org~. To add an item, copy an item heading, mint a fresh
|
||||||
|
~:ID:~ (~org-id-get-create~ or ~uuidgen~), fill the drawer + bullets.
|
||||||
|
2. Run ~yarn resume:build~ (or just ~yarn dev~ / ~yarn build~ — it runs first).
|
||||||
|
3. Commit ~resume.org~ *and* the regenerated ~resume.json~ together, so the
|
||||||
|
canonical change and its mirror land in one commit.
|
||||||
|
|
||||||
|
* Schema (RESUME_SCHEMA: 1)
|
||||||
|
- ~* Section~ headings: Profile, Experience, Projects, Education,
|
||||||
|
Organizations, Publications, Skills.
|
||||||
|
- ~** Item~ headings carry an ~:ID:~ and typed scalar fields in a drawer.
|
||||||
|
- ~- bullet~ → the item's ~bullets~ array.
|
||||||
|
- ~- tag :: value~ → named arrays; Education uses ~degree~, ~honor~, ~course~.
|
||||||
|
- Profile: drawer → identity fields; prose body → ~bio~.
|
||||||
|
- Skills: ~** Category~ headings; bullets → that category's list.
|
||||||
|
|
||||||
|
The parser contract lives at the top of ~scripts/org-to-json.mjs~.
|
||||||
@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// =============================================================================
|
||||||
|
// Module contract (praxis: Contract-First Modules · The Docstring Is the Contract)
|
||||||
|
//
|
||||||
|
// provides — parse the canonical résumé (resume.org, RESUME_SCHEMA: 1) into a
|
||||||
|
// deterministic JSON mirror (resume.json). One concern: org -> json.
|
||||||
|
// requires — Node >=18 (readFileSync, writeFileSync, JSON). No dependencies:
|
||||||
|
// the schema is small and self-owned, so a maintained org parser
|
||||||
|
// (orga / uniorg) would be more liability than a ~120-line reader.
|
||||||
|
// tests must show —
|
||||||
|
// * every `**` item heading with an :ID: becomes one object carrying its id;
|
||||||
|
// * drawer keys map to lowercased fields; tagged bullets (`- k :: v`) route
|
||||||
|
// into named arrays; plain bullets become `bullets`;
|
||||||
|
// * skills categories and the profile drawer + bio prose are captured;
|
||||||
|
// * output is byte-stable (sorted-nothing, fixed key order, 2-space, \n) so
|
||||||
|
// the CI canary (regenerate + git diff) only trips on real drift.
|
||||||
|
//
|
||||||
|
// Flow is ONE-WAY: resume.org -> resume.json. Never hand-edit resume.json.
|
||||||
|
// Usage: node scripts/org-to-json.mjs [resume.org] [resume.json]
|
||||||
|
// With --check it regenerates in memory and exits non-zero if the file on disk
|
||||||
|
// differs (the drift canary), without writing.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const GENERATED_NOTE =
|
||||||
|
'GENERATED FILE — do not edit. Source of truth: resume.org. ' +
|
||||||
|
'Regenerate: node scripts/org-to-json.mjs';
|
||||||
|
|
||||||
|
// Section registry — the fixed schema the parser understands.
|
||||||
|
const SECTIONS = {
|
||||||
|
profile: { kind: 'profile' },
|
||||||
|
experience: { kind: 'items', fields: ['role', 'company', 'dates'] },
|
||||||
|
projects: { kind: 'items', fields: ['name', 'timeline'] },
|
||||||
|
education: {
|
||||||
|
kind: 'items',
|
||||||
|
fields: ['school', 'gpa', 'graduation'],
|
||||||
|
tagged: { degree: 'degrees', honor: 'honors', course: 'coursework' },
|
||||||
|
},
|
||||||
|
organizations: { kind: 'items', fields: ['role', 'org', 'dates'] },
|
||||||
|
publications: { kind: 'items', fields: ['title', 'status'] },
|
||||||
|
skills: { kind: 'skills' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const slug = (s) => s.trim().toLowerCase();
|
||||||
|
const isHeading = (l) => /^\*+\s/.test(l);
|
||||||
|
const headingLevel = (l) => (l.match(/^(\*+)\s/) || [, ''])[1].length;
|
||||||
|
const headingText = (l) => l.replace(/^\*+\s+/, '').trim();
|
||||||
|
|
||||||
|
// Read a :PROPERTIES: ... :END: drawer starting at `start`; return {props, next}.
|
||||||
|
function readDrawer(lines, start) {
|
||||||
|
const props = {};
|
||||||
|
let i = start + 1;
|
||||||
|
for (; i < lines.length; i++) {
|
||||||
|
const t = lines[i].trim();
|
||||||
|
if (t === ':END:') return { props, next: i + 1 };
|
||||||
|
const m = t.match(/^:([A-Za-z0-9_]+):\s*(.*)$/);
|
||||||
|
if (m) props[m[1].toLowerCase()] = m[2].trim();
|
||||||
|
}
|
||||||
|
throw new Error(`Unterminated :PROPERTIES: drawer at line ${start + 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build one item object from its heading, drawer props, and bullet lines.
|
||||||
|
function makeItem(cfg, props, bullets) {
|
||||||
|
const item = {};
|
||||||
|
if (props.id) item.id = props.id;
|
||||||
|
for (const f of cfg.fields) if (props[f] != null) item[f] = props[f];
|
||||||
|
const tagged = cfg.tagged || {};
|
||||||
|
for (const key of Object.values(tagged)) item[key] = [];
|
||||||
|
const plain = [];
|
||||||
|
for (const raw of bullets) {
|
||||||
|
const m = raw.match(/^([A-Za-z0-9_]+)\s*::\s*(.*)$/);
|
||||||
|
if (m && tagged[m[1].toLowerCase()]) {
|
||||||
|
item[tagged[m[1].toLowerCase()]].push(m[2].trim());
|
||||||
|
} else {
|
||||||
|
plain.push(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cfg.tagged) item.bullets = plain;
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(text) {
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const out = {
|
||||||
|
profile: {},
|
||||||
|
experience: [],
|
||||||
|
projects: [],
|
||||||
|
education: [],
|
||||||
|
organizations: [],
|
||||||
|
publications: [],
|
||||||
|
skills: {},
|
||||||
|
};
|
||||||
|
const bioLines = [];
|
||||||
|
let section = null; // {key, cfg}
|
||||||
|
let itemProps = null; // drawer of the open item
|
||||||
|
let itemBullets = null; // bullets of the open item
|
||||||
|
let skillCat = null; // {key, list}
|
||||||
|
|
||||||
|
const flushItem = () => {
|
||||||
|
if (section && section.cfg.kind === 'items' && itemProps !== null) {
|
||||||
|
out[section.key].push(makeItem(section.cfg, itemProps, itemBullets));
|
||||||
|
}
|
||||||
|
itemProps = null;
|
||||||
|
itemBullets = null;
|
||||||
|
};
|
||||||
|
const flushSkill = () => {
|
||||||
|
if (skillCat) out.skills[skillCat.key] = skillCat.list;
|
||||||
|
skillCat = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const t = line.trim();
|
||||||
|
if (t.startsWith('#') || t === '') continue; // comments, keywords, blanks
|
||||||
|
|
||||||
|
if (t === ':PROPERTIES:') {
|
||||||
|
const { props, next } = readDrawer(lines, i);
|
||||||
|
i = next - 1;
|
||||||
|
if (section && section.cfg.kind === 'items' && itemProps !== null) {
|
||||||
|
Object.assign(itemProps, props);
|
||||||
|
} else if (section && section.key === 'profile') {
|
||||||
|
Object.assign(out.profile, props);
|
||||||
|
}
|
||||||
|
// file-level drawer (no section yet) is metadata — ignored.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHeading(line)) {
|
||||||
|
const level = headingLevel(line);
|
||||||
|
const label = headingText(line);
|
||||||
|
if (level === 1) {
|
||||||
|
flushItem();
|
||||||
|
flushSkill();
|
||||||
|
const key = slug(label);
|
||||||
|
section = SECTIONS[key] ? { key, cfg: SECTIONS[key] } : null;
|
||||||
|
if (!section) process.stderr.write(`warn: unknown section "${label}"\n`);
|
||||||
|
} else if (level === 2 && section) {
|
||||||
|
if (section.cfg.kind === 'items') {
|
||||||
|
flushItem();
|
||||||
|
itemProps = {};
|
||||||
|
itemBullets = [];
|
||||||
|
} else if (section.cfg.kind === 'skills') {
|
||||||
|
flushSkill();
|
||||||
|
skillCat = { key: slug(label), list: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.startsWith('- ')) {
|
||||||
|
const val = t.slice(2).trim();
|
||||||
|
if (section && section.cfg.kind === 'items' && itemBullets) itemBullets.push(val);
|
||||||
|
else if (section && section.cfg.kind === 'skills' && skillCat) skillCat.list.push(val);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// free prose — only meaningful inside Profile (the bio).
|
||||||
|
if (section && section.key === 'profile') bioLines.push(t);
|
||||||
|
}
|
||||||
|
flushItem();
|
||||||
|
flushSkill();
|
||||||
|
|
||||||
|
if (bioLines.length) out.profile.bio = bioLines.join(' ');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable serialization: fixed top-level key order, 2-space indent, trailing \n.
|
||||||
|
function serialize(data) {
|
||||||
|
const ordered = {
|
||||||
|
$generated: GENERATED_NOTE,
|
||||||
|
profile: data.profile,
|
||||||
|
experience: data.experience,
|
||||||
|
projects: data.projects,
|
||||||
|
education: data.education,
|
||||||
|
organizations: data.organizations,
|
||||||
|
publications: data.publications,
|
||||||
|
skills: data.skills,
|
||||||
|
};
|
||||||
|
return JSON.stringify(ordered, null, 2) + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = process.argv.slice(2).filter((a) => a !== '--check');
|
||||||
|
const check = process.argv.includes('--check');
|
||||||
|
const src = args[0] || 'resume.org';
|
||||||
|
const dst = args[1] || 'resume.json';
|
||||||
|
|
||||||
|
const json = serialize(parse(readFileSync(src, 'utf8')));
|
||||||
|
|
||||||
|
if (check) {
|
||||||
|
let current = '';
|
||||||
|
try {
|
||||||
|
current = readFileSync(dst, 'utf8');
|
||||||
|
} catch {
|
||||||
|
/* missing file counts as drift */
|
||||||
|
}
|
||||||
|
if (current !== json) {
|
||||||
|
process.stderr.write(
|
||||||
|
`DRIFT: ${dst} is out of sync with ${src}. Run: node scripts/org-to-json.mjs\n`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.stdout.write(`ok: ${dst} matches ${src}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(dst, json);
|
||||||
|
process.stdout.write(`wrote ${dst} from ${src}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Loading…
Reference in new issue