From 09b4e29d2266ea40b4b29d6ed9c3e0d27394cfd0 Mon Sep 17 00:00:00 2001 From: rootstring-bot Date: Tue, 22 Sep 2026 23:51:51 +0200 Subject: [PATCH 1/2] feat: share the Markdown dialect and note styles as packages --- docs/architecture.md | 31 +- docs/search.md | 2 +- package.json | 6 +- packages/design/README.md | 20 + packages/design/content.css | 686 ++++++++++++++++++ {src/styles => packages/design}/fonts.css | 8 +- .../design}/fonts/OFL-Manrope.txt | 0 .../design}/fonts/OFL-RobotoSerif.txt | 0 .../fonts/manrope-latin-ext-wght-normal.woff2 | Bin .../fonts/manrope-latin-wght-normal.woff2 | Bin .../roboto-serif-latin-ext-wght-normal.woff2 | Bin .../roboto-serif-latin-wght-normal.woff2 | Bin packages/design/package.json | 21 + {src/styles => packages/design}/tokens.css | 0 packages/markdown/README.md | 15 + packages/markdown/package.json | 29 + .../markdown/src/dialect.ts | 69 +- packages/markdown/src/icons.ts | 46 ++ packages/markdown/src/index.ts | 11 + .../markdown/src}/markdown-it-mark.d.ts | 0 .../markdown/src}/math.ts | 0 packages/markdown/src/render.test.ts | 132 ++++ packages/markdown/src/render.ts | 319 ++++++++ .../markdown/src}/types.ts | 0 packages/markdown/tsconfig.json | 14 + pnpm-lock.yaml | 27 + pnpm-workspace.yaml | 2 + src/app.css | 5 +- src/lib/editor/Callout.ts | 31 +- src/lib/editor/DateMention.ts | 7 +- src/lib/editor/Math.ts | 2 +- src/lib/editor/PageLink.ts | 7 +- src/lib/editor/WikiLink.ts | 8 +- src/lib/editor/markdown/index.ts | 2 +- src/lib/editor/markdown/serialize.ts | 2 +- src/lib/editor/markdown/state.ts | 2 +- src/lib/editor/markdown/syntax.ts | 58 +- src/lib/search/wiki-links.ts | 2 +- src/styles/editor.css | 603 ++------------- static/_headers | 4 - vite.config.js | 5 + vitest.config.ts | 6 +- 42 files changed, 1517 insertions(+), 665 deletions(-) create mode 100644 packages/design/README.md create mode 100644 packages/design/content.css rename {src/styles => packages/design}/fonts.css (81%) rename {static => packages/design}/fonts/OFL-Manrope.txt (100%) rename {static => packages/design}/fonts/OFL-RobotoSerif.txt (100%) rename {static => packages/design}/fonts/manrope-latin-ext-wght-normal.woff2 (100%) rename {static => packages/design}/fonts/manrope-latin-wght-normal.woff2 (100%) rename {static => packages/design}/fonts/roboto-serif-latin-ext-wght-normal.woff2 (100%) rename {static => packages/design}/fonts/roboto-serif-latin-wght-normal.woff2 (100%) create mode 100644 packages/design/package.json rename {src/styles => packages/design}/tokens.css (100%) create mode 100644 packages/markdown/README.md create mode 100644 packages/markdown/package.json rename src/lib/editor/markdown/syntax-extras.ts => packages/markdown/src/dialect.ts (82%) create mode 100644 packages/markdown/src/icons.ts create mode 100644 packages/markdown/src/index.ts rename {src/lib/editor/markdown => packages/markdown/src}/markdown-it-mark.d.ts (100%) rename {src/lib/editor/markdown => packages/markdown/src}/math.ts (100%) create mode 100644 packages/markdown/src/render.test.ts create mode 100644 packages/markdown/src/render.ts rename {src/lib/editor/markdown => packages/markdown/src}/types.ts (100%) create mode 100644 packages/markdown/tsconfig.json create mode 100644 pnpm-workspace.yaml diff --git a/docs/architecture.md b/docs/architecture.md index 30c23b1..67b2c46 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,8 +7,9 @@ - **Editor:** TipTap (ProseMirror) - **Storage:** Markdown files on desktop (`~/Documents/Set` by default), IndexedDB on the web. Both use the same file format. -- **Styling:** scoped Svelte CSS and CSS-variable tokens (`src/styles/tokens.css`), light and dark -- **Fonts:** Manrope and Roboto Serif, self-hosted +- **Styling:** scoped Svelte CSS and CSS-variable tokens (`packages/design/tokens.css`), light and + dark +- **Fonts:** Manrope and Roboto Serif, self-hosted (`packages/design/fonts/`) - **Sync:** [iroh](https://www.iroh.computer/), see [sync.md](sync.md) - **Dictation:** [whisper.cpp](https://github.com/ggml-org/whisper.cpp) via `whisper-rs`, behind the optional `dictation` cargo feature @@ -26,7 +27,7 @@ One SvelteKit SPA with two backends behind the same `PageStore` interface: set/ ├── src/ │ ├── app.css # global styles entry -│ ├── styles/ # design tokens + shared CSS +│ ├── styles/ # app CSS: controls, popovers, the editor's editing-only rules │ ├── routes/ # SvelteKit routes (thin: wire state to components) │ │ ├── +layout.svelte # app shell (+layout.ts loads the workspace) │ │ ├── +page.ts # `/`, redirects to the last open page or its context @@ -46,7 +47,10 @@ set/ │ ├── storage/ # PageStore + backends, Markdown/frontmatter serialization │ ├── types/ # shared domain types │ └── utils/ # generic helpers -├── static/ # favicon + fonts +├── packages/ # shared with anything that publishes a note (see below) +│ ├── design/ # tokens, fonts, content.css (how a note looks) +│ └── markdown/ # Set's Markdown dialect + renderHtml for publishing +├── static/ # favicon ├── e2e/ # Playwright suites, run against the built web app ├── scripts/ # release, bundle-check and dev helpers ├── docs/ @@ -131,6 +135,13 @@ Notes/ - **Set ignores its own writes.** `watch.rs` coalesces events and skips files matching the length and mtime of Set's last write. If the open page changes outside Set while it has unsaved edits, the user chooses what to keep. +- **The dialect is shared, the HTML isn't.** `packages/markdown`'s `setDialect` tokenizes Set's + Markdown for both the editor and `renderHtml`. Syntax changes go there, with a test in each. The + editor's renderer rules (the HTML its schema parses, and `data-md` for round-trips) stay in + `editor/markdown/syntax.ts`. +- **A note looks the same everywhere.** How a note's blocks look goes in `packages/design/content.css`, + under `:is(.ProseMirror, .set-content)`, and `renderHtml` emits the DOM the node views draw. + Selection, drag and menu styles stay in `src/styles/editor.css`. - **The editor doesn't know about pages.** `Editor.svelte` takes a document and emits changes. Blocks and slash commands are registered in `extensions.ts`. - **Editor prompts go through state.** Block menus render outside Svelte, so "which page?" @@ -143,3 +154,15 @@ Notes/ - [search.md](search.md): indexing and ranking - [sync.md](sync.md): device sync - [mcp.md](mcp.md): the MCP server + +## Packages + +`packages/design` and `packages/markdown` are consumed by the app through the pnpm workspace and +by other sites (the blog) as git dependencies pinned to a release tag: + +```json +"@rootstring/set-markdown": "github:rootstring/set#v0.0.3&path:packages/markdown" +``` + +They ship source (TypeScript and CSS) with no build step, so a consumer's bundler compiles them +(Vite: `ssr.noExternal`). Keep them free of `$lib`, Svelte and DOM dependencies. diff --git a/docs/search.md b/docs/search.md index 7ec8b8d..c07a63b 100644 --- a/docs/search.md +++ b/docs/search.md @@ -104,7 +104,7 @@ title, up to three names, with `+N` opening the full list (`src/lib/page/Backlin `wiki_link_titles` (`index.rs`) and `wikiLinkTitles` (`src/lib/search/wiki-links.ts`) share one list of test cases (`same_titles_as_the_typescript_scanner` / `matches the Rust scanner`). Both follow the -editor's rule (`editor/markdown/syntax-extras.ts`): drop the alias after `|` and the section after +editor's rule (`packages/markdown/src/dialect.ts`): drop the alias after `|` and the section after `#`, trim, and skip fenced code blocks and backtick spans. Known gap: indented (four-space) code blocks aren't skipped. diff --git a/package.json b/package.json index b6a6266..10b39d3 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "check:lockfile": "node scripts/check-lockfile.mjs", "version:sync": "node scripts/sync-version.mjs", "version:check": "node scripts/sync-version.mjs --check", - "format": "prettier --write \"src/**/*.{ts,svelte,css}\" \"e2e/**/*.ts\"", - "format:check": "prettier --check \"src/**/*.{ts,svelte,css}\" \"e2e/**/*.ts\"", + "format": "prettier --write \"src/**/*.{ts,svelte,css}\" \"packages/**/*.{ts,css}\" \"e2e/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.{ts,svelte,css}\" \"packages/**/*.{ts,css}\" \"e2e/**/*.ts\"", "gen-notes": "node scripts/gen-notes.mjs", "gen-icons": "bash scripts/gen-icons.sh", "test:unit": "vitest run", @@ -29,6 +29,8 @@ }, "license": "AGPL-3.0-only", "dependencies": { + "@rootstring/set-design": "workspace:*", + "@rootstring/set-markdown": "workspace:*", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.3", "@tauri-apps/plugin-fs": "^2.5.2", diff --git a/packages/design/README.md b/packages/design/README.md new file mode 100644 index 0000000..516ce5b --- /dev/null +++ b/packages/design/README.md @@ -0,0 +1,20 @@ +# @rootstring/set-design + +What makes something look like Set, for the app and for anything that shows a note outside it. + +| File | Is | +| ------------- | ------------------------------------------------------------------------------------ | +| `tokens.css` | Colours, type, spacing and the reading column, light and dark (`data-theme` or OS) | +| `fonts.css` | Manrope and Roboto Serif, self-hosted from `fonts/` (OFL, licences alongside) | +| `content.css` | A note's blocks, under `.ProseMirror` (the editor) or `.set-content` (published) | + +`content.css` styles the DOM that [`@rootstring/set-markdown`](../markdown)'s `renderHtml` emits. +Import all three, in that order, through a bundler that resolves the relative font URLs: + +```css +@import "@rootstring/set-design/fonts.css"; +@import "@rootstring/set-design/tokens.css"; +@import "@rootstring/set-design/content.css"; +``` + +Equations also need KaTeX's stylesheet (`katex/dist/katex.min.css`). diff --git a/packages/design/content.css b/packages/design/content.css new file mode 100644 index 0000000..feda181 --- /dev/null +++ b/packages/design/content.css @@ -0,0 +1,686 @@ +/* + * A note as Set draws it: the editor (`.ProseMirror`) and anywhere a note is published + * (`.set-content`, holding @rootstring/set-markdown's `renderHtml` output as direct children). + * Needs `tokens.css`. The editor's editing-only rules (selection, drag, grips, menus) stay in the + * app's `editor.css`. + */ + +:is(.ProseMirror, .set-content) { + font-size: var(--editor-font-size); + font-family: var(--font-body); + line-height: var(--editor-line-height); + color: var(--text); + + --marker-gutter: calc(1.5 * var(--editor-font-size)); + --list-indent: calc(1.375 * var(--editor-font-size)); + --checkbox-size: calc(0.95 * var(--editor-font-size)); + + --list-gap: calc(0.375 * var(--editor-font-size)); + + --editor-line: calc(var(--editor-line-height) * var(--editor-font-size)); + + width: var(--reading-column); + margin-inline: auto; +} + +:is(.ProseMirror, .set-content) :where(p, h1, h2, h3, h4, h5, h6, ul, ol, blockquote) { + margin: 0; +} + +/* `pretty` weighs the paragraph as a whole; the right edge stays ragged on purpose. */ +:is(.ProseMirror, .set-content) :where(p, li, blockquote, dd, figcaption) { + text-wrap: pretty; +} + +/* A heading is a line or two, where a one-word second line is the whole + shape of it, so these balance rather than merely avoid the worst break. */ +:is(.ProseMirror, .set-content) :where(h1, h2, h3, h4, h5, h6) { + text-wrap: balance; +} + +:is(.ProseMirror, .set-content) > * + * { + margin-top: var(--block-gap); +} + +:is(.ProseMirror, .set-content) h1 { + font-size: 1.875em; + font-weight: 700; + line-height: 1.3; + margin-top: 1.75rem; + margin-bottom: 0.85rem; +} + +:is(.ProseMirror, .set-content) h2 { + font-size: 1.5em; + font-weight: 600; + line-height: 1.3; + margin-top: 1.5rem; + margin-bottom: 0.75rem; +} + +:is(.ProseMirror, .set-content) h3 { + font-size: 1.25em; + font-weight: 600; + line-height: 1.3; + margin-top: 1.15rem; + margin-bottom: 0.6rem; +} + +:is(.ProseMirror, .set-content) ul, +:is(.ProseMirror, .set-content) ol { + margin-left: 0; + padding-left: var(--marker-gutter); +} + +:is(.ProseMirror, .set-content) ul:not([data-type="taskList"]), +:is(.ProseMirror, .set-content) ol { + list-style: none; +} + +:is(.ProseMirror, .set-content) ul:not([data-type="taskList"]) > li, +:is(.ProseMirror, .set-content) ol > li { + position: relative; +} + +:is(.ProseMirror, .set-content) ul:not([data-type="taskList"]) > li::before { + content: ""; + position: absolute; + left: calc(0.5 * var(--checkbox-size) - var(--marker-gutter)); + top: calc(0.5 * var(--editor-line)); + transform: translate(-50%, -50%); + width: 0.3em; + height: 0.3em; + border-radius: 50%; + background: var(--text-subtle); +} + +:is(.ProseMirror, .set-content) ol > li::before { + content: counter(list-item) "."; + position: absolute; + left: calc(-1 * var(--marker-gutter)); + top: 0; + + font-size: 0.875em; + line-height: var(--editor-line); + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: var(--text-subtle); +} + +:is(.ProseMirror, .set-content) li > ul, +:is(.ProseMirror, .set-content) li > ol, +:is(.ProseMirror, .set-content) li > div > ul, +:is(.ProseMirror, .set-content) li > div > ol { + position: relative; + margin-top: var(--list-gap); + margin-left: calc(var(--list-indent) - var(--marker-gutter)); +} + +:is(.ProseMirror, .set-content) li > ul::before, +:is(.ProseMirror, .set-content) li > ol::before, +:is(.ProseMirror, .set-content) li > div > ul::before, +:is(.ProseMirror, .set-content) li > div > ol::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + + left: calc(0.5 * var(--checkbox-size) - var(--list-indent)); + border-left: 1px solid var(--border); + pointer-events: none; +} + +:is(.ProseMirror, .set-content) li + li { + margin-top: var(--list-gap); +} + +/* A loose list, one written with blank lines between its items, is spaced the + way Markdown means it: further apart than the tight list the editor makes. */ +:is(.ProseMirror, .set-content) + ul:not([data-tight]):not([data-type="taskList"]) + > li + + li, +:is(.ProseMirror, .set-content) ol:not([data-tight]) > li + li { + margin-top: calc(2 * var(--list-gap)); +} + +:is(.ProseMirror, .set-content) > ul + ul, +:is(.ProseMirror, .set-content) > ul + ol, +:is(.ProseMirror, .set-content) > ol + ul, +:is(.ProseMirror, .set-content) > ol + ol { + margin-top: var(--list-gap); +} + +:is(.ProseMirror, .set-content) blockquote { + border-left: 3px solid var(--border); + padding-left: calc(var(--marker-gutter) - 3px); + color: var(--text-muted); +} + +:is(.ProseMirror, .set-content) a { + color: var(--accent); + text-decoration: underline; + text-decoration-color: var(--accent-soft); + text-underline-offset: 2px; + cursor: pointer; +} + +:is(.ProseMirror, .set-content) a:hover { + text-decoration-color: var(--accent); +} + +:is(.ProseMirror, .set-content) code { + font-family: var(--font-mono); + font-size: 0.875em; + background-color: var(--bg-elevated); + padding: 0.15em 0.35em; + border-radius: 4px; +} + +:is(.ProseMirror, .set-content) pre { + font-family: var(--font-mono); + background-color: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.85rem 1rem; + overflow-x: auto; +} + +:is(.ProseMirror, .set-content) pre code { + background: none; + padding: 0; + font-size: 0.875rem; +} + +:is(.ProseMirror, .set-content) pre code .hljs-comment, +:is(.ProseMirror, .set-content) pre code .hljs-quote { + color: var(--hl-comment); + font-style: italic; +} + +:is(.ProseMirror, .set-content) pre code .hljs-keyword, +:is(.ProseMirror, .set-content) pre code .hljs-literal, +:is(.ProseMirror, .set-content) pre code .hljs-selector-tag, +:is(.ProseMirror, .set-content) pre code .hljs-section, +:is(.ProseMirror, .set-content) pre code .hljs-doctag { + color: var(--hl-keyword); +} + +:is(.ProseMirror, .set-content) pre code .hljs-string, +:is(.ProseMirror, .set-content) pre code .hljs-regexp, +:is(.ProseMirror, .set-content) pre code .hljs-addition { + color: var(--hl-string); +} + +:is(.ProseMirror, .set-content) pre code .hljs-number, +:is(.ProseMirror, .set-content) pre code .hljs-symbol, +:is(.ProseMirror, .set-content) pre code .hljs-bullet { + color: var(--hl-number); +} + +:is(.ProseMirror, .set-content) pre code .hljs-title, +:is(.ProseMirror, .set-content) pre code .hljs-name, +:is(.ProseMirror, .set-content) pre code .hljs-selector-id, +:is(.ProseMirror, .set-content) pre code .hljs-selector-class { + color: var(--hl-function); +} + +:is(.ProseMirror, .set-content) pre code .hljs-title.class_, +:is(.ProseMirror, .set-content) pre code .hljs-type { + color: var(--hl-class); +} + +:is(.ProseMirror, .set-content) pre code .hljs-attr, +:is(.ProseMirror, .set-content) pre code .hljs-attribute, +:is(.ProseMirror, .set-content) pre code .hljs-variable, +:is(.ProseMirror, .set-content) pre code .hljs-template-variable, +:is(.ProseMirror, .set-content) pre code .hljs-property, +:is(.ProseMirror, .set-content) pre code .hljs-selector-attr, +:is(.ProseMirror, .set-content) pre code .hljs-selector-pseudo, +:is(.ProseMirror, .set-content) pre code .hljs-deletion { + color: var(--hl-variable); +} + +:is(.ProseMirror, .set-content) pre code .hljs-built_in, +:is(.ProseMirror, .set-content) pre code .hljs-meta, +:is(.ProseMirror, .set-content) pre code .hljs-tag { + color: var(--hl-builtin); +} + +:is(.ProseMirror, .set-content) pre code .hljs-emphasis { + font-style: italic; +} + +:is(.ProseMirror, .set-content) pre code .hljs-strong { + font-weight: 600; +} + +:is(.ProseMirror, .set-content) pre code .hljs-link { + text-decoration: underline; +} + +:is(.ProseMirror, .set-content) hr { + --divider-color: var(--border); + border: none; + height: var(--drag-handle-height); + margin-block: calc(1.2rem - var(--drag-handle-height) / 2); + background-image: linear-gradient(var(--divider-color), var(--divider-color)); + background-repeat: no-repeat; + background-size: 100% 1px; + background-position: center; +} + +:is(.ProseMirror, .set-content) ul[data-type="taskList"] { + list-style: none; +} + +:is(.ProseMirror, .set-content) ul[data-type="taskList"] > li { + display: flex; + align-items: flex-start; + + gap: 0; +} + +:is(.ProseMirror, .set-content) ul[data-type="taskList"] > li > label { + flex: 0 0 auto; + display: flex; + align-items: center; + width: var(--marker-gutter); + margin-left: calc(-1 * var(--marker-gutter)); + height: var(--editor-line); + user-select: none; +} + +:is(.ProseMirror, .set-content) ul[data-type="taskList"] > li > div { + flex: 1 1 auto; + min-width: 0; +} + +:is(.ProseMirror, .set-content) + ul[data-type="taskList"] + > li + > label + > input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + position: relative; + flex: 0 0 auto; + + font: inherit; + width: var(--checkbox-size); + height: var(--checkbox-size); + margin: 0; + border: 1.5px solid var(--text-subtle); + border-radius: 4px; + background: transparent; + cursor: pointer; + transition: + background-color 0.12s ease, + border-color 0.12s ease; +} + +:is(.ProseMirror, .set-content) + ul[data-type="taskList"] + > li + > label + > input[type="checkbox"]:hover { + border-color: var(--text-muted); +} + +:is(.ProseMirror, .set-content) + ul[data-type="taskList"] + > li + > label + > input[type="checkbox"]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +:is(.ProseMirror, .set-content) + ul[data-type="taskList"] + > li + > label + > input[type="checkbox"]:checked { + background: var(--accent); + border-color: var(--accent); +} + +:is(.ProseMirror, .set-content) + ul[data-type="taskList"] + > li + > label + > input[type="checkbox"]:checked::after { + content: ""; + position: absolute; + left: 50%; + top: 48%; + width: 0.3em; + height: 0.58em; + border: solid var(--on-accent); + border-width: 0 2px 2px 0; + transform: translate(-50%, -55%) rotate(45deg); +} + +:is(.ProseMirror, .set-content) + ul[data-type="taskList"] + > li[data-checked="true"] + > div + > p { + color: var(--text-subtle); + text-decoration: line-through; +} + +:is(.ProseMirror, .set-content) .page-link { + display: flex; + align-items: center; + gap: 0.5rem; + + padding: 0.25rem 0.4rem 0.25rem 0; + + line-height: 1; + border-radius: var(--radius); + + color: var(--accent); + cursor: pointer; + user-select: none; + transition: background-color 0.12s ease; +} + +/* A fill would read as the drop-into ring below. */ +:is(.ProseMirror, .set-content) .page-link:hover .page-link-title { + text-decoration-color: currentColor; +} + +:is(.ProseMirror, .set-content) .page-link-icon { + display: inline-flex; + flex: 0 0 auto; + color: var(--accent); +} + +:is(.ProseMirror, .set-content) .page-link-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 3px; + + padding-block: 0.25em; + margin-block: -0.25em; +} + +:is(.ProseMirror, .set-content) .date-mention { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.05rem 0.35rem 0.05rem 0.3rem; + border-radius: var(--radius); + color: var(--accent-ink); + background-color: var(--accent-soft); + cursor: pointer; + user-select: none; + white-space: nowrap; + transition: background-color 0.12s ease; +} + +:is(.ProseMirror, .set-content) .date-mention-icon { + display: inline-flex; + flex: 0 0 auto; + font-size: 0.85em; +} + +:is(.ProseMirror, .set-content) .date-mention-label { + font-size: 0.9em; +} + +:is(.ProseMirror, .set-content) .image-block { + display: flex; +} + +:is(.ProseMirror, .set-content) .image-frame { + position: relative; + + display: inline-block; + max-width: 100%; + line-height: 0; +} + +:is(.ProseMirror, .set-content) .image-frame img { + display: block; + max-width: 100%; + height: auto; + border-radius: var(--radius); +} + +/* + * Scrolled sideways inside the reading column; a swipe that reaches the end must not trigger the + * window's back gesture. + */ +:is(.ProseMirror, .set-content) .tableWrapper { + overflow-x: auto; + overscroll-behavior-x: contain; +} + +:is(.ProseMirror, .set-content) table { + border-collapse: collapse; +} + +:is(.ProseMirror, .set-content) th, +:is(.ProseMirror, .set-content) td { + min-width: 4em; + padding: 0.3em 0.65em; + border: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +:is(.ProseMirror, .set-content) th { + font-weight: 600; + background-color: var(--bg-elevated); +} + +/* The three the slash menu doesn't offer, which a note from elsewhere may use. */ +:is(.ProseMirror, .set-content) h4 { + font-size: 1.1em; + font-weight: 600; + line-height: 1.35; + margin-top: 1rem; + margin-bottom: 0.5rem; +} + +:is(.ProseMirror, .set-content) h5 { + font-size: 1em; + font-weight: 600; + margin-top: 0.9rem; + margin-bottom: 0.45rem; +} + +:is(.ProseMirror, .set-content) h6 { + font-size: 0.9em; + font-weight: 600; + color: var(--text-muted); + margin-top: 0.9rem; + margin-bottom: 0.45rem; +} + +/* Equations: KaTeX draws them, the dialog edits them; the source is what the file keeps. */ +:is(.ProseMirror, .set-content) .math-inline { + border-radius: 4px; + padding-inline: 0.1em; + cursor: pointer; + transition: background-color 0.12s ease; +} + +:is(.ProseMirror, .set-content) .math-block { + border-radius: var(--radius); + cursor: pointer; + transition: background-color 0.12s ease; +} + +:is(.ProseMirror, .set-content) .math-preview { + padding: 0.5rem 0.25rem; + text-align: center; + overflow-x: auto; +} + +:is(.ProseMirror, .set-content) .katex-display { + margin: 0; +} + +/* A wide equation scrolls rather than pushing the page out. */ +:is(.ProseMirror, .set-content) .katex-display > .katex { + overflow-x: auto; + overflow-y: hidden; + padding-block: 0.15em; +} + +:is(.ProseMirror, .set-content) mark { + color: inherit; + background-color: color-mix(in oklch, var(--warning) 26%, transparent); + border-radius: 2px; + padding-inline: 0.1em; +} + +:is(.ProseMirror, .set-content) .footnote-ref { + font-size: 0.75em; + color: var(--accent); + padding-inline: 0.1em; + cursor: pointer; +} + +:is(.ProseMirror, .set-content) .footnote-definition { + display: flex; + gap: 0.5rem; + font-size: 0.875em; + color: var(--text-muted); +} + +:is(.ProseMirror, .set-content) .footnote-label { + flex: none; + color: var(--accent); + font-variant-numeric: tabular-nums; + user-select: none; +} + +:is(.ProseMirror, .set-content) .footnote-body { + flex: 1 1 auto; + min-width: 0; +} + +/* + * Same clothes as the child-page block, but the arrow leaves the sheet, the underline is dashed, + * and it stays inline. + */ +:is(.ProseMirror, .set-content) .wiki-link { + display: inline; + border-radius: var(--radius); + padding-inline: 0.15em; + margin-inline: -0.05em; + color: var(--accent); + cursor: pointer; + transition: background-color 0.12s ease; +} + +:is(.ProseMirror, .set-content) .wiki-link:hover { + background-color: var(--accent-soft); + color: var(--accent-ink); +} + +:is(.ProseMirror, .set-content) .wiki-link-icon { + display: inline-flex; + margin-inline-end: 0.25em; + + /* Sits on the text's baseline rather than the line box's, so the sheet lines + up with the x-height of the title beside it at any font size. */ + vertical-align: -0.075em; +} + +:is(.ProseMirror, .set-content) .wiki-link-title { + text-decoration: underline; + text-decoration-style: dashed; + text-decoration-color: color-mix(in oklch, currentColor 45%, transparent); + text-underline-offset: 3px; +} + +:is(.ProseMirror, .set-content) .callout { + --callout: var(--accent); + border-left: 3px solid var(--callout); + background-color: color-mix(in oklch, var(--callout) 7%, transparent); + border-radius: 0 var(--radius) var(--radius) 0; + padding: 0.5rem 0.85rem; +} + +:is(.ProseMirror, .set-content) .callout[data-kind="tip"] { + --callout: var(--hl-string); +} + +:is(.ProseMirror, .set-content) .callout[data-kind="important"] { + --callout: var(--hl-keyword); +} + +:is(.ProseMirror, .set-content) .callout[data-kind="warning"] { + --callout: var(--warning); +} + +:is(.ProseMirror, .set-content) .callout[data-kind="caution"] { + --callout: var(--danger); +} + +:is(.ProseMirror, .set-content) .callout-header { + display: flex; + align-items: center; + gap: 0.4rem; + width: fit-content; + margin-bottom: 0.25rem; + font-size: 0.9em; + font-weight: 600; + color: var(--callout); + cursor: pointer; + user-select: none; +} + +:is(.ProseMirror, .set-content) .callout-icon { + display: inline-flex; +} + +:is(.ProseMirror, .set-content) .callout-body > * + * { + margin-top: var(--block-gap); +} + +:is(.ProseMirror, .set-content) .inline-image { + max-width: 100%; + height: auto; + vertical-align: middle; + border-radius: 4px; +} + +/* Published only: what the editor gets from its node views or never shows. */ +.set-content a:is(.page-link, .wiki-link, .footnote-label), +.set-content .footnote-ref a { + text-decoration: none; +} +.set-content .page-link { + width: fit-content; +} +.set-content :is(.date-mention, .math-inline, .math-block, .callout-header) { + cursor: default; +} +.set-content ul[data-type="taskList"] > li > label > input[type="checkbox"] { + cursor: default; +} +/* A tight list has no `

` in its items. */ +.set-content ul[data-type="taskList"] > li[data-checked="true"] > div { + color: var(--text-subtle); + text-decoration: line-through; +} +.set-content details > summary { + font-weight: 600; + cursor: pointer; +} +.set-content details > summary::marker { + color: var(--text-subtle); +} +.set-content details > :not(summary) { + margin-top: var(--block-gap); + padding-left: var(--marker-gutter); +} diff --git a/src/styles/fonts.css b/packages/design/fonts.css similarity index 81% rename from src/styles/fonts.css rename to packages/design/fonts.css index 82fd9d8..539ddcf 100644 --- a/src/styles/fonts.css +++ b/packages/design/fonts.css @@ -3,7 +3,7 @@ font-style: normal; font-display: swap; font-weight: 200 800; - src: url("/fonts/manrope-latin-wght-normal.woff2") format("woff2"); + src: url("./fonts/manrope-latin-wght-normal.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, @@ -14,7 +14,7 @@ font-style: normal; font-display: swap; font-weight: 200 800; - src: url("/fonts/manrope-latin-ext-wght-normal.woff2") format("woff2"); + src: url("./fonts/manrope-latin-ext-wght-normal.woff2") format("woff2"); unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; @@ -25,7 +25,7 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url("/fonts/roboto-serif-latin-wght-normal.woff2") format("woff2"); + src: url("./fonts/roboto-serif-latin-wght-normal.woff2") format("woff2"); unicode-range: U+0000-005F, U+0061-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, @@ -37,7 +37,7 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url("/fonts/roboto-serif-latin-ext-wght-normal.woff2") format("woff2"); + src: url("./fonts/roboto-serif-latin-ext-wght-normal.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, diff --git a/static/fonts/OFL-Manrope.txt b/packages/design/fonts/OFL-Manrope.txt similarity index 100% rename from static/fonts/OFL-Manrope.txt rename to packages/design/fonts/OFL-Manrope.txt diff --git a/static/fonts/OFL-RobotoSerif.txt b/packages/design/fonts/OFL-RobotoSerif.txt similarity index 100% rename from static/fonts/OFL-RobotoSerif.txt rename to packages/design/fonts/OFL-RobotoSerif.txt diff --git a/static/fonts/manrope-latin-ext-wght-normal.woff2 b/packages/design/fonts/manrope-latin-ext-wght-normal.woff2 similarity index 100% rename from static/fonts/manrope-latin-ext-wght-normal.woff2 rename to packages/design/fonts/manrope-latin-ext-wght-normal.woff2 diff --git a/static/fonts/manrope-latin-wght-normal.woff2 b/packages/design/fonts/manrope-latin-wght-normal.woff2 similarity index 100% rename from static/fonts/manrope-latin-wght-normal.woff2 rename to packages/design/fonts/manrope-latin-wght-normal.woff2 diff --git a/static/fonts/roboto-serif-latin-ext-wght-normal.woff2 b/packages/design/fonts/roboto-serif-latin-ext-wght-normal.woff2 similarity index 100% rename from static/fonts/roboto-serif-latin-ext-wght-normal.woff2 rename to packages/design/fonts/roboto-serif-latin-ext-wght-normal.woff2 diff --git a/static/fonts/roboto-serif-latin-wght-normal.woff2 b/packages/design/fonts/roboto-serif-latin-wght-normal.woff2 similarity index 100% rename from static/fonts/roboto-serif-latin-wght-normal.woff2 rename to packages/design/fonts/roboto-serif-latin-wght-normal.woff2 diff --git a/packages/design/package.json b/packages/design/package.json new file mode 100644 index 0000000..478f780 --- /dev/null +++ b/packages/design/package.json @@ -0,0 +1,21 @@ +{ + "name": "@rootstring/set-design", + "version": "0.0.2", + "description": "Set's design tokens, fonts, and the CSS that draws a note.", + "license": "AGPL-3.0-only", + "repository": { + "type": "git", + "url": "git+https://github.com/rootstring/set.git", + "directory": "packages/design" + }, + "exports": { + "./tokens.css": "./tokens.css", + "./fonts.css": "./fonts.css", + "./content.css": "./content.css", + "./fonts/*": "./fonts/*" + }, + "files": [ + "*.css", + "fonts" + ] +} diff --git a/src/styles/tokens.css b/packages/design/tokens.css similarity index 100% rename from src/styles/tokens.css rename to packages/design/tokens.css diff --git a/packages/markdown/README.md b/packages/markdown/README.md new file mode 100644 index 0000000..3c54a3e --- /dev/null +++ b/packages/markdown/README.md @@ -0,0 +1,15 @@ +# @rootstring/set-markdown + +Set's Markdown dialect, shared by the app's editor and anything that publishes a note. + +- `setDialect(md)`: the markdown-it rules for what Set reads beyond CommonMark (highlights, strict + linkify, footnotes, `[[wikilinks]]`, `> [!NOTE]` callouts, `$math$`). Tokens only. +- `renderHtml(markdown, options)` from `@rootstring/set-markdown/render`: a note as HTML, in the DOM + the editor draws, for [`@rootstring/set-design`](../design)'s `content.css`. + +Ships TypeScript source. Outside this workspace, have your bundler compile it (Vite: +`ssr.noExternal: ["@rootstring/set-markdown"]`). Install from git, pinned to a release tag: + +```sh +pnpm add "github:rootstring/set#v0.0.3&path:packages/markdown" +``` diff --git a/packages/markdown/package.json b/packages/markdown/package.json new file mode 100644 index 0000000..6f0fc10 --- /dev/null +++ b/packages/markdown/package.json @@ -0,0 +1,29 @@ +{ + "name": "@rootstring/set-markdown", + "version": "0.0.2", + "description": "Set's Markdown dialect for markdown-it, and a renderer that draws a note the way the editor does.", + "type": "module", + "license": "AGPL-3.0-only", + "repository": { + "type": "git", + "url": "git+https://github.com/rootstring/set.git", + "directory": "packages/markdown" + }, + "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./render": "./src/render.ts" + }, + "files": [ + "src" + ], + "dependencies": { + "highlight.js": "^11", + "katex": "^0.18.7", + "markdown-it": "^14.3.0", + "markdown-it-mark": "^4.0.0" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.2" + } +} diff --git a/src/lib/editor/markdown/syntax-extras.ts b/packages/markdown/src/dialect.ts similarity index 82% rename from src/lib/editor/markdown/syntax-extras.ts rename to packages/markdown/src/dialect.ts index c08f3ab..5452b32 100644 --- a/src/lib/editor/markdown/syntax-extras.ts +++ b/packages/markdown/src/dialect.ts @@ -1,6 +1,7 @@ +/// +import markPlugin from "markdown-it-mark"; import { mathSpanAt } from "./math"; import { - escapeAttr, type BlockRule, type InlineRule, type MarkdownIt, @@ -9,11 +10,24 @@ import { type Token, } from "./types"; +const configured = new WeakSet(); + /** - * Footnotes, wikilinks, GitHub alerts and math; parsed by `Footnote.ts`, `WikiLink.ts`, - * `Callout.ts` and `Math.ts`. + * What Set reads beyond CommonMark: `==highlights==`, strict linkify, footnotes, wikilinks, GitHub + * alerts and math. Tokens only; each renderer decides the HTML. The editor's are in the app's + * `syntax.ts`, the published page's in `render.ts`. Idempotent. + * + * Tokens it adds: `math_block`, `math_inline` (`meta.display`), `footnote_def` (`meta.label`, + * `meta.space`, raw `content`), `footnote_ref` (`meta.label`), `wikilink` (`meta.target`, + * `meta.alias`) and `callout_open`/`callout_close` (attrs `data-callout`, `data-fold`, + * `data-title`, `data-gap`). */ -export function registerExtras(md: MarkdownIt): void { +export function setDialect(md: MarkdownIt): void { + if (configured.has(md)) return; + configured.add(md); + + md.use(markPlugin as unknown as (md: MarkdownIt) => void); + configureLinkify(md); md.block.ruler.before("reference", "set_footnote_def", footnoteDefinition, { alt: ["paragraph", "reference"], }); @@ -26,41 +40,32 @@ export function registerExtras(md: MarkdownIt): void { md.inline.ruler.before("link", "set_math_inline", mathInline); md.core.ruler.after("block", "set_callouts", callouts); - const escape = md.utils.escapeHtml; const rules = md.renderer.rules; - rules.math_block = (tokens, idx) => { - const form = tokens[idx].meta?.single ? ' data-md="single"' : ""; - return `

${escape(tokens[idx].content)}
`; - }; - rules.math_inline = (tokens, idx) => { - const display = tokens[idx].meta?.display ? ' data-display=""' : ""; - return ``; - }; - rules.footnote_def = (tokens, idx) => { - const { label, space } = tokens[idx].meta as { label: string; space: string }; - return ( - `
` + - `${escape(tokens[idx].content)}
` - ); - }; - rules.footnote_ref = (tokens, idx) => { - const { label } = tokens[idx].meta as { label: string }; - return ``; - }; - rules.wikilink = (tokens, idx) => { - const { target, alias } = tokens[idx].meta as { - target: string; - alias: string | null; - }; - const aliasAttr = alias === null ? "" : ` data-alias="${escapeAttr(alias)}"`; - return ``; - }; rules.callout_open = (tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options); rules.callout_close = (tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options); } +/** Only URLs that say they are; a fuzzy match turns every `README.md` into a link. */ +function configureLinkify(md: MarkdownIt): void { + md.set({ linkify: true }); + md.linkify.set({ fuzzyLink: false }); + md.linkify.add("www.", { + validate(text: string, pos: number, self: { re: Record }) { + self.re.set_www ??= new RegExp( + `^${self.re.src_host_port_strict as string}${self.re.src_path as string}`, + "i", + ); + const match = (self.re.set_www as RegExp).exec(text.slice(pos)); + return match ? match[0].length : 0; + }, + normalize(match: { url: string }) { + match.url = `http://${match.url}`; + }, + }); +} + function lineText(state: StateBlock, line: number): string { return state.src.slice(state.bMarks[line] + state.tShift[line], state.eMarks[line]); } diff --git a/packages/markdown/src/icons.ts b/packages/markdown/src/icons.ts new file mode 100644 index 0000000..8d7919d --- /dev/null +++ b/packages/markdown/src/icons.ts @@ -0,0 +1,46 @@ +/** Icons shared by the editor's node views and the published page, so both draw the same thing. */ + +const svg = (body: string, stroke = "1.4") => + '"; + +/** A child page. */ +export const PAGE_ICON = svg( + '' + + '', +); + +/** A `[[wikilink]]`: the page, with an arrow. */ +export const WIKI_LINK_ICON = svg( + '' + + '', +); + +export const CALENDAR_ICON = svg( + '' + + '', +); + +/** GitHub's five alert kinds; others (Obsidian) are kept and drawn as a note. */ +export const CALLOUT_KINDS = ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"] as const; + +export const CALLOUT_ICONS: Record = { + note: svg(''), + tip: svg( + '', + ), + important: svg( + '', + ), + warning: svg(''), + caution: svg( + '', + ), +}; + +/** A callout's header when it has no title of its own: `WARNING` → `Warning`. */ +export const calloutLabel = (kind: string) => + kind.charAt(0).toUpperCase() + kind.slice(1).toLowerCase(); diff --git a/packages/markdown/src/index.ts b/packages/markdown/src/index.ts new file mode 100644 index 0000000..dfab135 --- /dev/null +++ b/packages/markdown/src/index.ts @@ -0,0 +1,11 @@ +export { setDialect } from "./dialect"; +export { delimit, mathSpanAt, type MathSpan } from "./math"; +export { + CALENDAR_ICON, + CALLOUT_ICONS, + CALLOUT_KINDS, + PAGE_ICON, + WIKI_LINK_ICON, + calloutLabel, +} from "./icons"; +export * from "./types"; diff --git a/src/lib/editor/markdown/markdown-it-mark.d.ts b/packages/markdown/src/markdown-it-mark.d.ts similarity index 100% rename from src/lib/editor/markdown/markdown-it-mark.d.ts rename to packages/markdown/src/markdown-it-mark.d.ts diff --git a/src/lib/editor/markdown/math.ts b/packages/markdown/src/math.ts similarity index 100% rename from src/lib/editor/markdown/math.ts rename to packages/markdown/src/math.ts diff --git a/packages/markdown/src/render.test.ts b/packages/markdown/src/render.test.ts new file mode 100644 index 0000000..b6dd900 --- /dev/null +++ b/packages/markdown/src/render.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { renderHtml, type RenderOptions } from "./render"; + +const published: RenderOptions = { + pageHref: (id) => (id === "p1" ? "/blog/launch" : null), + wikiHref: (title) => (title === "Launch" ? "/blog/launch" : null), + assetUrl: (src) => `/media/${src.split("/").pop()}`, +}; + +describe("renderHtml", () => { + it("draws a callout the way the editor does", () => { + const html = renderHtml("> [!WARNING] Careful\n> Hot surface"); + expect(html).toContain('
'); + expect(html).toContain('Careful'); + expect(html).toContain('
\n

Hot surface

'); + expect(html).not.toContain(" { + expect(renderHtml("> [!tip]\n> Try this")).toContain( + 'Tip', + ); + }); + + it("keeps a plain quote a quote", () => { + expect(renderHtml("> just a quote")).toContain("
"); + }); + + it("links a published child page as a page block", () => { + const html = renderHtml("[Launch](page:p1)", published); + expect(html).toContain(''); + expect(html).toContain('Launch'); + expect(html).not.toContain("

"); + }); + + it("leaves out a child page that isn't published", () => { + expect(renderHtml("Before\n\n[Draft](page:p2)\n\nAfter", published)).toBe( + "

Before

\n

After

\n", + ); + }); + + it("leaves out an unpublished child page that ends the note", () => { + expect(renderHtml("Before\n\n[Draft](page:p2)", published)).toBe("

Before

\n"); + }); + + it("keeps the words of an unpublished page link in running text", () => { + expect(renderHtml("See [Draft](page:p2) soon", published)).toBe( + "

See Draft soon

\n", + ); + }); + + it("draws a date as a date mention", () => { + const html = renderHtml("Due [Sep 22, 2026](date:2026-09-22)"); + expect(html).toContain(''); + }); + + it("resolves wikilinks, and shows the rest as text", () => { + const html = renderHtml("[[Launch#Why|the launch]] and [[Nowhere]]", published); + expect(html).toContain('
'); + expect(html).toContain('the launch'); + expect(html).toContain(" and Nowhere

"); + }); + + it("typesets math, and leaves prices alone", () => { + const html = renderHtml("$e^{i\\pi}$ costs $5 and $10\n\n$$\nx^2\n$$"); + expect(html).toContain(''); + expect(html).toContain("costs $5 and $10"); + expect(html).toContain('
'); + expect(html).toContain('class="katex-display"'); + }); + + it("links footnotes both ways", () => { + const html = renderHtml("Claim[^1]\n\n[^1]: Source *here*"); + expect(html).toContain( + '1', + ); + expect(html).toContain('
'); + expect(html).toContain('
Source here
'); + }); + + it("draws task lists as the editor does", () => { + const html = renderHtml("- [x] Done\n- [ ] Todo"); + expect(html).toContain('
    '); + expect(html).toContain( + '
  • \n
    Done
  • ', + ); + expect(html).toContain('
  • '); + }); + + it("leaves bullet lists that aren't tasks alone", () => { + expect(renderHtml("- one\n- two")).toBe("
      \n
    • one
    • \n
    • two
    • \n
    \n"); + }); + + it("points images at their published copy, as blocks when alone", () => { + const html = renderHtml("![A cat](Launch/Set-page-assets/cat.png)", published); + expect(html).toContain('
    { + const html = renderHtml( + '', + published, + ); + expect(html).toBe( + '
    \n', + ); + }); + + it("highlights code the editor's colours can reach", () => { + const html = renderHtml("```ts\nconst a = 1;\n```"); + expect(html).toContain('const'); + }); + + it("wraps tables so a wide one scrolls", () => { + expect(renderHtml("| a |\n| - |\n| 1 |")).toContain( + '
    ', + ); + }); + + it("gives headings unique ids to link to", () => { + const html = renderHtml("## Why local-first?\n\n## Why local-first?"); + expect(html).toContain('

    '); + expect(html).toContain('

    '); + }); + + it("highlights ==marked== text", () => { + expect(renderHtml("a ==b== c")).toBe("

    a b c

    \n"); + }); +}); diff --git a/packages/markdown/src/render.ts b/packages/markdown/src/render.ts new file mode 100644 index 0000000..e380fdb --- /dev/null +++ b/packages/markdown/src/render.ts @@ -0,0 +1,319 @@ +import MarkdownIt from "markdown-it"; +import katex from "katex"; +import hljs from "highlight.js/lib/common"; +import { setDialect } from "./dialect"; +import { + CALENDAR_ICON, + CALLOUT_ICONS, + PAGE_ICON, + WIKI_LINK_ICON, + calloutLabel, +} from "./icons"; +import { + escapeAttr, + type MarkdownIt as Dialect, + type StateCore, + type Token, +} from "./types"; + +export interface RenderOptions { + /** + * Where `[Title](page:)` goes, or `null` when that page isn't published. An unpublished + * child-page block is left out; a link in running text keeps its words. Default: none published. + */ + pageHref?: (id: string) => string | null; + /** Where `[[Title]]` goes, by the title it names (`#section` dropped), or `null`: plain text. */ + wikiHref?: (title: string) => string | null; + /** The URL for an image as the note wrote it, e.g. `Launch/Set-page-assets/cover.png`. */ + assetUrl?: (src: string) => string; +} + +interface Env { + render: RenderOptions; + headingIds: Set; + [key: string]: unknown; +} + +const PAGE = "page:"; +const DATE = "date:"; + +let shared: MarkdownIt | undefined; + +/** + * A note's Markdown as HTML for reading outside Set, in the DOM the editor draws, so the same CSS + * (`@rootstring/set-design`'s `content.css`, under `.set-content`) styles both. + * + * HTML in the note is passed through, as the editor keeps it: render only notes you trust. + */ +export function renderHtml(markdown: string, options: RenderOptions = {}): string { + shared ??= createRenderer(); + const env: Env = { render: options, headingIds: new Set() }; + return shared.render(markdown, env); +} + +function createRenderer(): MarkdownIt { + const md: MarkdownIt = new MarkdownIt({ + html: true, + highlight: (code, lang) => { + const language = lang.trim().split(/\s+/)[0]; + if (!language || !hljs.getLanguage(language)) return ""; + return hljs.highlight(code, { language, ignoreIllegals: true }).value; + }, + }); + setDialect(md as unknown as Dialect); + + const dialect = md as unknown as Dialect; + dialect.core.ruler.after("linkify", "set_publish", (state) => { + const env = state.env as unknown as Env; + taskLists(state); + setLinks(state, env.render); + imageBlocks(state); + headingIds(state, env); + }); + + const escape = md.utils.escapeHtml; + const rules = md.renderer.rules; + const options = (env: unknown) => (env as Env).render; + + rules.image = (tokens, idx, opts, env, self) => { + const token = tokens[idx]; + const src = token.attrGet("src") ?? ""; + token.attrSet("src", options(env).assetUrl?.(src) ?? src); + token.attrSet("alt", self.renderInlineAsText(token.children ?? [], opts, env)); + token.attrSet("loading", "lazy"); + const html = self.renderToken(tokens, idx, opts); + return token.meta?.block ? `
    ${html}
    ` : html; + }; + // A resized image is written as ``. + const imgSources = (html: string, env: unknown) => + html.replace(/(]*?\bsrc=")([^"]*)(")/gi, (_, open, src, close) => { + const url = options(env).assetUrl?.(src) ?? src; + return open + escapeAttr(url) + close; + }); + rules.html_block = (tokens, idx, _opts, env) => { + const html = imgSources(tokens[idx].content, env); + return /^\s*]*>\s*$/i.test(html) + ? `
    ${html.trim()}
    \n` + : html; + }; + rules.html_inline = (tokens, idx, _opts, env) => imgSources(tokens[idx].content, env); + + rules.table_open = () => '

    \n'; + rules.table_close = () => "
    \n"; + + rules.math_inline = (tokens, idx) => { + const display = Boolean(tokens[idx].meta?.display); + const cls = display ? "math-inline math-display" : "math-inline"; + return `${tex(tokens[idx].content, display)}`; + }; + rules.math_block = (tokens, idx) => + `
    ${tex(tokens[idx].content, true)}
    \n`; + + rules.footnote_ref = (tokens, idx) => { + const label = escapeAttr(String(tokens[idx].meta?.label)); + return `${label}`; + }; + rules.footnote_def = (tokens, idx, _opts, env) => { + const label = escapeAttr(String(tokens[idx].meta?.label)); + const body = md.renderInline(tokens[idx].content, env); + return ( + `
    ` + + `${label}` + + `
    ${body}
    \n` + ); + }; + + rules.wikilink = (tokens, idx, _opts, env) => { + const { target, alias } = tokens[idx].meta as { + target: string; + alias: string | null; + }; + const label = escape(alias ?? target); + const href = options(env).wikiHref?.(target.split("#")[0].trim()); + if (!href) return label; + return ( + `` + + `${WIKI_LINK_ICON}` + + `${label}` + ); + }; + + rules.callout_open = (tokens, idx) => { + const token = tokens[idx]; + const kind = token.attrGet("data-callout") ?? "note"; + const lower = kind.toLowerCase(); + const title = token.attrGet("data-title") || calloutLabel(kind); + return ( + `
    ` + + `
    ` + + `${CALLOUT_ICONS[lower] ?? CALLOUT_ICONS.note}` + + `${escape(title)}
    ` + + `
    \n` + ); + }; + rules.callout_close = () => "
    \n"; + + rules.set_page_link = (tokens, idx) => { + const { href, title } = tokens[idx].meta as { href: string; title: string }; + return ( + `` + + `${PAGE_ICON}` + + `${escape(title)}\n` + ); + }; + rules.set_task_open = (tokens, idx) => { + const checked = tokens[idx].meta?.checked ? " checked" : ""; + return `
    `; + }; + rules.set_task_close = () => "
    "; + + return md; +} + +function tex(source: string, displayMode: boolean): string { + return katex.renderToString(source, { displayMode, throwOnError: false }); +} + +/** The closing token that pairs with `tokens[open]`. */ +function closeOf(tokens: Token[], open: number): number { + const { level } = tokens[open]; + for (let i = open + 1; i < tokens.length; i++) { + if (tokens[i].nesting === -1 && tokens[i].level === level) return i; + } + return -1; +} + +const TASK = /^\[([ xX])\][ \t]/; + +/** `- [ ] item` and `- [x] item`, drawn the way the editor draws a task list. */ +function taskLists(state: StateCore): void { + const tokens = state.tokens; + for (let i = tokens.length - 1; i >= 0; i--) { + const item = tokens[i]; + const inline = tokens[i + 2]; + if (item.type !== "list_item_open" || inline?.type !== "inline") continue; + const match = TASK.exec(inline.content); + if (!match) continue; + + const first = inline.children?.[0]; + if (first?.type === "text") first.content = first.content.replace(TASK, ""); + inline.content = inline.content.replace(TASK, ""); + const checked = match[1] !== " "; + item.attrSet("data-checked", String(checked)); + + for (let j = i - 1; j >= 0; j--) { + if (tokens[j].type === "bullet_list_open" && tokens[j].level === item.level - 1) { + tokens[j].attrSet("data-type", "taskList"); + break; + } + } + const close = closeOf(tokens, i); + if (close < 0) continue; + const open = new state.Token("set_task_open", "", 0); + open.meta = { checked }; + tokens.splice(close, 0, new state.Token("set_task_close", "", 0)); + tokens.splice(i + 1, 0, open); + } +} + +/** Links to other pages and to dates, which only mean something inside Set. */ +function setLinks(state: StateCore, options: RenderOptions): void { + const tokens = state.tokens; + for (let i = tokens.length - 1; i >= 0; i--) { + const inline = tokens[i]; + // Past the end when the last block was a child page that was just left out. + if (inline?.type !== "inline" || !inline.children) continue; + const children = inline.children; + + // A child-page block: a paragraph that is one page link and nothing else. + const only = children.length === 3 && children[0].type === "link_open"; + const href = children[0]?.attrGet("href") ?? ""; + if (only && href.startsWith(PAGE) && tokens[i - 1]?.type === "paragraph_open") { + const target = options.pageHref?.(decodeURIComponent(href.slice(PAGE.length))); + const title = children[1].content; + const replacement: Token[] = []; + if (target) { + const block = new state.Token("set_page_link", "", 0); + block.meta = { href: target, title }; + block.block = true; + replacement.push(block); + } + tokens.splice(i - 1, 3, ...replacement); + continue; + } + + for (let j = 0; j < children.length; j++) { + const open = children[j]; + if (open.type !== "link_open") continue; + const link = open.attrGet("href") ?? ""; + const close = closeOf(children, j); + if (close < 0) continue; + + if (link.startsWith(DATE)) { + const iso = decodeURIComponent(link.slice(DATE.length)); + toHtml( + open, + `"); + } else if (link.startsWith(PAGE)) { + const target = options.pageHref?.(decodeURIComponent(link.slice(PAGE.length))); + if (target) { + open.attrSet("href", target); + } else { + toHtml(open, ""); + toHtml(children[close], ""); + } + } + } + } +} + +function toHtml(token: Token, html: string): void { + token.type = "html_inline"; + token.content = html; + token.attrs = null; +} + +/** An image alone in its paragraph is a block, as in the editor. */ +function imageBlocks(state: StateCore): void { + const tokens = state.tokens; + for (let i = 1; i < tokens.length - 1; i++) { + const inline = tokens[i]; + const children = inline.children ?? []; + if ( + inline.type !== "inline" || + children.length !== 1 || + children[0].type !== "image" + ) { + continue; + } + if (tokens[i - 1].type !== "paragraph_open") continue; + tokens[i - 1].tag = "div"; + tokens[i - 1].attrSet("class", "image-block"); + tokens[i + 1].tag = "div"; + children[0].meta = { ...children[0].meta, block: true }; + } +} + +/** `## Why local-first` → `id="why-local-first"`, so a section can be linked to. */ +function headingIds(state: StateCore, env: Env): void { + const tokens = state.tokens; + for (let i = 0; i < tokens.length; i++) { + if (tokens[i].type !== "heading_open") continue; + const text = tokens[i + 1]?.content ?? ""; + const base = + text + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-+|-+$/g, "") || "section"; + let id = base; + for (let n = 2; env.headingIds.has(id); n++) id = `${base}-${n}`; + env.headingIds.add(id); + tokens[i].attrSet("id", id); + } +} diff --git a/src/lib/editor/markdown/types.ts b/packages/markdown/src/types.ts similarity index 100% rename from src/lib/editor/markdown/types.ts rename to packages/markdown/src/types.ts diff --git a/packages/markdown/tsconfig.json b/packages/markdown/tsconfig.json new file mode 100644 index 0000000..ca63f79 --- /dev/null +++ b/packages/markdown/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "types": [] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89ab362..83c71e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,12 @@ importers: .: dependencies: + '@rootstring/set-design': + specifier: workspace:* + version: link:packages/design + '@rootstring/set-markdown': + specifier: workspace:* + version: link:packages/markdown '@tauri-apps/api': specifier: ^2 version: 2.11.1 @@ -134,6 +140,27 @@ importers: specifier: ^5.0.0 version: 5.0.0(jsdom@30.0.1)(vite@6.4.3) + packages/design: {} + + packages/markdown: + dependencies: + highlight.js: + specifier: ^11 + version: 11.12.0 + katex: + specifier: ^0.18.7 + version: 0.18.7 + markdown-it: + specifier: ^14.3.0 + version: 14.3.0 + markdown-it-mark: + specifier: ^4.0.0 + version: 4.0.0 + devDependencies: + '@types/markdown-it': + specifier: ^14.1.2 + version: 14.1.2 + packages: '@asamuzakjp/css-color@6.0.7': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..924b55f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/src/app.css b/src/app.css index 81df46d..d6b079d 100644 --- a/src/app.css +++ b/src/app.css @@ -1,8 +1,9 @@ -@import "./styles/fonts.css"; -@import "./styles/tokens.css"; +@import "@rootstring/set-design/fonts.css"; +@import "@rootstring/set-design/tokens.css"; @import "./styles/base.css"; @import "./styles/controls.css"; @import "./styles/popover.css"; @import "./styles/settings.css"; @import "./styles/onboarding.css"; +@import "@rootstring/set-design/content.css"; @import "./styles/editor.css"; diff --git a/src/lib/editor/Callout.ts b/src/lib/editor/Callout.ts index 30d4475..9f38b25 100644 --- a/src/lib/editor/Callout.ts +++ b/src/lib/editor/Callout.ts @@ -1,5 +1,10 @@ import { Node } from "@tiptap/core"; import type { Node as PMNode } from "@tiptap/pm/model"; +import { + CALLOUT_ICONS as ICONS, + CALLOUT_KINDS as KINDS, + calloutLabel as title, +} from "@rootstring/set-markdown"; import { showBlockMenu } from "./block-menu"; declare module "@tiptap/core" { @@ -10,32 +15,6 @@ declare module "@tiptap/core" { } } -/** GitHub's five; others (Obsidian) are kept. */ -const KINDS = ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"] as const; - -const svg = (body: string) => - '"; - -const ICONS: Record = { - note: svg(''), - tip: svg( - '', - ), - important: svg( - '', - ), - warning: svg(''), - caution: svg( - '', - ), -}; - -const title = (kind: string) => - kind.charAt(0).toUpperCase() + kind.slice(1).toLowerCase(); - interface State { write(content?: string): void; ensureNewLine(): void; diff --git a/src/lib/editor/DateMention.ts b/src/lib/editor/DateMention.ts index 4e5cf25..04fa0b6 100644 --- a/src/lib/editor/DateMention.ts +++ b/src/lib/editor/DateMention.ts @@ -2,15 +2,10 @@ import { Node, mergeAttributes } from "@tiptap/core"; import type { Editor, Range } from "@tiptap/core"; import { formatDateLabel } from "./date-format"; import { showDatePicker } from "./date-picker"; +import { CALENDAR_ICON as CALENDAR_SVG } from "@rootstring/set-markdown"; const DATE_NODE = "date"; -const CALENDAR_SVG = - ''; - const SCHEME = "date:"; export function insertDateMention( diff --git a/src/lib/editor/Math.ts b/src/lib/editor/Math.ts index 6f87804..89306af 100644 --- a/src/lib/editor/Math.ts +++ b/src/lib/editor/Math.ts @@ -10,7 +10,7 @@ import { } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; import { askMath } from "$lib/state/math-dialog.svelte"; -import { delimit } from "./markdown/math"; +import { delimit } from "@rootstring/set-markdown"; import { cancelMath, renderMath } from "./math-render"; import { writeLines } from "./RawMarkdown"; diff --git a/src/lib/editor/PageLink.ts b/src/lib/editor/PageLink.ts index 0a778a4..6b85456 100644 --- a/src/lib/editor/PageLink.ts +++ b/src/lib/editor/PageLink.ts @@ -1,5 +1,6 @@ import { Extension, Node, mergeAttributes } from "@tiptap/core"; import { NodeSelection } from "@tiptap/pm/state"; +import { PAGE_ICON as ICON_SVG } from "@rootstring/set-markdown"; import { PAGE_LINK_NODE } from "./page-links"; import { TRASH_SVG, @@ -20,12 +21,6 @@ export interface PageLinkOptions { const SCHEME = "page:"; -const ICON_SVG = - ''; - function displayTitle(title: string): string { return title.trim() || "Untitled"; } diff --git a/src/lib/editor/WikiLink.ts b/src/lib/editor/WikiLink.ts index 8b21cd5..091806d 100644 --- a/src/lib/editor/WikiLink.ts +++ b/src/lib/editor/WikiLink.ts @@ -8,6 +8,7 @@ import { type BlockMenuItem, } from "./block-menu"; import { askForPage } from "./wiki-link-tools"; +import { WIKI_LINK_ICON as ICON_SVG } from "@rootstring/set-markdown"; export interface WikiLinkOptions { onOpenPage?: (id: string) => void; @@ -33,13 +34,6 @@ const OPEN_SVG = '' + ''; -const ICON_SVG = - ''; - /** * `[[Page]]` and `[[Page|shown as]]`. Resolved when drawn and again when clicked, so a page made * since works. diff --git a/src/lib/editor/markdown/index.ts b/src/lib/editor/markdown/index.ts index c3712b5..f670a2e 100644 --- a/src/lib/editor/markdown/index.ts +++ b/src/lib/editor/markdown/index.ts @@ -8,7 +8,7 @@ import { type MarkSerializer, type NodeSerializer, } from "./serialize"; -import type { MarkdownIt } from "./types"; +import type { MarkdownIt } from "@rootstring/set-markdown"; interface MarkdownStorage { parser: { md: MarkdownIt }; diff --git a/src/lib/editor/markdown/serialize.ts b/src/lib/editor/markdown/serialize.ts index 1fc583a..bf160d8 100644 --- a/src/lib/editor/markdown/serialize.ts +++ b/src/lib/editor/markdown/serialize.ts @@ -1,6 +1,6 @@ import type { Mark, Node as PMNode } from "@tiptap/pm/model"; import type { SetMarkdownState } from "./state"; -import { mathSpanAt } from "./math"; +import { mathSpanAt } from "@rootstring/set-markdown"; /** * With no recorded syntax the output is what tiptap-markdown wrote; recorded `markup` (see diff --git a/src/lib/editor/markdown/state.ts b/src/lib/editor/markdown/state.ts index ce77b0c..62bf4ca 100644 --- a/src/lib/editor/markdown/state.ts +++ b/src/lib/editor/markdown/state.ts @@ -1,6 +1,6 @@ import { MarkdownSerializerState } from "prosemirror-markdown"; import type { Mark, Node as PMNode } from "@tiptap/pm/model"; -import type { MarkdownIt } from "./types"; +import type { MarkdownIt } from "@rootstring/set-markdown"; /** Marked internal by prosemirror-markdown but what every serializer spec is written against. */ interface StateInternals { diff --git a/src/lib/editor/markdown/syntax.ts b/src/lib/editor/markdown/syntax.ts index 08957aa..a651b4c 100644 --- a/src/lib/editor/markdown/syntax.ts +++ b/src/lib/editor/markdown/syntax.ts @@ -1,14 +1,13 @@ -import markPlugin from "markdown-it-mark"; -import { registerExtras } from "./syntax-extras"; import { escapeAttr, + setDialect, wrapRule, type InlineRule, type BlockRule, type MarkdownIt, type StateCore, type Token, -} from "./types"; +} from "@rootstring/set-markdown"; /** * Parsing goes Markdown → HTML → schema, and the schema loses what it has no node for. So the @@ -42,11 +41,10 @@ export function setSyntax(md: MarkdownIt): void { if (configured.has(md)) return; configured.add(md); - md.use(markPlugin as unknown as (md: MarkdownIt) => void); - configureLinkify(md); + // Before the dialect, so `set_callouts` runs ahead of `set_definitions` (both follow `block`). recordBreaks(md); recordReferences(md); - registerExtras(md); + setDialect(md); md.core.ruler.before("text_join", "set_markup", stampMarkup); md.core.ruler.after("set_markup", "set_spacers", injectSpacers); @@ -81,6 +79,35 @@ export function setSyntax(md: MarkdownIt): void { `
    ${escape(tokens[idx].content)}
    `; // See `injectSpacers`. rules.set_spacer = () => "

    "; + + // The dialect's tokens, as the HTML `Footnote.ts`, `WikiLink.ts` and `Math.ts` parse. + rules.math_block = (tokens, idx) => { + const form = tokens[idx].meta?.single ? ' data-md="single"' : ""; + return `
    ${escape(tokens[idx].content)}
    `; + }; + rules.math_inline = (tokens, idx) => { + const display = tokens[idx].meta?.display ? ' data-display=""' : ""; + return ``; + }; + rules.footnote_def = (tokens, idx) => { + const { label, space } = tokens[idx].meta as { label: string; space: string }; + return ( + `
    ` + + `${escape(tokens[idx].content)}
    ` + ); + }; + rules.footnote_ref = (tokens, idx) => { + const { label } = tokens[idx].meta as { label: string }; + return ``; + }; + rules.wikilink = (tokens, idx) => { + const { target, alias } = tokens[idx].meta as { + target: string; + alias: string | null; + }; + const aliasAttr = alias === null ? "" : ` data-alias="${escapeAttr(alias)}"`; + return ``; + }; } /** Set's own HTML blocks go through the DOM; anything else is kept exactly. */ @@ -91,25 +118,6 @@ function isDomBlock(source: string): boolean { return tag === "img" && /^\s*]*>\s*$/i.test(source); } -/** Only URLs that say they are; a fuzzy match turns every `README.md` into a link. */ -function configureLinkify(md: MarkdownIt): void { - md.set({ linkify: true }); - md.linkify.set({ fuzzyLink: false }); - md.linkify.add("www.", { - validate(text: string, pos: number, self: { re: Record }) { - self.re.set_www ??= new RegExp( - `^${self.re.src_host_port_strict as string}${self.re.src_path as string}`, - "i", - ); - const match = (self.re.set_www as RegExp).exec(text.slice(pos)); - return match ? match[0].length : 0; - }, - normalize(match: { url: string }) { - match.url = `http://${match.url}`; - }, - }); -} - /** Which of the two hard-break spellings was used: trailing spaces or `\`. */ function recordBreaks(md: MarkdownIt): void { const ruler = md.inline.ruler; diff --git a/src/lib/search/wiki-links.ts b/src/lib/search/wiki-links.ts index dd75e68..43e0a45 100644 --- a/src/lib/search/wiki-links.ts +++ b/src/lib/search/wiki-links.ts @@ -7,7 +7,7 @@ export interface ScannableBody { } /** - * Read off raw Markdown, which the index already holds. Must agree with `markdown/syntax-extras.ts` + * Read off raw Markdown, which the index already holds. Must agree with `wikiLink` in packages/markdown/src/dialect.ts * and `Workspace.pageIdByTitle`. Mirrored by `wiki_link_titles` in src-tauri/src/index.rs. */ export function wikiLinkTitles(body: string): string[] { diff --git a/src/styles/editor.css b/src/styles/editor.css index 60697e0..24132cf 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -1,313 +1,16 @@ -.ProseMirror { - font-size: var(--editor-font-size); - font-family: var(--font-body); - line-height: var(--editor-line-height); - color: var(--text); - - --marker-gutter: calc(1.5 * var(--editor-font-size)); - --list-indent: calc(1.375 * var(--editor-font-size)); - --checkbox-size: calc(0.95 * var(--editor-font-size)); - - --list-gap: calc(0.375 * var(--editor-font-size)); - - --editor-line: calc(var(--editor-line-height) * var(--editor-font-size)); - - width: var(--reading-column); - margin-inline: auto; -} .dragging-block .ProseMirror { width: 100%; padding-inline: var(--reading-pad); } -.ProseMirror :where(p, h1, h2, h3, h4, h5, h6, ul, ol, blockquote) { - margin: 0; -} -/* `pretty` weighs the paragraph as a whole; the right edge stays ragged on purpose. */ -.ProseMirror :where(p, li, blockquote, dd, figcaption) { - text-wrap: pretty; -} -/* A heading is a line or two, where a one-word second line is the whole - shape of it, so these balance rather than merely avoid the worst break. */ -.ProseMirror :where(h1, h2, h3, h4, h5, h6) { - text-wrap: balance; -} -.ProseMirror > * + * { - margin-top: var(--block-gap); -} -.ProseMirror h1 { - font-size: 1.875em; - font-weight: 700; - line-height: 1.3; - margin-top: 1.75rem; - margin-bottom: 0.85rem; -} -.ProseMirror h2 { - font-size: 1.5em; - font-weight: 600; - line-height: 1.3; - margin-top: 1.5rem; - margin-bottom: 0.75rem; -} -.ProseMirror h3 { - font-size: 1.25em; - font-weight: 600; - line-height: 1.3; - margin-top: 1.15rem; - margin-bottom: 0.6rem; -} -.ProseMirror ul, -.ProseMirror ol { - margin-left: 0; - padding-left: var(--marker-gutter); -} -.ProseMirror ul:not([data-type="taskList"]), -.ProseMirror ol { - list-style: none; -} -.ProseMirror ul:not([data-type="taskList"]) > li, -.ProseMirror ol > li { - position: relative; -} -.ProseMirror ul:not([data-type="taskList"]) > li::before { - content: ""; - position: absolute; - left: calc(0.5 * var(--checkbox-size) - var(--marker-gutter)); - top: calc(0.5 * var(--editor-line)); - transform: translate(-50%, -50%); - width: 0.3em; - height: 0.3em; - border-radius: 50%; - background: var(--text-subtle); -} -.ProseMirror ol > li::before { - content: counter(list-item) "."; - position: absolute; - left: calc(-1 * var(--marker-gutter)); - top: 0; - font-size: 0.875em; - line-height: var(--editor-line); - white-space: nowrap; - font-variant-numeric: tabular-nums; - color: var(--text-subtle); -} -.ProseMirror li > ul, -.ProseMirror li > ol, -.ProseMirror li > div > ul, -.ProseMirror li > div > ol { - position: relative; - margin-top: var(--list-gap); - margin-left: calc(var(--list-indent) - var(--marker-gutter)); -} -.ProseMirror li > ul::before, -.ProseMirror li > ol::before, -.ProseMirror li > div > ul::before, -.ProseMirror li > div > ol::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - - left: calc(0.5 * var(--checkbox-size) - var(--list-indent)); - border-left: 1px solid var(--border); - pointer-events: none; -} -.ProseMirror li + li { - margin-top: var(--list-gap); -} -/* A loose list, one written with blank lines between its items, is spaced the - way Markdown means it: further apart than the tight list the editor makes. */ -.ProseMirror ul:not([data-tight]):not([data-type="taskList"]) > li + li, -.ProseMirror ol:not([data-tight]) > li + li { - margin-top: calc(2 * var(--list-gap)); -} -.ProseMirror > ul + ul, -.ProseMirror > ul + ol, -.ProseMirror > ol + ul, -.ProseMirror > ol + ol { - margin-top: var(--list-gap); -} -.ProseMirror blockquote { - border-left: 3px solid var(--border); - padding-left: calc(var(--marker-gutter) - 3px); - color: var(--text-muted); -} -.ProseMirror a { - color: var(--accent); - text-decoration: underline; - text-decoration-color: var(--accent-soft); - text-underline-offset: 2px; - cursor: pointer; -} -.ProseMirror a:hover { - text-decoration-color: var(--accent); -} -.ProseMirror code { - font-family: var(--font-mono); - font-size: 0.875em; - background-color: var(--bg-elevated); - padding: 0.15em 0.35em; - border-radius: 4px; -} -.ProseMirror pre { - font-family: var(--font-mono); - background-color: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 0.85rem 1rem; - overflow-x: auto; -} -.ProseMirror pre code { - background: none; - padding: 0; - font-size: 0.875rem; -} -.ProseMirror pre code .hljs-comment, -.ProseMirror pre code .hljs-quote { - color: var(--hl-comment); - font-style: italic; -} -.ProseMirror pre code .hljs-keyword, -.ProseMirror pre code .hljs-literal, -.ProseMirror pre code .hljs-selector-tag, -.ProseMirror pre code .hljs-section, -.ProseMirror pre code .hljs-doctag { - color: var(--hl-keyword); -} -.ProseMirror pre code .hljs-string, -.ProseMirror pre code .hljs-regexp, -.ProseMirror pre code .hljs-addition { - color: var(--hl-string); -} -.ProseMirror pre code .hljs-number, -.ProseMirror pre code .hljs-symbol, -.ProseMirror pre code .hljs-bullet { - color: var(--hl-number); -} -.ProseMirror pre code .hljs-title, -.ProseMirror pre code .hljs-name, -.ProseMirror pre code .hljs-selector-id, -.ProseMirror pre code .hljs-selector-class { - color: var(--hl-function); -} -.ProseMirror pre code .hljs-title.class_, -.ProseMirror pre code .hljs-type { - color: var(--hl-class); -} -.ProseMirror pre code .hljs-attr, -.ProseMirror pre code .hljs-attribute, -.ProseMirror pre code .hljs-variable, -.ProseMirror pre code .hljs-template-variable, -.ProseMirror pre code .hljs-property, -.ProseMirror pre code .hljs-selector-attr, -.ProseMirror pre code .hljs-selector-pseudo, -.ProseMirror pre code .hljs-deletion { - color: var(--hl-variable); -} -.ProseMirror pre code .hljs-built_in, -.ProseMirror pre code .hljs-meta, -.ProseMirror pre code .hljs-tag { - color: var(--hl-builtin); -} -.ProseMirror pre code .hljs-emphasis { - font-style: italic; -} -.ProseMirror pre code .hljs-strong { - font-weight: 600; -} -.ProseMirror pre code .hljs-link { - text-decoration: underline; -} -.ProseMirror hr { - --divider-color: var(--border); - border: none; - height: var(--drag-handle-height); - margin-block: calc(1.2rem - var(--drag-handle-height) / 2); - background-image: linear-gradient(var(--divider-color), var(--divider-color)); - background-repeat: no-repeat; - background-size: 100% 1px; - background-position: center; -} .ProseMirror hr.ProseMirror-selectednode { --divider-color: var(--accent); } -.ProseMirror ul[data-type="taskList"] { - list-style: none; -} -.ProseMirror ul[data-type="taskList"] > li { - display: flex; - align-items: flex-start; - gap: 0; -} -.ProseMirror ul[data-type="taskList"] > li > label { - flex: 0 0 auto; - display: flex; - align-items: center; - width: var(--marker-gutter); - margin-left: calc(-1 * var(--marker-gutter)); - height: var(--editor-line); - user-select: none; -} -.ProseMirror ul[data-type="taskList"] > li > div { - flex: 1 1 auto; - min-width: 0; -} -.ProseMirror ul[data-type="taskList"] > li > label > input[type="checkbox"] { - appearance: none; - -webkit-appearance: none; - position: relative; - flex: 0 0 auto; - - font: inherit; - width: var(--checkbox-size); - height: var(--checkbox-size); - margin: 0; - border: 1.5px solid var(--text-subtle); - border-radius: 4px; - background: transparent; - cursor: pointer; - transition: - background-color 0.12s ease, - border-color 0.12s ease; -} -.ProseMirror ul[data-type="taskList"] > li > label > input[type="checkbox"]:hover { - border-color: var(--text-muted); -} -.ProseMirror - ul[data-type="taskList"] - > li - > label - > input[type="checkbox"]:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} -.ProseMirror ul[data-type="taskList"] > li > label > input[type="checkbox"]:checked { - background: var(--accent); - border-color: var(--accent); -} -.ProseMirror - ul[data-type="taskList"] - > li - > label - > input[type="checkbox"]:checked::after { - content: ""; - position: absolute; - left: 50%; - top: 48%; - width: 0.3em; - height: 0.58em; - border: solid var(--on-accent); - border-width: 0 2px 2px 0; - transform: translate(-50%, -55%) rotate(45deg); -} -.ProseMirror ul[data-type="taskList"] > li[data-checked="true"] > div > p { - color: var(--text-subtle); - text-decoration: line-through; -} .ProseMirror li[data-progress="partial"] > label > input[type="checkbox"]:not(:checked) { border-color: var(--accent); } + .ProseMirror li[data-progress="partial"] > label @@ -322,9 +25,11 @@ background: var(--accent); transform: translate(-50%, -50%); } + .ProseMirror li[data-progress="all"] > label > input[type="checkbox"]:not(:checked) { border-color: var(--accent); } + .ProseMirror .task-count { margin-left: 0.5em; font-size: 0.8em; @@ -334,40 +39,26 @@ user-select: none; cursor: default; } + .ProseMirror li[data-progress="all"] > div > p > .task-count { color: var(--accent); } -.ProseMirror .page-link { - display: flex; - align-items: center; - gap: 0.5rem; - - padding: 0.25rem 0.4rem 0.25rem 0; - - line-height: 1; - border-radius: var(--radius); - color: var(--accent); - cursor: pointer; - user-select: none; - transition: background-color 0.12s ease; -} .ProseMirror .page-link.ProseMirror-selectednode { background-color: var(--accent-soft); color: var(--accent-ink); } + .ProseMirror .page-link.ProseMirror-selectednode .page-link-icon { color: var(--accent-ink); } -/* A fill would read as the drop-into ring below. */ -.ProseMirror .page-link:hover .page-link-title { - text-decoration-color: currentColor; -} + /* Dropping a block here files it inside the page. */ .ProseMirror .page-link.drop-into { background-color: var(--accent-soft); box-shadow: inset 0 0 0 2px var(--accent); } + .ProseMirror .page-link.drop-into .page-link-icon, .ProseMirror .page-link.drop-into .page-link-title { color: var(--accent-ink); @@ -379,80 +70,32 @@ display: none; } -.ProseMirror .page-link-icon { - display: inline-flex; - flex: 0 0 auto; - color: var(--accent); -} -.ProseMirror .page-link-title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - - padding-block: 0.25em; - margin-block: -0.25em; -} -.ProseMirror .date-mention { - display: inline-flex; - align-items: center; - gap: 0.3rem; - padding: 0.05rem 0.35rem 0.05rem 0.3rem; - border-radius: var(--radius); - color: var(--accent-ink); - background-color: var(--accent-soft); - cursor: pointer; - user-select: none; - white-space: nowrap; - transition: background-color 0.12s ease; -} .ProseMirror .date-mention:hover, .ProseMirror .date-mention.ProseMirror-selectednode { background-color: color-mix(in oklch, var(--accent-soft), var(--accent) 20%); } + /* ProseMirror sets `contenteditable` false on the whole editor when read-only. */ .ProseMirror[contenteditable="false"] .date-mention { cursor: default; } + .ProseMirror[contenteditable="false"] .date-mention:hover { background-color: var(--accent-soft); } -.ProseMirror .date-mention-icon { - display: inline-flex; - flex: 0 0 auto; - font-size: 0.85em; -} -.ProseMirror .date-mention-label { - font-size: 0.9em; -} -.ProseMirror .image-block { - display: flex; -} -.ProseMirror .image-frame { - position: relative; - display: inline-block; - max-width: 100%; - line-height: 0; -} -.ProseMirror .image-frame img { - display: block; - max-width: 100%; - height: auto; - border-radius: var(--radius); -} .ProseMirror .image-block.missing .image-frame { min-width: 8rem; min-height: 5rem; border: 1px dashed var(--border); background: var(--bg-elevated); } + .ProseMirror .image-block.selected .image-frame img { outline: 2px solid var(--accent); outline-offset: 2px; } + .ProseMirror .image-grip { position: absolute; right: 6px; @@ -466,21 +109,26 @@ opacity: 0; transition: opacity 0.12s ease; } + .ProseMirror .image-block:hover .image-grip, .ProseMirror .image-block.selected .image-grip, .ProseMirror .image-block.resizing .image-grip { opacity: 1; } + .ProseMirror .image-block.resizing { cursor: nwse-resize; user-select: none; } + .ProseMirror[contenteditable="false"] .image-grip { display: none; } + .ProseMirror .details { position: relative; } + .ProseMirror .details-toggle { position: absolute; top: 0; @@ -501,54 +149,40 @@ background-color 0.12s ease, color 0.12s ease; } + .ProseMirror .details-toggle:hover { background-color: var(--bg-hover); color: var(--text-muted); } + .ProseMirror .details-toggle svg { transition: transform 0.15s ease; } + .ProseMirror .details[data-open="true"] .details-toggle svg { transform: rotate(90deg); } + .ProseMirror .details-inner { padding-left: var(--marker-gutter); } + .ProseMirror .details-inner > .details-summary { font-weight: 600; } + .ProseMirror .details-inner > .details-summary + * { margin-top: 0.4rem; } + .ProseMirror .details[data-open="false"] .details-inner > :not(.details-summary) { display: none; } -/* - * Scrolled sideways inside the reading column; a swipe that reaches the end must not trigger the - * window's back gesture. - */ -.ProseMirror .tableWrapper { - overflow-x: auto; - overscroll-behavior-x: contain; -} -.ProseMirror table { - border-collapse: collapse; -} -.ProseMirror th, -.ProseMirror td { - min-width: 4em; - padding: 0.3em 0.65em; - border: 1px solid var(--border); - text-align: left; - vertical-align: top; -} -.ProseMirror th { - font-weight: 600; - background-color: var(--bg-elevated); -} + .ProseMirror .selectedCell { background-color: var(--accent-soft); } + /* Selected whole, from the drag handle. The class lands on the wrapper. */ .ProseMirror .tableWrapper.ProseMirror-selectednode > table { outline: 2px solid var(--accent); @@ -570,39 +204,21 @@ cursor: pointer; visibility: hidden; } + .table-add:hover { color: var(--accent); background-color: var(--bg-hover); border-style: solid; border-color: var(--accent); } + .table-add-row { height: 13px; } + .table-add-col { width: 13px; } -/* The three the slash menu doesn't offer, which a note from elsewhere may use. */ -.ProseMirror h4 { - font-size: 1.1em; - font-weight: 600; - line-height: 1.35; - margin-top: 1rem; - margin-bottom: 0.5rem; -} -.ProseMirror h5 { - font-size: 1em; - font-weight: 600; - margin-top: 0.9rem; - margin-bottom: 0.45rem; -} -.ProseMirror h6 { - font-size: 0.9em; - font-weight: 600; - color: var(--text-muted); - margin-top: 0.9rem; - margin-bottom: 0.45rem; -} /* Source kept exactly as the file had it: HTML Set can't show, and runs of reference definitions. Quiet, so it reads as the note's plumbing. */ @@ -615,188 +231,70 @@ border-radius: var(--radius); padding: 0.4rem 0.75rem; } + .ProseMirror .raw-inline { font-family: var(--font-mono); font-size: 0.8em; color: var(--text-subtle); } -/* Equations: KaTeX draws them, the dialog edits them; the source is what the file keeps. */ -.ProseMirror .math-inline { - border-radius: 4px; - padding-inline: 0.1em; - cursor: pointer; - transition: background-color 0.12s ease; -} .ProseMirror .math-inline:hover, .ProseMirror .math-inline.ProseMirror-selectednode { background-color: var(--accent-soft); } + .ProseMirror .math-inline:empty::before { content: "equation"; color: var(--text-subtle); font-style: italic; } + /* The source, until KaTeX has drawn it. */ .ProseMirror .math-pending { font-family: var(--font-mono); font-size: 0.875em; color: var(--text-muted); } -.ProseMirror .math-block { - border-radius: var(--radius); - cursor: pointer; - transition: background-color 0.12s ease; -} + .ProseMirror .math-block:hover, .ProseMirror .math-block.ProseMirror-selectednode { background-color: var(--accent-soft); } -.ProseMirror .math-preview { - padding: 0.5rem 0.25rem; - text-align: center; - overflow-x: auto; -} + .ProseMirror .math-block.empty .math-preview::before { content: "Empty equation"; color: var(--text-subtle); font-style: italic; } -.ProseMirror .katex-display { - margin: 0; -} -/* A wide equation scrolls rather than pushing the page out. */ -.ProseMirror .katex-display > .katex { - overflow-x: auto; - overflow-y: hidden; - padding-block: 0.15em; -} - -.ProseMirror mark { - color: inherit; - background-color: color-mix(in oklch, var(--warning) 26%, transparent); - border-radius: 2px; - padding-inline: 0.1em; -} -.ProseMirror .footnote-ref { - font-size: 0.75em; - color: var(--accent); - padding-inline: 0.1em; - cursor: pointer; -} -.ProseMirror .footnote-definition { - display: flex; - gap: 0.5rem; - font-size: 0.875em; - color: var(--text-muted); -} -.ProseMirror .footnote-label { - flex: none; - color: var(--accent); - font-variant-numeric: tabular-nums; - user-select: none; -} -.ProseMirror .footnote-body { - flex: 1 1 auto; - min-width: 0; -} - -/* - * Same clothes as the child-page block, but the arrow leaves the sheet, the underline is dashed, - * and it stays inline. - */ -.ProseMirror .wiki-link { - display: inline; - border-radius: var(--radius); - padding-inline: 0.15em; - margin-inline: -0.05em; - color: var(--accent); - cursor: pointer; - transition: background-color 0.12s ease; -} -.ProseMirror .wiki-link:hover, .ProseMirror .wiki-link.ProseMirror-selectednode { background-color: var(--accent-soft); color: var(--accent-ink); } -.ProseMirror .wiki-link-icon { - display: inline-flex; - margin-inline-end: 0.25em; - /* Sits on the text's baseline rather than the line box's, so the sheet lines - up with the x-height of the title beside it at any font size. */ - vertical-align: -0.075em; -} -.ProseMirror .wiki-link-title { - text-decoration: underline; - text-decoration-style: dashed; - text-decoration-color: color-mix(in oklch, currentColor 45%, transparent); - text-underline-offset: 3px; -} /* No page answers to this title. */ .ProseMirror .wiki-link.missing { color: var(--text-muted); cursor: default; } + .ProseMirror .wiki-link.missing:hover { background-color: transparent; color: var(--text-muted); } + .ProseMirror .wiki-link.missing .wiki-link-icon { opacity: 0.6; } + .ProseMirror .wiki-link.missing .wiki-link-title { text-decoration-color: var(--border); } -.ProseMirror .callout { - --callout: var(--accent); - border-left: 3px solid var(--callout); - background-color: color-mix(in oklch, var(--callout) 7%, transparent); - border-radius: 0 var(--radius) var(--radius) 0; - padding: 0.5rem 0.85rem; -} -.ProseMirror .callout[data-kind="tip"] { - --callout: var(--hl-string); -} -.ProseMirror .callout[data-kind="important"] { - --callout: var(--hl-keyword); -} -.ProseMirror .callout[data-kind="warning"] { - --callout: var(--warning); -} -.ProseMirror .callout[data-kind="caution"] { - --callout: var(--danger); -} -.ProseMirror .callout-header { - display: flex; - align-items: center; - gap: 0.4rem; - width: fit-content; - margin-bottom: 0.25rem; - font-size: 0.9em; - font-weight: 600; - color: var(--callout); - cursor: pointer; - user-select: none; -} .ProseMirror[contenteditable="false"] .callout-header { cursor: default; } -.ProseMirror .callout-icon { - display: inline-flex; -} -.ProseMirror .callout-body > * + * { - margin-top: var(--block-gap); -} -.ProseMirror .inline-image { - max-width: 100%; - height: auto; - vertical-align: middle; - border-radius: 4px; -} .ProseMirror .inline-image.ProseMirror-selectednode { outline: 2px solid var(--accent); outline-offset: 2px; @@ -813,16 +311,20 @@ ); transition: background-color 0.12s ease; } + :root[data-drag-handle="off"] .drag-handle { display: none; } + /* Set and cleared by `DragHitArea`. */ .away-from-handle .drag-handle { visibility: hidden; } + .drag-handle:active { cursor: grabbing; } + .block-menu { position: fixed; z-index: 60; @@ -835,6 +337,7 @@ border-radius: var(--radius); box-shadow: 0 8px 24px oklch(0% 0 0 / 0.24); } + .block-menu-item { display: flex; align-items: center; @@ -850,31 +353,38 @@ text-align: left; cursor: pointer; } + .block-menu-item:hover:not(:disabled) { background: var(--accent); color: var(--on-accent); } + .block-menu-item:hover:not(:disabled) .block-menu-icon { color: var(--on-accent); } + /* Listed, but not on offer, so the menu reads the same everywhere. */ .block-menu-item:disabled { opacity: 0.45; cursor: default; } + .block-menu-icon { display: inline-flex; flex: 0 0 auto; font-size: 0.9375rem; color: var(--text-subtle); } + .block-menu-item.danger:hover:not(:disabled) { background: var(--danger); color: oklch(100% 0 0); } + .block-menu-item.danger:hover:not(:disabled) .block-menu-icon { color: oklch(100% 0 0); } + .date-picker { position: fixed; z-index: 60; @@ -886,16 +396,19 @@ border-radius: var(--radius); box-shadow: 0 8px 24px oklch(0% 0 0 / 0.24); } + .date-picker-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; } + .date-picker-label { font-size: 0.85rem; font-weight: 500; } + .date-picker-nav { display: inline-flex; align-items: center; @@ -910,15 +423,18 @@ line-height: 1; cursor: pointer; } + .date-picker-nav:hover { background: var(--accent-soft); color: var(--accent-ink); } + .date-picker-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; } + .date-picker-weekday { display: flex; align-items: center; @@ -927,6 +443,7 @@ color: var(--text-subtle); font-size: 0.7rem; } + .date-picker-day { display: flex; align-items: center; @@ -939,20 +456,25 @@ font-size: 0.8rem; cursor: pointer; } + .date-picker-day:hover { background: var(--accent-soft); } + .date-picker-day.today { color: var(--accent); font-weight: 600; } + .date-picker-day.today:hover { color: var(--accent-ink); } + .date-picker-day.selected { background: var(--accent); color: oklch(100% 0 0); } + /* One height for both, so the picker does not jump. */ .date-picker-time { display: flex; @@ -964,6 +486,7 @@ padding-top: 0.4rem; border-top: 1px solid var(--border); } + .date-picker-time-input { padding: 0.2rem 0.35rem; border: 1px solid var(--border); @@ -973,12 +496,14 @@ font: inherit; font-size: 0.8rem; } + .date-picker-footer { display: flex; justify-content: space-between; gap: 0.4rem; margin-top: 0.25rem; } + .date-picker-action { padding: 0.25rem 0.4rem; border: none; @@ -989,10 +514,12 @@ font-weight: 500; cursor: pointer; } + .date-picker-action:hover { background: var(--accent-soft); color: var(--accent-ink); } + .date-picker-action.danger:hover { background: var(--danger); color: oklch(100% 0 0); diff --git a/static/_headers b/static/_headers index 05ce2e3..d2287ea 100644 --- a/static/_headers +++ b/static/_headers @@ -8,7 +8,3 @@ # Fingerprinted, so a changed file is a changed URL. /_app/immutable/* Cache-Control: public, max-age=31536000, immutable - -# Fonts are not fingerprinted, so long but not immutable. -/fonts/* - Cache-Control: public, max-age=604800 diff --git a/vite.config.js b/vite.config.js index d892071..fb0e794 100644 --- a/vite.config.js +++ b/vite.config.js @@ -30,5 +30,10 @@ export default defineConfig(async () => ({ watch: { ignored: ["**/src-tauri/**"], }, + // SvelteKit narrows Vite's allow list to src/ and node_modules/. The workspace packages are + // linked into node_modules but resolve to packages/, so their fonts need allowing there. + fs: { + allow: ["packages"], + }, }, })); diff --git a/vitest.config.ts b/vitest.config.ts index 66a410d..f3302fc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,12 +2,12 @@ import { defineConfig } from "vitest/config"; import { svelte } from "@sveltejs/vite-plugin-svelte"; import { fileURLToPath } from "node:url"; -// Pure logic under src/lib only; no sveltekit() plugin. Storage and editor behaviour belongs in -// e2e/. +// Pure logic under src/lib and packages/ only; no sveltekit() plugin. Storage and editor behaviour +// belongs in e2e/. export default defineConfig({ plugins: [svelte()], test: { - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "packages/*/src/**/*.test.ts"], environment: "node", }, resolve: { From 37ee5f5640677283af6f54e7d8cbb2ef19cdc830 Mon Sep 17 00:00:00 2001 From: rootstring-bot Date: Wed, 23 Sep 2026 02:01:41 +0200 Subject: [PATCH 2/2] fix(tests): flaky toggle e2e test --- e2e/toggle.spec.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/e2e/toggle.spec.ts b/e2e/toggle.spec.ts index 3714b1e..3526da4 100644 --- a/e2e/toggle.spec.ts +++ b/e2e/toggle.spec.ts @@ -9,12 +9,18 @@ import { storedBody } from "./db"; /** * The wait is load bearing: a native caret move reaches ProseMirror on a later `selectionchange`. + * Chrome now and then drops a Home that lands a few milliseconds after a keystroke, so it is + * pressed again until the caret moves. */ async function typeAtLineStart(page: Page, text: string) { - await page.keyboard.press("Home"); - await expect - .poll(() => page.evaluate(() => window.getSelection()?.anchorOffset ?? -1)) - .toBe(0); + await expect(async () => { + await page.keyboard.press("Home"); + await expect + .poll(() => page.evaluate(() => window.getSelection()?.anchorOffset ?? -1), { + timeout: 1000, + }) + .toBe(0); + }).toPass(); await page.keyboard.type(text); }