Merge pull request #1 from Mihir-Null/claude/miterm-redesign-cleanup-vhd49u
Add resume.org SSOT + org→json pipeline (redesign phase 1)pull/59/head
commit
82ac784148
@ -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