Imported from ldonnez/memo-web (
AGENTS.md). Install upstream withnpx skills add ldonnez/memo-web. Copyright stays with the author.
Memo Web — agent instructions
Stack
- Vanilla TypeScript PWA — no framework. Vite is the dev server and production bundler (
vite.config.ts), output todist/for GitHub Pages. - ESM —
"type": "module"inpackage.json; Vite bundles all npm deps (CM6,@catppuccin/codemirror,@fig/lezer-bash, openpgp, marked, dompurify, highlight.js) intodist/assets/. - Service Worker — generated by vite-plugin-pwa (Workbox
generateSW) intodist/sw.jsat build time. No hand-writtensw.js. - All CSS lives in
app.css(imported via<link rel="stylesheet">inindex.html). No inline<style>blocks. - All TS logic in
app.ts(~1610 lines). Imports fromlib/{crypto,draft,editor,format,github,hljs,types,util,cm}.tsvia.tsextensions. - All runtime libraries are npm packages bundled by Vite — no CDN
<script>tags, no SRI hashes.
Commands
npm run dev # Vite dev server with HMR at http://localhost:8080 (required — SW needs HTTP)
npm test # node --test 'specs/**/*.spec.ts'
npm run lint # oxlint
npm run format # prettier --write '**/*.{ts,js,html,css,md,json}'
npm run build # vite build -> dist/ (bundles app + generates Workbox SW)
npm run preview # serve the production build locally
CI order (ci.yml): commitlint → lint (format:check + lint + typecheck + typecheck:specs) → test (+ build); deploy (deploy-pages.yml) builds dist/ and uploads it on release.
Testing
- Uses Node's built-in test runner (
node --test). 370 tests across 26 files inspecs/. - Run single file:
node --test specs/crypto.spec.ts. - Node 24 required (CI runs Node 24).
specs/crypto.spec.tsimportsopenpgpdirectly andencryptContent/decryptContentfromlib/crypto.ts(noglobalThishack needed — the lib imports OpenPGP itself).specs/cm.spec.tsimports the@codemirror/*/@lezer/highlightpackages directly and runsEditorState.createwith the full extension set fromlib/cm.tsas a regression guard against duplicate CM6 instances being resolved.
Architecture
index.html— shell. Vite injects the hashed<script type="module">and<link rel="stylesheet" href=/app.css>at build time. CSP in a<meta>tag (no inline scripts allowed).app.css— all styles (extracted from the former inline<style>). Referenced fromindex.html.vite.config.ts— Vite config +VitePWAplugin (registerType: 'autoUpdate',manifest: falsesincepublic/manifest.jsonis used, WorkboxruntimeCachingpreserving the old SW strategies: GitHub APINetworkOnly+BackgroundSync, index.htmlNetworkFirst, all othersStaleWhileRevalidate).public/— static files copied verbatim todist/:icon.svg,manifest.json.app.ts— application shell: state management, CodeMirror 6 editor (vialib/cm.tsadapter), settings, preview rendering. Entry point at bottom:loadConfig()theninit().let cm = nullholds the editor adapter; falls back to the<textarea id="editorContent">if#editorHost/adapter init fails (offline). Registers the SW viaimport { registerSW } from 'virtual:pwa-register'+registerSW({ immediate: true })(autoUpdate). Prettier is lazy-loaded viaimport('prettier')+import('prettier/plugins/markdown').lib/crypto.ts—encryptContent/decryptContentusing OpenPGP.js (import * as openpgp from 'openpgp').lib/format.ts— Markdown table formatting (reflowTable,getPipePositions,getCellContentStart).lib/util.ts— helpers:escHtml,escAttr,highlightCode,computeDirtyState,formatNoteItem, etc.lib/hljs.ts— highlight.js core + registered languages (import hljs from 'highlight.js'), re-exported forapp.ts.lib/github.ts— GitHub API client:gh,ghGetFile,ghListDir,ghPutFile,ghDeleteFile,verifyRepo,parseEntries,buildStatusText,fetchAllNotesContent,walkAllDirsAndPrefetch. All functions takeconfigas first parameter.lib/draft.ts— draft/content cache:contentCache,draftCache,persistDrafts,restoreDrafts,saveDraft,removeDraft. Module-levelMapsingletons backed by localStorage.lib/types.ts— shared TypeScript types/interfaces for the app.lib/editor.ts— high-level edit helpers operating on the CM6EditorView:smartEnter,formatTable,moveInTable,handleTab/handleShiftTab,toggleTaskByIndex,toggleTaskOnLine,insertMarkdown,insertTimestamp. No direct@codemirror/*imports — all view/state access goes through helpers exported fromlib/cm.ts.lib/cm.ts— CodeMirror 6 integration: imports bare@codemirror/*+@lezer/highlightpackages (Vite bundles them into one module graph — crucial forinstanceofchecks), defines light/dark chrome themes + Catppuccin syntaxHighlightStyles, a search-markStateField(Decorations), exported position converterstoOffset/offsetToDocPos, and small view ops (setTheme,clearHistory,replaceDoc,replaceRange,setCursor,setSelection,insertSoftTab,addMark/clearAllMarks/getAllMarks).createEditor()returns anEditorViewdirectly. The theme/historyCompartments live at module scope (set insidecreateEditor).byId()—document.getElementById()shorthand, defined at top ofapp.ts.loadFromCache(path, extraState)— loads cached notes from IndexedDB on connection failure; shared byconnectandnavigateToDir.
Conventions
- No inline event handlers. All event binding in
bindEvents()function at bottom ofapp.ts. No'unsafe-inline'inscript-srcCSP. style-src 'unsafe-inline'retained — hundreds of inlinestyle="..."attributes not worth refactoring.- No HTML comments in code. Minimal JS comments.
- CodeMirror 6 — bundled via Vite. All
@codemirror/*+@lezer/highlightpackages are devDependencies and bundled by Vite into a single module graph — this guarantees one instance of each library (the old jsDelivr+esmapproach loaded duplicate@codemirror/state/@codemirror/viewinstances andEditorState.createthrewUnrecognized extension value in extension set). Bumping a CM6 package requires only annpm install— no committed bundle to regenerate.createEditor()returns theEditorViewdirectly andapp.ts/lib/editor.tsuse native CM6 APIs (view.state.doc,view.dispatch, the exported helpers inlib/cm.ts) — there is no CM5-compatible adapter layer. CustomkeymapusesPrec.high(keymap.of(...))(modern CM6keymapis a Facet, not a function). No CM6lang-shell/lang-yaml/lang-luaofficial packages — bash/sh comes from@fig/lezer-bash(anLRParserwrapped viaLRLanguage.define); fencedyaml/yml/luablocks still rely on plain text (unhighlighted) in the editor because there is no@lezer/lr-compatible grammar on npm (the onlylezer-luais built on the legacylezer@0.13/lezer-tree@0.13stack, which is incompatible with CM6). Editor theme: the dark theme uses the official@catppuccin/codemirrorcatppuccinFrappeextension and the light themecatppuccinLatte(chrome + highlight style combined);getTheme()returns the app's single light/dark pair, nomaterialreferences remain. Colors/readability:lib/cm.tsdefines its ownfrappeHighlightStyle/latteHighlightStyle(Catppuccin palettes) returned alongside each theme withsyntaxHighlighting(...)— the@codemirror/languagedefaultHighlightStyleis deliberately NOT used because it applies light-background colors (e.g. dark blue#221199for URLs/todo markers) that are unreadable on the dark Frappe background. Fenced code highlighting:markdown({ codeLanguages })inlib/cm.tsmust return aLanguageobject (e.g.javascriptLanguage, not thejavascript()factory;sql().languagefor SQL) — passing aLanguageSupport/factory leaves code blocks plain because lang-markdown reads.parseroff the returned value. - Marked v18 — npm package
marked. No default export — alwaysimport { marked } from 'marked'(function +use/parse/Renderernamed exports). API:marked.parse(text, { breaks, gfm }),marked.use({ renderer: { code, listitem } }). - DOMPurify — npm package
dompurify,import DOMPurify from 'dompurify'. - highlight.js — npm package
highlight.js, core + common languages registered inlib/hljs.ts. - Prettier —
formatDoc()lazy-loads viaimport('prettier')+import('prettier/plugins/markdown')(major pin@3). - Prettier: no semicolons (
.prettierrcsemi: false), single quotes, trailing commas, 120 print width, 2-space indent, no parens on single arrow param.
iOS / PWA quirks
- Keyboard / viewport: no JS font of the keyboard. iOS treats it as an overlay, so the layout uses plain
100dvh(calc(100dvh - var(--header-h))); novisualViewport/--app-hcode. Earlier approaches (JS-driven--app-h+ poll-on-focus +healViewportre-measure) were removed as unreliable on iOS 26. - CSS Highlights API: used unconditionally —
CSS.highlightsis available in all modern browsers.CSS.highlights.clear()called directly, no feature detection or try/catch. - Inputs must be
font-size: 16pxto prevent iOS auto-zoom on focus. - Service worker: registered by
app.tsviaregisterSW({ immediate: true })fromvirtual:pwa-register(autoUpdatemode → SWskipWaiting()on install +clients.claim()on activate). The SW itself is generated by vite-plugin-pwa at build time.
Security
- CSP in
<meta>tag (seeindex.html):default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.github.com; img-src 'self' data: https:; font-src 'self' data:. form-action 'none',frame-ancestors 'none',base-uri 'none',upgrade-insecure-requests.- No CDN resources at runtime →
script-src 'self'only.
Maintaining
Dependency updates
Runtime libraries (CM6, openpgp, marked, dompurify, highlight.js, prettier) live in dependencies; build/test tools (vite, vite-plugin-pwa, oxlint, commitlint) in devDependencies. All are bundled by Vite into the app, so nothing resolves from node_modules at runtime. Run npm outdated then bump with npm update or npm install <pkg>@latest. After bumping any @codemirror/*/@lezer/* package, no committed bundle needs regenerating (the old npm run build:cm step is gone). Verify with npm test && npm run lint && npm run build.
Library inventory
| Library | Version | Notes |
|---|---|---|
| vite | ^8.2.2 |
dev server + bundler |
| vite-plugin-pwa | ^1.3.0 |
Workbox SW generation |
@codemirror/* |
see package.json | bundled by Vite (single module graph) |
@lezer/highlight |
^1.2.3 |
bundled by Vite |
@catppuccin/codemirror |
^1.0.3 |
Catppuccin editor theme (catppuccinFrappe) |
@fig/lezer-bash |
^1.2.5 |
bash/sh editor grammar (LRLanguage.define) |
| marked | ^18.0.11 |
named exports ({ marked }) |
| dompurify | ^3.4.14 |
bundled by Vite |
| highlight.js | ^11.12.0 |
core + registered languages |
| openpgp | ^6.3.1 |
bundled by Vite |
| prettier | ^3.9.6 |
lazy-loaded at runtime |
| oxlint | ^1.81.0 |
linter (replaces ESLint; see .oxlintrc.json) |