LexBuild converts U.S. legal source data into structured Markdown for AI/RAG ingestion. It currently supports federal sources: U.S. Code (USLM schema), eCFR (GPO/SGML-derived XML), and Federal Register (FederalRegister.gov API), with an architecture designed for additional sources at the federal (public laws, congressional bills), state, and local level. It is a monorepo built with Turborepo, pnpm workspaces, TypeScript, and Node.js.
lexbuild/
├── packages/
│ ├── core/ # @lexbuild/core — XML parsing, AST, Markdown rendering, shared utilities
│ ├── usc/ # @lexbuild/usc — U.S. Code-specific element handlers and downloader
│ ├── ecfr/ # @lexbuild/ecfr — eCFR (Code of Federal Regulations) converter and downloader
│ ├── fr/ # @lexbuild/fr — Federal Register converter and downloader
│ └── cli/ # @lexbuild/cli — CLI binary (the published npm package users install)
├── apps/
│ ├── astro/ # LexBuild web app — Astro 6, SSR, browse U.S. Code + eCFR as Markdown
│ └── api/ # @lexbuild/api — Data API (Hono, SQLite, Meilisearch proxy)
├── scripts/
│ ├── deploy.sh # Production deploy (code, content, or full remote pipeline)
│ ├── update.sh # Unified content update orchestrator (incremental by default)
│ ├── update-ecfr.sh # eCFR sub-script (change-detection via API metadata)
│ ├── update-fr.sh # FR sub-script (checkpoint-based date window)
│ ├── update-usc.sh # USC sub-script (release-point detection)
│ ├── ecfr-changed-titles.ts # eCFR change detection helper (API metadata vs checkpoint)
│ ├── setup-secrets.sh # Initialize ~/.lexbuild-secrets on VPS
│ └── .deploy.env.example # Template for .deploy.env (VPS_HOST config)
├── downloads/
│ ├── usc/
│ │ ├── xml/ # Full USC XML files (usc01.xml ... usc54.xml) — gitignored
│ │ └── .usc-release-point # USC checkpoint (latest OLRC release point ID)
│ ├── ecfr/
│ │ ├── xml/ # Full eCFR XML files (ECFR-title1.xml ... ECFR-title50.xml) — gitignored
│ │ └── .ecfr-titles-state.json # eCFR checkpoint (per-title amendment dates)
│ └── fr/ # FR XML + JSON files (YYYY/MM/doc-number.xml/.json) — gitignored
│ └── .fr-state.json # FR checkpoint ({ lastRun, lastDate })
├── fixtures/
│ ├── fragments/ # Small synthetic XML snippets for unit tests
│ └── expected/ # Expected output snapshots for integration tests
├── turbo.json # Turborepo pipeline config
└── CLAUDE.md # This file
Each package and app has its own CLAUDE.md with architecture details, module structure, and package-specific conventions:
packages/core/CLAUDE.md— XML→AST→Markdown pipeline, emit-at-level streaming, AST node types, rendering, link resolution, resilient filesystem utilitiespackages/usc/CLAUDE.md— Collect-then-write pattern, granularity output, edge cases (duplicates, appendices), downloaderpackages/ecfr/CLAUDE.md— eCFR GPO/SGML XML→AST→Markdown, DIV-based hierarchy, element classification, downloaderpackages/fr/CLAUDE.md— Federal Register XML→AST→Markdown, document-centric structure, dual JSON+XML ingestion, API downloaderpackages/cli/CLAUDE.md— Commands, options, UI module, title parser, build configapps/astro/CLAUDE.md— Astro 6 SSR site, island architecture, multi-source content browser, deploymentapps/api/CLAUDE.md— Data API (Hono + SQLite + Meilisearch), endpoints, rate limiting, deployment
.claude/rules/conventions.md— Coding conventions, architectural rules, and quality standards for the LexBuild monorepo.claude/reference/uslm-xml-user-guide.md— USLM XML user guide.claude/reference/uslm-schema/— USLM XML schema reference files (XSD, documentation).claude/reference/ecfr-xml-user-guide.md— Electronic Code of Federal Regulations XML user guide.claude/reference/fr-xml-user-guide.md— Federal Register XML User Guide.claude/reference/cfr-xml-user-guide.md— Code of Federal Regulations XML user guide.claude/reference/bills-xml-user-guide.md— Bills XML user guide.claude/reference/bills-summary-xml-user-guide.md— Bills Summary XML user guide.claude/llms/- Various llms.txt files for Cloudflare, Astro, Hono, and Scalar
- Runtime: Node.js >= 22 LTS (ESM)
- Language: TypeScript 5.x, strict mode, no
anyunless explicitly justified - XML Parsing:
saxes(SAX streaming) - CLI:
commander - CLI Output:
chalk,ora,cli-table3 - Fonts: IBM Plex Sans (body), Serif (display), Mono (code) — self-hosted via
@fontsource. No other font families. - YAML:
yamlpackage - Zip:
yauzl - Token Counting: character/4 heuristic
- Testing:
vitest - Build:
tsup - Linting: ESLint +
@typescript-eslint - Unused code detection:
knip(config:knip.jsonc— must use this exact filename, notknip.config.jsonc) - Formatting: Prettier (double quotes, trailing commas, 100 char print width)
- Monorepo: Turborepo + pnpm workspaces
- Versioning:
@changesets/cliwith lockstep versioning across all packages - Root and Astro app versions are manually synced with published packages. After changesets bump
packages/*to a new version, also updateversionin rootpackage.jsonandapps/astro/package.jsonto match, and add a corresponding entry to rootCHANGELOG.md.
# Core monorepo commands (from repo root)
pnpm install
pnpm turbo build # Build all packages
pnpm turbo build --filter=@lexbuild/core # Build specific package
pnpm turbo test # Run all tests
pnpm turbo test --filter=@lexbuild/usc # Test specific package
pnpm turbo typecheck
pnpm turbo lint
pnpm turbo dev # Watch + rebuild
# Run the CLI locally (build first)
node packages/cli/dist/index.js download-usc --all
node packages/cli/dist/index.js convert-usc --all
node packages/cli/dist/index.js download-ecfr --all
node packages/cli/dist/index.js convert-ecfr --all
node packages/cli/dist/index.js download-fr --recent 30
node packages/cli/dist/index.js convert-fr --all
node packages/cli/dist/index.js enrich-fr --from 2000-01-01 # Only needed for govinfo bulk downloads
node packages/cli/dist/index.js list-release-points
# Astro app — NOT included in default `pnpm turbo build`
pnpm turbo dev:astro --filter=@lexbuild/astro # Dev server (http://localhost:4321)
pnpm turbo build:astro --filter=@lexbuild/astro # Production build
# Data API — NOT included in default `pnpm turbo build`
pnpm turbo dev:api --filter=@lexbuild/api # Dev server (http://localhost:4322)
pnpm turbo build:api --filter=@lexbuild/api # Production build
# Deploy to production VPS (from monorepo root)
./scripts/deploy.sh # Code only (git pull, build, pm2 reload)
./scripts/deploy.sh --content # Code + rsync local output/ to VPS
./scripts/deploy.sh --content-only # Rsync only, no code deploy
./scripts/deploy.sh --nav-only # Rsync nav JSON only
./scripts/deploy.sh --sitemaps-only # Rsync sitemaps only
./scripts/deploy.sh --highlights-only # Rsync .highlighted.html files only
./scripts/deploy.sh --remote # Full pipeline on VPS (download + convert + build)
./scripts/deploy.sh --api # Deploy API code (git pull, build:api, pm2 reload)
./scripts/deploy.sh --api-db # Sync lexbuild.db to VPS + reload API
./scripts/deploy.sh --api-full # API code + database sync + reload
./scripts/deploy.sh --search-docker # Build search index in Docker, transfer to VPS
./scripts/deploy.sh --search-docker --source fr # Incremental: index one source into existing volume
./scripts/deploy.sh --search-docker-seed # Seed Docker volume from VPS (recover after volume loss)
# Content updates (from monorepo root)
# Default behavior is incremental from each source's checkpoint. Search indexing
# runs locally in Docker; each sub-script delegates to `deploy.sh --search-docker
# --source <name>` after the local pipeline.
./scripts/update.sh # All sources, incremental from checkpoints
./scripts/update.sh --source fr # One source, incremental
./scripts/update.sh --source ecfr,fr # Multi-source, incremental
./scripts/update.sh --source ecfr --titles 1,17 # eCFR titles 1, 17 only (skip change-detection)
./scripts/update.sh --source fr --days 7 # FR last 7 days
./scripts/update.sh --source usc --force # USC full redownload + reconvert
./scripts/update.sh --force --from 2026-01-01 # All sources, full rebuild (FR force requires --from)
./scripts/update.sh --skip-deploy # Local only (no rsync, no search)
./scripts/update.sh --skip-search # Rsync content/nav, but skip search reindex
./scripts/update.sh --deploy-only # Push existing local output + reindex
./scripts/update.sh --dry-run # Print plan, exit 0
./scripts/update.sh -v / --verbose # Pass --verbose through to each convert stepSub-scripts (update-ecfr.sh, update-fr.sh, update-usc.sh) accept the same flag grammar
minus --source. Run any of them with --help for the full list.
Checkpoints (gitignored, in downloads/<source>/):
usc/.usc-release-point— latest OLRC release point ID (plain text).ecfr/.ecfr-titles-state.json— per-titlelatestAmendedOnsnapshot.fr/.fr-state.json—{ lastRun, lastDate }. Defaultupdate-fr.shuseslastDateas--from.
If a checkpoint is missing, eCFR/USC bootstrap into a full first-run automatically. FR bootstrap
errors with a hint; you must specify --from YYYY-MM-DD or --days N because FR has no inherent
"all" (decades of documents).
See packages/cli/CLAUDE.md for full command options. See apps/astro/CLAUDE.md for content pipeline scripts.
- Publish workflow (
.github/workflows/publish.yml): Useschangesets/actionwithcommitMode: github-apiand a GitHub App token (lexbuild-release-bot) for verified commits that satisfy branch protection. Secrets:RELEASE_BOT_APP_ID,RELEASE_BOT_PRIVATE_KEY(repo secrets).
The Astro app (apps/astro/) is deployed to a self-managed VPS (AWS Lightsail) behind Cloudflare's edge cache. It has no code dependency on @lexbuild/core, @lexbuild/usc, or @lexbuild/cli. See apps/astro/CLAUDE.md for the full architecture spec.
- Excluded from
pnpm turbo build— nobuildscript in itspackage.json(onlybuild:astro). Prevents CI failures since the app requires content files that aren't in git. - Excluded from changesets —
"private": trueand listed in.changeset/config.jsonignore. - Content is gitignored —
apps/astro/content/,public/nav/,public/sitemap.xml,*.highlighted.htmlare all generated artifacts.
The Data API (apps/api/) serves legal content programmatically at https://lexbuild.dev/api/. Hono + SQLite + Meilisearch. See apps/api/CLAUDE.md for the full spec.
- Excluded from
pnpm turbo build— usesbuild:api(same pattern as Astro app) - Excluded from changesets —
"private": true - Two SQLite databases:
lexbuild.db(content, read-only, rebuilt bylexbuild ingest) andlexbuild-keys.db(API keys, read-write, persists across re-ingestion) better-sqlite3native bindings are platform-specific — macOS binaries don't work on Linux. Runpnpm installon the VPS after code deployment.- API port 4322 is not exposed in the Lightsail firewall — traffic reaches the API through Caddy (ports 80/443). Same pattern as Meilisearch on 7700.
- VPS needs
build-essentialforbetter-sqlite3—sudo apt-get install -y build-essential. Required once for native addon compilation.
- pnpm workspaces with
workspace:*protocol for internal deps - Transitive dependency vulnerabilities: Dependabot cannot update transitive deps in pnpm monorepos. Use
pnpm.overridesin rootpackage.json(e.g.,"flatted": "^3.4.2"). Use^ranges (not>=) for determinism. pnpm.overridesversion-range selectors: Use"picomatch@^2": "^2.3.2"to target a specific major version range when a transitive dep exists at multiple majors (e.g., picomatch v2 from changesets and picomatch v4 from astro). Without the@^2selector, the override applies to all versions and can break packages expecting a different major.- ESM only (
"type": "module"in all package.json files) - Strict mode:
strict: true,noUncheckedIndexedAccess: true,exactOptionalPropertyTypes: true - Use
import typefor type-only imports - Prefer
interfaceovertypefor object shapes (better error messages, declaration merging) - All exported functions and types must have JSDoc comments
- Use
unknownoverany; ifanyis truly needed, add// eslint-disable-next-line @typescript-eslint/no-explicit-anywith a comment explaining why - Barrel exports via
index.tsin each packagesrc/
- Project name: "LexBuild" in prose/descriptions/titles. Lowercase
lexbuildonly for package names (@lexbuild/*), CLI commands (lexbuild convert-usc), URLs, directory paths, and code identifiers. - CLI commands follow
{action}-{source}pattern:download-usc,convert-usc,download-ecfr,convert-ecfr. Baredownload/convertcommands show a source selection error. - Files:
kebab-case.ts - Types/Interfaces:
PascalCase(e.g.,SectionNode,ConvertOptions) - Functions:
camelCase(e.g.,parseIdentifier,renderSection) - Constants:
UPPER_SNAKE_CASEfor true constants (e.g.,USLM_NAMESPACE) - Enum-like objects:
PascalCasekeys usingas constsatisfies pattern
- Use custom error classes extending
Errorwithcausechaining preserve-caught-errorlint rule (fromtseslint.configs.strict): When re-throwing errors in catch blocks, always attach{ cause: err }to the new Error. E.g.,throw new Error("context message", { cause: err }).- XML parsing errors: warn and continue (log malformed elements, don't crash on anomalous structures)
- File I/O errors: throw with context (file path, operation attempted)
- Never swallow errors silently — at minimum, log at
warnlevel
- Co-locate test files:
parser.ts→parser.test.tsin same directory - Use
describeblocks mirroring the module's exported API - Snapshot tests for Markdown output stability (update snapshots intentionally, not casually)
- Name test cases descriptively:
it("converts <subsection> with chapeau to indented bold-lettered paragraph")
- USLM User Guide (PDF) — v0.1.4, Oct 2013. Covers abstract/concrete model, identification, referencing, metadata, versioning, and presentation models.
- USLM Schema & CSS — USLM-1.0.xsd, USLM-1.0.15.xsd, usctitle.css, Dublin Core schemas, XHTML schema
The XML files use the USLM 1.0 schema (patch level 1.0.15). Namespace: http://xml.house.gov/schemas/uslm/1.0. See packages/core/CLAUDE.md for element classification details.
title > subtitle > chapter > subchapter > article > subarticle > part > subpart > division > subdivision
> section (PRIMARY LEVEL)
> subsection > paragraph > subparagraph > clause > subclause > item > subitem > subsubitem
Additional level elements: <preliminary> (outside main hierarchy), <compiledAct>, <courtRules>/<courtRule>, <reorganizationPlans>/<reorganizationPlan> (title appendices).
Important: The schema intentionally does NOT enforce strict hierarchy — any <level> can nest inside any <level>. This is a deliberate design choice, not a bug.
LexBuild uses canonical URI paths as identifiers for all sources:
USC identifiers (from USLM identifier attributes):
/us/usc/t{title}/s{section} — e.g., /us/usc/t1/s1
Reference prefixes (big levels): t = title, st = subtitle, ch = chapter, sch = subchapter, art = article, p = part, sp = subpart, d = division, sd = subdivision, s = section. Small levels (subsection and below) use their number directly without a prefix.
CFR identifiers (constructed by the eCFR builder from NODE and N attributes):
/us/cfr/t{title}/s{section} — e.g., /us/cfr/t17/s240.10b-5
Note: identifiers use /us/cfr/ (content type) not /us/ecfr/ (data source). Both eCFR and future annual CFR use the same identifier space.
FR identifiers (from FederalRegister.gov document numbers):
/us/fr/{document_number} — e.g., /us/fr/2026-06029
Link resolution: /us/usc/ → relative links or OLRC fallback URLs. /us/cfr/ → relative links or ecfr.gov fallback URLs. /us/fr/ → relative links or federalregister.gov fallback URLs. /us/stat/, /us/pl/ → plain text citations.
-
SAX over DOM: Large titles (26, 42) can exceed 100MB XML. SAX streaming keeps memory bounded. DOM is not used.
-
Section as the atomic unit: A section is the smallest citable legal unit in both USC and CFR. Subsections, paragraphs, etc. are rendered within the section file, not as separate files.
-
Frontmatter + sidecar index: Both YAML frontmatter on every .md file AND
_meta.jsonper directory. Frontmatter enables file-level RAG ingestion. Sidecar enables index-based retrieval without parsing every file. -
Multi-source frontmatter: Every file includes
source("usc","ecfr", or"fr") andlegal_statusfields. Source-specific optional fields (e.g.,authority,cfr_part) are included when relevant. Thesourcediscriminator lets consumers know which fields to expect. -
Relative cross-reference links: Cross-refs within the converted corpus use relative Markdown links. USC refs fall back to OLRC website URLs; CFR refs fall back to ecfr.gov URLs.
-
Notes included by default: All notes (editorial, statutory, amendments) are included by default. Notes can be disabled with
--no-include-notesor selectively filtered with--include-editorial-notes,--include-statutory-notes,--include-amendments. -
Identifier scheme: USC uses
/us/usc/identifiers from USLMidentifierattributes. CFR uses/us/cfr/identifiers constructed from the eCFRNODEandNattributes. Both eCFR and future annual CFR share the/us/cfr/space since they represent the same content. -
Resilient file I/O:
@lexbuild/coreexportswriteFile,writeFileIfChanged, andmkdirwrappers (packages/core/src/fs.ts).writeFileretries onENFILE/EMFILEerrors with exponential backoff.writeFileIfChangedadditionally compares content before writing, preserving mtimes on unchanged files so downstream tools (highlights, search indexing) skip reprocessing. -
Secrets management:
~/.lexbuild-secretson the VPS is the single source of truth.ecosystem.config.cjsreads secrets fromprocess.env(populated via~/.zshenv→~/.lexbuild-secrets)..env.productionis generated byscripts/deploy.shon every deploy — never manually maintained.
- macOS zsh aliases
cpandmvto-iby default: interactive overwrite prompts will block scripted flows and Bash tool calls (which can't respond). Usecat src > destfor file overwrites,command cp/command mvto bypass the alias, orcp -f/mv -fto force. - macOS ships bash 3.2 +
set -ucrashes empty-array expansion:/usr/bin/env bashresolves to/bin/bash3.2.57 on macOS.printf '%s\n' "${arr[@]}"and"${arr[@]}"at call sites firebash: arr[@]: unbound variablewhen the array is empty. Use the bash 3.2-safe pattern:printf '%s\n' "${arr[@]+"${arr[@]}"}"(and same at call sites). Affects every script inscripts/since they all useset -euo pipefail. set -u+$2in value-taking case arms: a case arm like--foo) some_helper "hint $2" ;;crashes withbash: $2: unbound variableif the user runsscript --foo(no value) — before the helper's error-handling runs. Use${2:-<placeholder>}. The migration helpers inscripts/update.share the live example.- macOS BSD sed vs GNU sed brace blocks:
sed -n '2,/^$/{ s/^# //; p }'(multi-command brace block) breaks on BSD sed with "extra characters at the end of p command". Useawk 'NR==1{next} /^$/{exit} {sub(/^# ?/, ""); print}'for cross-platform multi-line extraction. The update scripts use this pattern for--help. - Hono v4 HTTPException bypasses middleware catch blocks: In Hono v4,
HTTPExceptionis intercepted at the compose layer before middleware try-catch runs. Useapp.onError()(not middleware) to handleHTTPException. The Data API configures this inapps/api/src/app.ts. - USC snapshot fixtures use a
lexbuild@__VERSION__placeholder:fixtures/expected/*.mdstore the generator field aslexbuild@__VERSION__. The snapshot test (packages/usc/src/snapshot.test.ts) normalizes the livegenerator: "lexbuild@X.Y.Z"line to the same placeholder before comparison via anormalizeGenerator()helper, so version bumps do NOT churn the fixtures — no sed bump needed on release. (Historical note: before this normalization landed, the changesets Version Packages PR would fail CI because it bumpedpackage.jsonbut not the fixtures; the documented recovery was either to update fixtures in that PR or do a fully manual version bump.) - Changeset
majorwith lockstep versioning bumps ALL packages: All 6 published packages are in thefixedarray. Amajorchangeset on any one package bumps every package to the next major (e.g., 1.x → 2.0.0). Preferminororpatchunless all packages genuinely have breaking changes. ??does not catch empty strings:"" ?? "fallback"returns"", not"fallback". Use||when empty strings should be treated as falsy (e.g., date components defaulting to"0000").consttemporal dead zone in closures: A closure that captures aconstvariable defined later in the same scope will throwReferenceErrorwhen invoked — even though the closure itself is defined without error. Watch for this withresolveLinkcallbacks that referenceoutputPath.- XHTML namespace tables:
<table>elements in USC XML are in the XHTML namespace, not the USLM namespace. The SAX parser must handle namespace-aware element names. - Anomalous structures: Some sections have non-standard nesting (e.g.,
<paragraph>directly under<section>without a<subsection>). Handlers must not assume strict hierarchy. - Empty/repealed sections: Some sections contain only a
<note>with status information (e.g., "Repealed" or "Transferred"). These should still produce an output file with appropriate frontmatter. - Multiple
<p>elements in content: A single<content>or<note>may contain multiple<p>elements. Each should be a separate paragraph in Markdown output. - Permissive content model:
<content>usesprocessContents="lax"withnamespace="##any"— it can contain elements from any namespace. The SAX parser must handle unexpected elements gracefully. <continuation>is interstitial: Not just "after sub-levels" but also between elements of the same level. Handle as a text block in whatever position it appears.- Quoted content sections:
<section>elements inside<quotedContent>(quoted bills in statutory notes) must not be emitted as standalone files. TrackquotedContentDepthto suppress emission. - Duplicate section numbers: Some titles have multiple sections with the same number within a chapter (e.g., Title 5). Output files are disambiguated with
-2suffixes. - CLI
-oflag appends source subdirectories:convert-usc -o /some/pathwrites to/some/path/usc/..., not/some/path/...directly. Same for eCFR. - gray-matter
{ cache: false }in batch scripts: gray-matter caches every parsed file by input string, causing unbounded memory growth. Always pass{ cache: false }when callingmatter()in loops or batch processing scripts. - Ora spinner text should NOT end with
...: The trailing dots conflict with ora's own dots animation. The spinner animation itself provides the "in progress" cue. - Indexed array iteration with strict TypeScript:
noUncheckedIndexedAccessmakesarr[i]returnT | undefined, andno-non-null-assertionforbidsarr[i]!. Usefor (const [i, item] of arr.entries())to get typed values without assertions. - Astro template expressions are plain JS, not TypeScript:
new Map<string, T>()and other generics in template{}expressions cause esbuild errors. Move complex typed logic to the---frontmatter section. - Meilisearch dumps re-index on import: Dumps are blueprints, not ready-to-use databases. Use Docker data directory transfer (
--search-docker) instead — VPS import is instant. - LMDB is architecture-dependent: Meilisearch data directories from macOS won't work on Linux. Docker with
--platform linux/amd64produces compatible data. - SQLite is architecture-independent: Unlike LMDB, SQLite
.dbfiles transfer between macOS and Linux without issues. SCP directly. gray-mattercacheoption not in TypeScript types: The{ cache: false }option works at runtime but isn't in the type definitions. Useas anywith an eslint-disable comment in typechecked code.pnpm.onlyBuiltDependencies: Native packages likebetter-sqlite3need explicit approval in rootpackage.jsonunderpnpm.onlyBuiltDependenciesto compile during install.- Turborepo app task naming: Apps excluded from default
buildneed matching script names (e.g.,build:apiin bothturbo.jsonand the app'spackage.json). - Docker Meilisearch stores data at
data.ms/inside the volume: When tarring/extracting, use-C /data/data.msnot-C /data. Extracting at the wrong level causes "failed to infer database version" on the VPS. - Docker search index checkpoints: The incremental indexing script writes checkpoint files (
.search-indexed-at-{source}) into the content directory. For Docker runs, these are persisted indownloads/.search-checkpoints/and restored into the temp content dir on each run. If this directory is deleted, the next Docker index run will scan all files from scratch. - Docker volume profiles:
MEILI_PROFILE=dev|fullselects volume (meili-data-devormeili-data-full). Dev mode runs without master key (MEILI_ENV=development). Full mode requiresMEILI_MASTER_KEYfor VPS-compatible data. - Cloudflare "Managed robots.txt": When enabled, Cloudflare overwrites the site's
robots.txtto block AI crawlers. For LexBuild (public domain legal content), this should be OFF. The customrobots.txtatapps/astro/public/robots.txtblocks AI crawlers from/_astro/(hashed static assets),/nav/(internal JSON), and/api/while allowing legal content. - VPS PM2 logs live at
/home/ubuntu/pm2/logs/lexbuild/, not~/.pm2/logs/. The latter is legacy — onlypm2-logrotate-out.logstill writes there. Check the new path when debugging PM2-managed services. - VPS has 6 GiB swap at
/swapfile(persisted in/etc/fstab). Added as defense against Meilisearch OOM during bulk upserts on a 7.6 GiB RAM Lightsail box. Don't remove. - Stuck Meilisearch tasks crash-loop across restarts: document-addition tasks that OOM Meilisearch are persisted in LMDB and re-attempted after every PM2 restart (observed ~60s crash cycle, 160+ restarts in 2.5 hours). Cancel via
curl -XPOST -H "Authorization: Bearer $MEILI_MASTER_KEY" "http://127.0.0.1:7700/tasks/cancel?uids=<list>"— the cancellation typically executes during a healthy window even if the stuck task itself can't complete. _meta.json/README.mdcarry wall-clock timestamps: Converter outputs include agenerated_atfield. Byte-parity tests comparing outputs across runs must skip these files (assert existence, not content).
The multi-source architecture is proven — @lexbuild/ecfr and @lexbuild/fr validate the pattern with completely different XML schemas (hierarchical DIV-based eCFR vs flat document-centric FR). Adding a new source follows the established pattern:
- Create
packages/{source}/with a dependency on@lexbuild/core - Implement a source-specific AST builder (SAX events → LexBuild AST nodes) in the source package
- Implement a converter function (collect-then-write) analogous to
convertTitle()orconvertEcfrTitle() - Implement a downloader if the source has bulk data available
- Add
download-{source}andconvert-{source}CLI commands inpackages/cli - Reuse
@lexbuild/corefor XML parsing, AST types, Markdown rendering, frontmatter, and link resolution - Add new
SourceTypevalue topackages/core/src/ast/types.tsand any source-specific optional fields toFrontmatterData - Add the package to the
fixedarray in.changeset/config.json - Document the source's XML schema in the package's
CLAUDE.md
Source packages must be independent — they depend only on core, never on each other.
- Package boundary enforcement: ESLint
no-restricted-importsrules ineslint.config.jsprevent source packages from importing each other. When adding a new source package, add it to the restriction lists of all other source packages and add its own restriction block.