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/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); } 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 @@
+/// Hot surface ");
+ });
+
+ it("leaves out a child page that isn't published", () => {
+ expect(renderHtml("Before\n\n[Draft](page:p2)\n\nAfter", published)).toBe(
+ " Before After Before See Draft soon a b c {
+ 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("
');
+ expect(html).toContain(
+ '
\n
\n");
+ });
+
+ it("points images at their published copy, as blocks when alone", () => {
+ const html = renderHtml("", published);
+ expect(html).toContain(' {
+ const html = renderHtml(
+ '
',
+ published,
+ );
+ expect(html).toBe(
+ '
',
+ );
+ });
+
+ 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("
`.
+ 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)
+ ? `
\n';
+ rules.table_close = () => "
]*>\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