Skip to content
| Marketplace
Sign in
Visual Studio Code>Other>Early Text CompositorNew to Visual Studio Code? Get it now.
Early Text Compositor

Early Text Compositor

Early Text Centre

|
2 installs
| (0) | Free
A VSCode extension for contributing to the Early Text Corpus
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

The Early Text Compositor

The Early Text Compositor is a VSCode extension for contributing to the Early Text Corpus, a collection of diplomatic digital editions of texts from the hand press era, stored in Markit — a human-friendly markup language designed for early text preservation.

The extension activates when VSCode is opened in a copy of the corpus (it looks for data/authors; if the corpus is a subfolder of the workspace, point compositor.corpusRoot at it). Contributors are not expected to know git: the extension sets the corpus up on their machine and carries their finished work back to the Centre for review, using a GitHub account and nothing else.

It sits on top of the Markit language extension (declared as an extension dependency), which provides syntax highlighting, live compile errors, formatting, and preview for individual .mit files. The Compositor adds the corpus layer:

  • Corpus Browser — an activity-bar tree of authors → works → editions, labelled from metadata (names, titles, years; the canonical edition is starred). Clicking an author or edition opens its file; works expand to their editions, with the metadata stub on the context menu.
  • Authority records — right-click an author for "Open in VIAF" and "Open in Wikidata", or an edition for "Open in ESTC" and "Open the TCP Text", from the viaf/wikidata/estc/tcp identifiers the corpus records (see the corpus's external identifiers). Each item appears only where that identifier is present, and the identifiers show in the node's tooltip.
  • Corpus Search — a docked search-and-replace panel shaped like VSCode's native Search view, scoped to the corpus's works: it filters by author (include/exclude) rather than file glob, covers only catalogue editions, and matches only block content — never [metadata] sections, title lines, or {#…} block tags. Results group per edition under catalogue labels ("Hume · Enquiry · 1748"); replacement works per match, per edition, or across everything not dismissed, with each match re-verified against the live document before it is touched. "Search the Corpus…" on a .mit editor's context menu seeds the panel with the word under the cursor (whole-word, case-sensitive).
  • Validation — the corpus's full rule set (the same rules deno task validate runs) published to the Problems panel, with a status-bar summary and a badge on the tree. Saving a file revalidates in about a second; the initial load compiles the whole corpus and takes ~20s.
  • Scaffolding — New Author, New Work (with its first edition), and New Edition commands that prompt for the required metadata and write canonical, already-formatted files.
  • Fix Formatting — the one-click equivalent of the corpus's deno task fmt, applying the Markit formatter to every file.
  • Insert Borrowed Section Reference — pick an edition from the catalogue and insert a ## <Author.Work.Edition> placeholder at the cursor.
  • Dictionary diagnostics — an opt-in overlay (compositor.flagUnaccountedWords) that squiggles every word the corpus dictionary does not yet account for (unknown surfaces as warnings, unconfirmed ? entries as hints), each with quick-fixes that curate the entry — add it as a modern word, a respelling, or with a lemma; confirm an unconfirmed one — writing the shard file canonically.
  • Contribute — a docked panel that carries a contributor's work back to the Centre without ever naming a branch, a commit, a push or a pull request. It shows one situation at a time — your changes, your submission, what the editors decided — with the file list labelled from the catalogue ("Hume · Enquiry · 1748"), a diff of what you changed, an undo per file, and a description box whose text becomes the title the editors read. Sending brings in the latest corpus first, asking about any text that changed on both sides, then opens the submission on GitHub. See Contributing back.
  • Dictionary — a docked panel over the corpus's register: the unaccounted surfaces corpus-wide, most frequent first, so it can be backfilled highest-impact first, alongside browsable lemma-and-forms and variant-spelling views and controls that add or remove an entry.

All corpus logic (catalogue building, validation rules, path conventions) is this repository's own src/, bundled directly by esbuild, so the rules cannot drift from the corpus's own — and contributors need nothing installed beyond VSCode.

Development

The Compositor lives inside the corpus repository, alongside the src/ it bundles — there is no sibling checkout to manage. Markit is the one remaining external dependency, installed through JSR's npm compatibility layer under its registry name @jsr/earlytexts__markit (the committed .npmrc maps the @jsr scope to npm.jsr.io).

npm install
npm run build     # bundle to dist/
npm run check     # typecheck
npm run fmt:check # format check (npm run fmt to apply)
npm test          # unit tests (scaffold templates against the real rule set)
npm run package   # build the .vsix

To try it: open the corpus repository root (this extension's parent folder, which carries the launch config) in VSCode, press F5, and open a corpus checkout in the Extension Development Host.

Architecture

Key decisions

  • Standalone extension with extensionDependencies on earlytexts.markit-language (which owns syntax highlighting, per-file live compile errors, formatting, and preview). The Compositor adds only the corpus layer, and suppresses its own copy of compile errors for open documents so the two never double-report.
  • Corpus logic is imported, never reimplemented. @earlytexts/corpus (../src/index.ts, this repository's own source — see esbuild.mjs) exports the corpus's own catalogue build, metadata schema, path conventions, validation rules, and the catalogue/ read/write pair as runtime-neutral logic (everything takes a CorpusFs port; the disk binding — node:fs-backed, shared with the corpus's own scripts — is nodeCorpusFs, re-exported from the main entry). esbuild bundles it straight from source into dist/extension.cjs, so contributors need nothing beyond VSCode and a plain npm install. The corpus's own source imports markit under its Deno bare specifier (@earlytexts/markit); esbuild.mjs, vitest.config.ts, and tsconfig.json each alias that specifier to this extension's own markit dependency (@jsr/earlytexts__markit, from JSR's npm compatibility layer), so both halves of the suggestion pipeline resolve to the one installed copy — which matters because markit tags blocks with Symbol()s that only compare equal within one instance.
  • One compile pass per change. src/core/model/corpusModel.ts compiles the corpus once, feeds the compiled files to the validation rules, and hands the same documents to buildCatalogue (its precompiled parameter) so the catalogue composes without recompiling. A watcher on data/** recompiles just the saved .mit file (~1s round trip); non-file events trigger a full reload (~20s, cold-start cost).
  • The compiled catalogue/ masks the cold start. At startup the model seeds the tree from catalogue/ via the corpus's loadCatalogue (~0.5s) while the full compile runs; diagnostics always wait for the compile (serialised documents carry no source ranges). Every completed load writes catalogue/ back (writeCatalogue, ~0.5s, chained so writes never interleave), so the cache — and the computer's dev input — stays fresh.
  • Git is the extension's business, not the contributor's. Both halves of the round trip — setting the corpus up, and sending work back — run on bundled git (isomorphic-git) and the GitHub REST API, authenticated with VSCode's built-in GitHub sign-in, so there is no system git to install and no token to paste. Set-up forks the corpus into the contributor's account, clones the fork, and points upstream at the canonical corpus; opening the clone activates the extension, and no separate build step is needed (the model builds in memory). See Contributing back for the rest.
  • Build tooling: esbuild + npm; vsce package for the .vsix; vitest for unit tests (scaffold templates are validated against the real corpus rule set via the corpus's in-memory test harness).

Structure

A hexagon, from entry point down into detail. src/core/ is the domain: every decision, written over ports it owns, and testable without a running editor. src/adapters/ is everything that names the outside world — the VSCode API, isomorphic-git, the network — each adapter thin enough to review at a glance. src/webview/ is the panels' front-ends, plain DOM bundles that reach the extension only by message. The rule of thumb — does this file import vscode, node:*, or isomorphic-git? — is a directory line, and test/coreBoundary.test.ts enforces it: if it needs the outside world it lives in adapters/, otherwise in core/. When that test fails the fix is never to relax it, but to move the offending code out and hand the core a port.

Entry (src/)

  • extension.ts — activation (corpus-root detection), command registration, and the composition root: the one place adapters are built and injected

Core (src/core/, no vscode/node:*/isomorphic-git, held at 100% coverage)

One folder per feature the extension offers, plus the state they share. test/ mirrors the shape: a module's tests sit at the same path under test/, with the whole-pipeline tests (markup/suggestionsPipeline, dictionary/repro-possessive) alongside them and the boundary guard at the root.

  • model/ — the corpus state everything else hangs off. corpusModel.ts (load/validate/catalogue + the catalogue/ seed and write-back), compiledFileCache.ts (the bounded, on-demand compile cache), reloadKind.ts (what a change under data/ actually has to re-run), workspace.ts (which open folder is the corpus; what the tree says per phase)
  • catalogue/ — pure vocabulary over the compiled catalogue. nodes.ts (the tree's node types and the catalogue→file-path lookups shared by the tree and the commands), walk.ts (each work and each edition document visited exactly once, despite co-authorship and borrowing), links.ts (VIAF/Wikidata/ESTC/TCP URLs, and the viewItem tokens deciding which link items a menu offers)
  • dictionary/ — the register. curation.ts (the corpus-wide, frequency-ranked backlog), scan.ts (locate unaccounted surfaces in a document's source), resolve.ts and cascade.ts (the attestation rule, and the interactive walk that resolves every target an entry names), edits.ts (place a decision into a shard's canonical text), entryText.ts (validate entry input; squiggle and quick-fix wording), shardIO.ts (the read-modify-write primitive both write paths share), views.ts (the two cross-cut views the panel browses), and panel/ — its viewModel.ts, the optimistic patches.ts, input.ts validation, and the client.ts filter/page transforms the webview applies
  • hover/ — the token-accounting hover: info.ts (how the corpus accounts for one hovered token), view.ts (that as Markdown), pinMarkup.ts (the [w:surface=value] a pin inserts)
  • markup/ — the suggestion engine: hints.ts (mine lexicons from existing markup, scan raw source), suggestions.ts (category ⇄ markup rules, wrap delimiters), hintOverrides.ts (manual patches to the mined lexicons)
  • search/ — panel.ts: the query matcher, the block-content line filter, author scoping over the catalogue, the per-file scan, and the replace plan with its regex-aware replacement strings
  • authoring/ — the cores of the one-shot editing commands: scaffolds.ts with its templates.ts (formatted, schema-correct file builders), borrowedRef.ts, fixFormatting.ts, importTcp.ts, and compareEditions.ts with compareScope.ts (which works are comparable; an edition's successor)
  • contribute/ — the round trip. gitPort.ts and github.ts declare the two ports (what changed, branch, commit, merge, push; and the REST calls that are not git — the signed-in user, the fork, the pull request); workflow.ts is the translation layer, where describeState decides where a contributor stands and the four verbs are written over both ports; contribution.ts gathers the panel's scene; setup.ts holds the onboarding decisions
  • diagnostics/ — plan.ts (validations → collection action + status text) and overlayEngine.ts (the lifecycle both inline overlays share: the scanned-map state, the per-document debounce, the scan/drop/refresh branching)
  • shared/ — the primitives several folders need: sourceTokens.ts (Markit's own tokens placed back in raw .mit source — the atom the markup scanner, the dictionary scan, the hover, and search all agree on), serialize.ts (a FIFO mutex, so shard read-modify-writes cannot interleave), emitter.ts (a vscode-free EventEmitter an adapter can still subscribe to)

Adapters (src/adapters/, the only code that names the outside world)

  • vscode/ — corpusTree.ts (Corpus Browser tree data provider, rendering only), diagnostics.ts (Problems panel + status bar over the plan), hover.ts, corpusWatcher.ts, overlay.ts, doubleClickOpen.ts, notifier.ts, and the two webview panels — searchPanel.ts and dictionaryPanel.ts, plus contributionPanel.ts, which owns what only VSCode can do (sign-in, progress, the conflict dialogs, the diffs). panelShell.ts carries the shared CSP shell and each *Css.ts its styles. commands/ is one file per registered command, each gathering input and applying effects while the decisions stay in core/
  • git/ — gitPort.ts (the one place isomorphic-git lives: cloning, remotes, and the GitPort implementation), github.ts (the REST client behind GitHubClient), setup.ts (the "Set up the corpus" command)
  • http/ — tcpText.ts, the Text Creation Partnership fetch

Webviews (src/webview/, framework-free DOM)

  • main.ts (the dictionary panel), search.ts, contribute.ts

Markup suggestions

compositor.suggestMarkup flags likely people, citations, and foreign text (Latin/French/Greek/…) in the open edition so a contributor can cycle them (F8, like any diagnostic) and mark each up with a quick fix — or ignore it. The finding logic lives here, in src/core/markup/hints.ts: buildHints/scanSource mine lexicons from the markup the corpus already carries (so suggestions improve as markup accumulates) and scan a file's raw source. This is read-side text processing over the compiled catalogue, which the Compositor owns outright (the corpus is the write side) — it was moved out of the corpus package into this extension. The rest is the editor layer: src/adapters/vscode/commands/suggestMarkup.ts owns the toggle picker, a dedicated Information-severity diagnostic collection (kept apart from validation, whose diagnostics share the "compositor" source, so the two never tangle), and the quick-fix code-action provider. Hints are cached and rebuilt only when the corpus model reloads; scanning is per-file and on-demand. Pure rules (category ⇄ suggestion mapping, wrap delimiters) live in src/core/markup/suggestions.ts and are unit-tested; test/markup/suggestionsPipeline.test.ts runs the whole mine→scan→filter→wrap path — which only holds together because markit resolves to one instance across the corpus/markit boundary (its block Symbol()s compare equal only within one instance), guaranteed here by the @earlytexts/markit alias in esbuild.mjs/vitest.config.ts pointing both the corpus's own import and this extension's dependency at the single installed @jsr/earlytexts__markit copy.

Dictionary curation

The corpus's dictionary (its curated register of surface forms) drives two editor surfaces, both off the corpus's own accounting rule (accountTokens in @earlytexts/corpus — the one coverage engine shared by corpus validation and this extension, so the two cannot disagree):

  • Diagnostics (compositor.flagUnaccountedWords, off by default — toggle with compositor.toggleUnaccountedWords). While on, the active editions are scanned and every unaccounted surface squiggled. The corpus owns the decision (which folded surfaces are unaccounted or unconfirmed); src/core/dictionary/scan.ts only locates them, reusing the markup-suggestion tokenizer (documentSourceTokens) so exempting markup (names, citations, foreign spans, [w:]) is skipped and page breaks/escapes are read through. A word built from {…} character escapes or a kept ligature (œconomy) may go unflagged rather than mis-flagged; the coverage counts stay exact.
  • Quick-fixes and the Curation view write dictionary entries. The pure placement (src/core/dictionary/edits.ts) parses the surface's shard, adds or confirms the entry, and re-serialises with the corpus's own shardDictionary — so an entry added from the editor is byte-identical to one deno task fmt would produce and round-trips through corpus validation. Whether the result is coherent (references resolve, readings select) is the corpus validation's business, reported live in the Problems panel after the write. The Curation backlog (src/core/dictionary/curation.ts, surfaced in the dictionary panel) ranks the whole register gap by corpus-wide frequency so it can be burned down highest-impact first.

The two overlays compose: enable the markup suggestions too, and a squiggled name offers both "mark up as a person" (from the suggestion provider) and the dictionary fixes at the same spot — the register and the mined lexicons reinforce each other, as intended, with no coupling between the features.

The dictionary quick-fixes deliberately do not re-offer name/citation/language markup (that lives in the markup-suggestion overlay), nor [w:]/edition-default disambiguation of an already-accounted ambiguous surface (which has no diagnostic to hang a fix on); both are natural follow-ups.

Contributing back

The Contribute panel exists so that someone who has never used git can send a corrected text to the Centre. Its whole design follows from one decision: one unit of work is in flight at a time, and it is called a submission — one branch, one pull request, one lifecycle that can be stated in a sentence. Several submissions at once would mean switching between them, which means files changing on disk underneath the contributor, which is exactly where a non-technical user loses trust in the tool.

The vocabulary is fixed, and nothing below the panel is allowed to leak into it:

git what the contributor is told
fork your copy of the corpus
working tree changes your changes
branch + commit + push + pull request send for review
a pull request your submission
merging upstream/main getting the latest corpus
merged accepted into the corpus

describeState reads three facts — which branch the copy is on, what has changed, and what GitHub says about the submission — and returns exactly one situation, which is the only thing the panel can render:

  • clean — nothing changed, nothing outstanding; offers the latest corpus.
  • editing — work not yet sent: the changed files, and the description box that sends them.
  • unfinished — a send that stopped part-way (the connection dropped between the push and the pull request). The work is safe on its branch, and the panel offers to finish, named after the commit it carries. Without this a contributor would be stranded on a branch with no way forward.
  • sent — awaiting review; further edits go to the same submission.
  • decided — accepted or closed. With nothing pending it offers to clear away and start afresh; with new edits it offers to send them as a new submission (a settled submission cannot be added to).

Sending is: commit everything as one described commit on a branch named for the date and the description, bring in the corpus, push to the fork, open the pull request. Bringing in the corpus at send time — rather than leaving it to the editors — is deliberate: a contributor should meet a clash with their own work while they still remember doing it.

A clash is handled in two passes, which is why mergeCorpus takes its choices as a second call rather than a callback. The first pass is a probe that aborts on conflict, so backing out costs nothing and nothing has moved; the contributor is then asked, per file, to keep their version or take the corpus's (with a diff of the two on request); the second pass replays the merge, writes the chosen sides into the working files, and commits the result with both parents, so it reads as an ordinary merge to git. Conflict-marker editing is deliberately not offered: for a corpus of separate texts the realistic clash is "we both edited this text", which a per-file choice settles, and anything finer is an editorial judgment that belongs in the review conversation.

The review conversation itself stays on GitHub — the panel links to it rather than rebuilding it.

Corpus layout (what the tree and scaffolds produce)

data/authors/<author>.mit            author metadata (no text)
data/works/<host>/<work>/index.mit   work stub: identity + canonical pointer
data/works/<host>/<work>/<year>.mit  a dated edition (1748, 1742a, 1739-40…)

<host> is the author slug, or a hyphen-joined joint slug for co-authored works. A section heading ## <Author.Work.Edition> borrows another edition's text (collections are composed this way). Metadata lives in [metadata] blocks inside the .mit files; the schema is ../src/validation/schema.ts.

Conventions

  • TypeScript strict (no stricter than the corpus's typecheck, whose sources this project typechecks directly via the @earlytexts/corpus alias). Functional style: arrow functions, no classes.
  • Imports use explicit .ts extensions (allowImportingTsExtensions).
  • The corpus itself is bundled directly from the sibling ../src/; markit remains an external dependency (@jsr/earlytexts__markit, from JSR's npm compatibility layer).
  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
© 2026 Microsoft