diff --git a/CLAUDE.md b/CLAUDE.md index ce3d3a3d2..f84993d47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,7 +207,8 @@ not. Run `ls` rather than trusting this tree to be exhaustive. │ ├── theme/ # Colors, spacing, typography tokens │ └── utils/ # Filesystem wrapper, logger, helpers ├── docs/specs/ # Design docs for cross-cutting features — read these before -│ # touching file attachments or mobile connectivity +│ # touching file attachments, mobile connectivity, or +│ # Markdown rendering ├── images/ # Documentation images ├── scripts/ # bootstrap.sh (setup) + check-playwright-browser.sh ├── Taskfile.yml diff --git a/docs/specs/markdown-rendering.md b/docs/specs/markdown-rendering.md new file mode 100644 index 000000000..ab090cd81 --- /dev/null +++ b/docs/specs/markdown-rendering.md @@ -0,0 +1,219 @@ +# Feature Spec: Markdown Rendering + +Status: **Implemented** — describes shipped behaviour. +Owner: TBD · Target: Jot webapp + mobile + +--- + +## 1. Scope + +Markdown applies to the **`content` of text notes**, and nothing else: + +- **List-note item text is plain.** No Markdown is parsed in it; the one piece of + formatting it gets is bare-URL autolinking, via the `LinkText` component on + each client. +- **Note titles are plain.** They are rendered as text everywhere. + +Both clients render the same feature set from the same source string, so a note +written on a phone reads identically in a browser and the other way round. They +get there through entirely different libraries, which is the reason this document +exists: the two implementations have no shared code path to keep them honest, only +a shared test corpus and this spec. + +| | Webapp | Mobile | +|---|---|---| +| Parser | `marked` (`gfm: true`, `breaks: true`) | `markdown-it` (`linkify: true`, `typographer: false`, `html: true`, `breaks: false`) | +| Renderer | HTML, filtered through a DOMPurify tag allowlist | `react-native-markdown-display` render rules | + +--- + +## 2. What is supported + +Stated as behaviour rather than as one library's options, since the two clients +reach it differently. + +| Syntax | Behaviour | +|---|---| +| `#`, `##`, `###` | Headings, each with its own size | +| `####`, `#####`, `######` | Headings, rendered as **bold body-size text** | +| `**bold**`, `*italic*`, `~~strike~~` | Rendered | +| `` `inline code` `` | Rendered | +| Fenced and indented code | Rendered **with block layout** | +| Bullet and ordered lists | Rendered | +| `- [ ]` / `- [x]` | Rendered as ☐ / ☑, **non-interactive** | +| Blockquotes | Rendered | +| `[text](url)` and bare `https://…` | Rendered as links | +| `---`, `***` | Horizontal rule | +| Single newline | Line break | + +**Link schemes are restricted to `http`, `https` and `mailto`** on both clients. +A link with any other scheme — `tel:`, `sms:`, an app deep link, `javascript:` — +renders as its label in plain text, with nothing to tap. So does a link with no +scheme at all (`/dashboard`, `example.com`). Notes are shareable, so the target +of a link can come from a collaborator, and following it is a navigation the +reader did not choose. + +Syntax highlighting inside code blocks is deliberately absent (§6). + +--- + +## 3. How unsupported formatting is handled + +There are **three** possible outcomes, and every unsupported construct is filed +under exactly one of them. This is the part that keeps being got wrong. + +### Rendered as literal source + +The user sees exactly what they typed, and can tell that Jot did not act on it. + +| Construct | Shown as | +|---|---| +| `![alt](url)` | `![alt](url)`, and `![alt](url "title")` when a title is present | +| Tables | The pipe rows as typed, header row included | +| Raw HTML | `bold text` — inert text, never an element | + +Literal means literal all the way down: a URL inside one of these regions — a +table cell, an `href` attribute — is text too, not a link. An HTML block also +swallows the Markdown inside it (`
` / `**bold**` / `
` shows the `**`), +on both clients. + +### Formatting dropped, text kept + +**Empty.** Nothing is handled this way today. Every unsupported construct shows +its source instead, so unsupported syntax never fails silently — markup that +quietly disappeared read as a rendering bug rather than a limitation. (This is a +change from the webapp's earlier behaviour, where raw HTML lost its markup and +kept its words.) + +Note that `####`–`######` are *not* in this category: they are supported, and +render as bold body text (§2). They are the one construct that renders without a +size of its own, which is a styling decision rather than a parsing one — both +clients emit real heading elements and style them down. + +### Removed entirely + +**Empty by choice.** No construct is dropped without a trace. The category is +named here so that adding something to it is a deliberate act. + +### Smart typography + +Not an unsupported construct so much as a transformation that does not happen: +`--` stays `--` and `"hi"` keeps its straight quotes. `markdown-it`'s +`typographer` is on by default and is switched off explicitly, since `marked` has +no equivalent and enabling it on one client only is exactly the kind of drift this +spec exists to prevent. + +--- + +## 4. Why + +- **Images are not rendered** because they are a separate, first-class feature: + they live in a gallery above the note body, not embedded in Markdown. See + [`file-attachments.md`](file-attachments.md), which states the no-inline-embedding + rule this implements. Rendering `![alt](url)` would additionally have the mobile + app fetch an arbitrary third-party URL out of note content. +- **Tables are not rendered** because they do not fit a phone note card, and a + table that renders in the browser but not on mobile is worse than one that + renders nowhere. +- **`####`–`######` render as bold body text** because three distinct heading + sizes are enough for a note: below h3 the steps would be indistinguishable from + each other at note sizes. The markup still does something visible, so it does + not read as broken. +- **Raw HTML is never rendered**, on the usual grounds. It is shown as source + rather than stripped so that it lands in the same bucket as everything else Jot + does not support. +- **Smart typography was dropped for parity**, as above. + +--- + +## 5. Where it is implemented + +| Concern | File | +|---|---| +| Shared link-scheme policy + literal-image format | `shared/src/markdown.ts` | +| Shared conformance corpus (both test suites) | `shared/src/markdownCases.ts` | +| Webapp renderer + tag allowlist | `webapp/src/utils/markdown.ts` | +| Mobile parser, core rules, link render rule | `mobile/src/utils/markdown.tsx` | +| Mobile styles | `mobile/src/utils/markdownStyles.ts` | + +The mobile note **card** preview does not use any of this — it flattens Markdown +to a single line of plain text with `stripMarkdownForPreview` in +`markdownStyles.ts`. Rendering Markdown in mobile cards is +[#819](https://github.com/hanzei/jot/issues/819). + +### Implementation constraints + +Each of these is a trap the naive implementation falls into. They are recorded +here so the next person does not have to rediscover them. + +- **`marked` has no `image` tokenizer to disable.** v18 handles images inside the + link tokenizer, so `use({ tokenizer: { image() { return false } } })` throws + `tokenizer 'image' does not exist`. Worse, `use()` treats a tokenizer that + returns `false` as *"fall through to the default"*, so tables cannot be disabled + that way either — doing so still renders a full ``. **Both must be + renderer overrides.** +- **`markdown-it`'s `.disable('image')` does not produce raw text.** It produces + `!` followed by a *live link*, and with an empty alt an invisible clickable one. + Mobile rewrites image tokens into text tokens in a core rule instead. +- **Nothing is disabled at parser level on mobile — everything is rewritten + after parsing**, and `linkify` is why. `.disable('table')` looks correct in + isolation (it leaves plain paragraph text), but linkify then turns a URL in a + cell into a live link inside text that is supposed to be literal, which the + webapp does not do. Same for `html: false`, which would escape the tags and + leave linkify free to link a URL inside an `href` attribute. Parsing them and + collapsing the tokens afterwards discards the parsed contents, links included. + This is the one place where the obvious config change silently reintroduces a + divergence, so it is worth re-reading before "simplifying" either option. +- **The image reconstruction format is pinned** in `formatLiteralImage` + (`shared/src/markdown.ts`) and used by both clients, because both rebuild it + from parsed tokens rather than echoing the source. If one side dropped the + title or the leading `!`, `![a](b "t")` would quietly diverge again. +- **Mobile rewrites images at parser level, not in a render rule**, because + `react-native-markdown-display` marks every image token `block: true`, which + would break the literal source out of its paragraph and onto its own line. +- **All mobile core rules run after `linkify`.** Running them before would have + linkify turn the URL inside a literal `![alt](url)` into a live link. +- **linkify is fuzzier than GFM and is trimmed back.** linkify-it autolinks a + bare `example.com`; marked requires a scheme or a `www.` prefix. Turning + `fuzzyLink` off is not the fix — it would also stop linking + `www.example.com`, which marked *does* link — so the extra links are made and + then unwrapped (`gfmAutolinksOnly`). Both clients accept the + `http://`-normalized target such an autolink produces. +- **h4–h6 are styled down, not rewritten.** Both clients emit real heading + elements and give them body size and bold weight in CSS + (`.markdown-content :is(h4, h5, h6)`) and in the style map + (`markdownStyles.ts`). Keeping the elements keeps the document outline intact + for assistive technology; a parser-level rewrite would not. +- **Webapp `breaks: true` and mobile's `softbreak` rule produce the same result by + different means** — the render rule emits `\n` despite `breaks: false`. Setting + mobile to `breaks: true` would look like a harmless alignment and change nothing + at all, until that rule changes. +- **Checkbox markers are positional on mobile.** `markdown-it` has no task-list + support, so `[x]` survives as literal text and is swapped for ☑ only at the head + of a list item's first inline token. That position check is what keeps `- [x]` + inside a fenced code block intact, matching `marked`, which only emits a + checkbox token for a real task-list item. + +--- + +## 6. Deliberately not covered + +- **Interactive checkboxes.** Toggling a rendered ☐ would mean writing back into + `content` — a much larger feature than rendering. +- **Markdown in list-note item text.** It stays plain (§1). +- **Syntax highlighting** in code blocks. The webapp allowlist drops the + `class="language-js"` attribute `marked` emits, so the language tag is parsed + and ignored on both clients. + +--- + +## 7. Testing + +`shared/src/markdownCases.ts` is the single list of inputs. Both +`webapp/src/utils/__tests__/markdown.test.ts` and +`mobile/__tests__/markdown.test.tsx` assert one expectation per case id and fail +if any id has none, so a case cannot be covered on one client and forgotten on the +other. Adding a case breaks both suites until both are updated. + +`webapp/e2e/tests/markdown.spec.ts` covers the same feature set through the +browser, on real note content. diff --git a/mobile/__tests__/markdown.test.tsx b/mobile/__tests__/markdown.test.tsx new file mode 100644 index 000000000..9a2b23c2a --- /dev/null +++ b/mobile/__tests__/markdown.test.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import Markdown from 'react-native-markdown-display'; +import { MARKDOWN_CASES } from '@jot/shared'; +import { allowLinkPress, markdownParser, markdownRules } from '../src/utils/markdown'; +import { compactMarkdownStyles, fullMarkdownStyles } from '../src/utils/markdownStyles'; + +// The mobile half of the shared conformance corpus (shared/src/markdownCases.ts); +// webapp/src/utils/__tests__/markdown.test.ts runs the same list through marked. +// The coverage test at the bottom is what keeps the two from drifting apart. +// +// Two assertion surfaces, because mobile splits the work in two: +// - html(): markdown-it's own renderer over the exact token stream +// react-native-markdown-display consumes. Everything the parser decides — +// headings, images, tables, linkify, typography, checkboxes — is visible here. +// - the tree: what the render rules do with those tokens, which is +// where link schemes are enforced. + +function markdownFor(id: string): string { + const testCase = MARKDOWN_CASES.find((c) => c.id === id); + if (!testCase) throw new Error(`unknown markdown case: ${id}`); + return testCase.markdown; +} + +function html(id: string): string { + return (markdownParser as unknown as { render(src: string): string }).render(markdownFor(id)); +} + +type RenderedNode = { props?: Record; children?: unknown[] } | string | null; + +function renderCase(id: string): RenderedNode { + return render( + + {markdownFor(id)} + , + ).toJSON() as RenderedNode; +} + +/** Every string in the rendered tree, i.e. what the user actually reads. */ +function visibleText(node: RenderedNode): string { + if (node === null) return ''; + if (typeof node === 'string') return node; + return (node.children ?? []).map((child) => visibleText(child as RenderedNode)).join(''); +} + +/** The text of every tappable node — a link the user can follow. */ +function tappableText(node: RenderedNode): string[] { + if (node === null || typeof node === 'string') return []; + const own = typeof node.props?.onPress === 'function' ? [visibleText(node)] : []; + return own.concat( + (node.children ?? []).flatMap((child) => tappableText(child as RenderedNode)), + ); +} + +const conformance: Record void> = { + bold: () => expect(html('bold')).toContain('hello'), + italic: () => expect(html('italic')).toContain('hello'), + strikethrough: () => expect(html('strikethrough')).toContain('hello'), + + 'heading-1': () => expect(html('heading-1')).toContain('

Top heading

'), + 'heading-3': () => expect(html('heading-3')).toContain('

Third heading

'), + // Rendered as heading4/heading6 nodes; markdownStyles.ts gives them body size + // and bold weight rather than a size of their own. + 'heading-4-bold': () => expect(html('heading-4-bold')).toContain('

Fourth heading

'), + 'heading-6-bold': () => expect(html('heading-6-bold')).toContain('
Sixth heading
'), + + 'inline-code': () => expect(html('inline-code')).toContain('code'), + 'fenced-code': () => { + expect(html('fenced-code')).toContain('
 {
+    expect(html('indented-code')).toContain('
 {
+    expect(html('task-marker-in-code')).toContain('
 expect(html('bullet-list')).toContain('
  • item
  • '), + 'ordered-list': () => { + expect(html('ordered-list')).toContain('
      '); + expect(html('ordered-list')).toContain('
    1. item
    2. '); + }, + 'task-unchecked': () => expect(html('task-unchecked')).toContain('
    3. ☐ todo
    4. '), + 'task-checked': () => expect(html('task-checked')).toContain('
    5. ☑ done
    6. '), + 'task-checked-uppercase': () => + expect(html('task-checked-uppercase')).toContain('
    7. ☑ done
    8. '), + 'task-marker-outside-list': () => { + expect(html('task-marker-outside-list')).toContain('[x] not a task'); + expect(html('task-marker-outside-list')).not.toContain('☑'); + }, + + blockquote: () => expect(html('blockquote')).toContain('
      '), + 'hr-dashes': () => expect(html('hr-dashes')).toContain('
      '), + 'hr-stars': () => expect(html('hr-stars')).toContain('
      '), + + 'inline-link': () => expect(tappableText(renderCase('inline-link'))).toEqual(['text']), + 'bare-url': () => { + expect(html('bare-url')).toContain('href="https://example.com"'); + expect(tappableText(renderCase('bare-url'))).toEqual(['https://example.com']); + }, + 'bare-url-www': () => { + expect(html('bare-url-www')).toContain('href="http://www.example.com"'); + expect(tappableText(renderCase('bare-url-www'))).toEqual(['www.example.com']); + }, + 'bare-domain': () => { + const tree = renderCase('bare-domain'); + expect(visibleText(tree)).toBe('visit example.com now'); + expect(tappableText(tree)).toEqual([]); + }, + 'mailto-link': () => expect(tappableText(renderCase('mailto-link'))).toEqual(['mail']), + 'tel-link': () => { + const tree = renderCase('tel-link'); + expect(visibleText(tree)).toContain('call'); + expect(tappableText(tree)).toEqual([]); + }, + 'javascript-link': () => expect(tappableText(renderCase('javascript-link'))).toEqual([]), + 'relative-link': () => { + const tree = renderCase('relative-link'); + expect(visibleText(tree)).toContain('rel'); + expect(tappableText(tree)).toEqual([]); + }, + + image: () => { + const tree = renderCase('image'); + expect(visibleText(tree)).toContain('![alt text](https://example.com/y.png)'); + expect(tappableText(tree)).toEqual([]); + }, + 'image-with-title': () => + expect(visibleText(renderCase('image-with-title'))).toContain( + '![alt](https://example.com/y.png "the title")', + ), + 'image-empty-alt': () => { + const tree = renderCase('image-empty-alt'); + expect(visibleText(tree)).toContain('![](https://example.com/y.png)'); + // .disable('image') would leave an invisible clickable link here. + expect(tappableText(tree)).toEqual([]); + }, + 'image-inline-in-paragraph': () => + expect(visibleText(renderCase('image-inline-in-paragraph'))).toContain( + 'see ![a](https://example.com/y.png) here', + ), + + table: () => { + expect(html('table')).not.toContain(' { + const tree = renderCase('table-cell-url'); + expect(visibleText(tree)).toContain('a | b\n--- | ---\nhttps://example.com | 2'); + expect(tappableText(tree)).toEqual([]); + }, + + 'typography-dashes': () => expect(visibleText(renderCase('typography-dashes'))).toBe('a -- b'), + 'typography-quotes': () => expect(visibleText(renderCase('typography-quotes'))).toBe('say "hi"'), + + 'soft-break': () => expect(visibleText(renderCase('soft-break'))).toBe('first\nsecond'), + 'raw-html': () => expect(visibleText(renderCase('raw-html'))).toBe('bold text'), + 'raw-html-attribute-url': () => { + const tree = renderCase('raw-html-attribute-url'); + expect(visibleText(tree)).toBe('x'); + expect(tappableText(tree)).toEqual([]); + }, + 'raw-html-block-swallows-markdown': () => + expect(visibleText(renderCase('raw-html-block-swallows-markdown'))).toBe( + '
      \n**bold**\n
      ', + ), + 'raw-html-script': () => + expect(visibleText(renderCase('raw-html-script'))).toBe(''), +}; + +describe('markdown rendering', () => { + it('has an expectation for every shared conformance case', () => { + const missing = MARKDOWN_CASES.filter((c) => !(c.id in conformance)).map((c) => c.id); + expect(missing).toEqual([]); + const stale = Object.keys(conformance).filter( + (id) => !MARKDOWN_CASES.some((c) => c.id === id), + ); + expect(stale).toEqual([]); + }); + + for (const testCase of MARKDOWN_CASES) { + it(`${testCase.id}: ${testCase.expected}`, () => { + conformance[testCase.id](); + }); + } + + describe('heading styles', () => { + it('renders h4-h6 at body size in bold, not as their own heading sizes', () => { + for (const styles of [fullMarkdownStyles('#000'), compactMarkdownStyles('#000')]) { + for (const level of ['heading4', 'heading5', 'heading6'] as const) { + expect(styles[level].fontSize).toBe(styles.body.fontSize); + expect(styles[level].fontWeight).toBe('700'); + } + } + }); + }); + + describe('allowLinkPress', () => { + it('allows the schemes Jot renders as links', () => { + expect(allowLinkPress('https://example.com')).toBe(true); + expect(allowLinkPress('http://example.com')).toBe(true); + expect(allowLinkPress('mailto:a@b.com')).toBe(true); + }); + + it('blocks app deep links and everything else', () => { + expect(allowLinkPress('tel:+15550100')).toBe(false); + expect(allowLinkPress('sms:+15550100')).toBe(false); + expect(allowLinkPress('jot://notes/1')).toBe(false); + expect(allowLinkPress('javascript:alert(1)')).toBe(false); + expect(allowLinkPress('/dashboard')).toBe(false); + }); + }); +}); diff --git a/mobile/src/screens/NoteEditorScreen.tsx b/mobile/src/screens/NoteEditorScreen.tsx index 2215f9b81..acffcb0d0 100644 --- a/mobile/src/screens/NoteEditorScreen.tsx +++ b/mobile/src/screens/NoteEditorScreen.tsx @@ -64,7 +64,8 @@ import { SafeAreaInsetsContext } from 'react-native-safe-area-context'; import type { RootStackParamList } from '../navigation/RootNavigator'; import { getCompletedSectionDividerColor, isWhiteHexColor } from '../utils/colorContrast'; import { formatEditorStateForShare } from '../utils/noteTextFormatter'; -import { fullMarkdownStyles, preprocessMarkdown } from '../utils/markdownStyles'; +import { fullMarkdownStyles } from '../utils/markdownStyles'; +import { allowLinkPress, markdownParser, markdownRules } from '../utils/markdown'; import { getActiveServer, listServers, type ServerAccountEntry } from '../store/serverAccounts'; import { isServerReachable } from '../api/serverReachability'; import { setPendingShare, usePendingShare } from '../store/shareIntent'; @@ -2725,8 +2726,13 @@ export default function NoteEditorScreen() { style={styles.contentPreview} > {content ? ( - - {preprocessMarkdown(content)} + + {content} ) : ( diff --git a/mobile/src/utils/markdown.tsx b/mobile/src/utils/markdown.tsx new file mode 100644 index 000000000..9df00df07 --- /dev/null +++ b/mobile/src/utils/markdown.tsx @@ -0,0 +1,289 @@ +import React from 'react'; +import { Linking, Text } from 'react-native'; +import { MarkdownIt } from 'react-native-markdown-display'; +import { formatLiteralImage, isAllowedLinkHref } from '@jot/shared'; + +// Jot's Markdown feature set is specified in docs/specs/markdown-rendering.md +// and is shared with the webapp, which reaches it through marked + a DOMPurify +// allowlist. Anything changed here needs a matching change in +// webapp/src/utils/markdown.ts. +// +// react-native-markdown-display ships no types, so the markdown-it shapes we +// touch are declared structurally below. + +interface MarkdownToken { + type: string; + content: string; + markup: string; + map: number[] | null; + children: MarkdownToken[] | null; + attrGet(name: string): string | null; +} + +interface MarkdownCoreState { + src: string; + tokens: MarkdownToken[]; + Token: new (type: string, tag: string, nesting: number) => MarkdownToken; +} + +type CoreRule = (state: MarkdownCoreState) => void; + +interface MarkdownParser { + disable(rules: string | string[]): MarkdownParser; + core: { ruler: { after(afterName: string, ruleName: string, rule: CoreRule): void } }; +} + +function textToken(state: MarkdownCoreState, content: string): MarkdownToken { + const token = new state.Token('text', '', 0); + token.content = content; + return token; +} + +/** A paragraph holding one run of literal, already-parsed text. */ +function literalParagraph(state: MarkdownCoreState, content: string): MarkdownToken[] { + const open = new state.Token('paragraph_open', 'p', 1); + const inline = new state.Token('inline', '', 0); + inline.content = content; + inline.children = [textToken(state, content)]; + const close = new state.Token('paragraph_close', 'p', -1); + return [open, inline, close]; +} + +/** + * Replace image tokens with their literal source. + * + * markdown-it's `.disable('image')` looks like the obvious move and is a trap: + * it produces `!` followed by a *live link* (`[alt](https://x/y.png)` → + * `!alt`), and an invisible clickable link when + * the alt text is empty. Rewriting the token is what actually yields text. + * + * Done at parser level rather than as a render rule because + * react-native-markdown-display marks every image token `block: true`, which + * would break the literal source out of its paragraph onto its own line — + * the webapp keeps it inline. + */ +const literalImages: CoreRule = (state) => { + for (const token of state.tokens) { + if (token.type !== 'inline' || !token.children) continue; + for (let i = 0; i < token.children.length; i++) { + const child = token.children[i]; + if (child.type !== 'image') continue; + // `content` is the raw alt text; src and title are attributes. + token.children[i] = textToken( + state, + formatLiteralImage(child.content, child.attrGet('src') ?? '', child.attrGet('title')), + ); + } + } +}; + +/** + * Replace raw HTML tokens with their literal source. + * + * The parser runs with `html: true` and the tags are neutralised here instead + * of being escaped at parse time. Two reasons, both about matching the webapp: + * markdown-it's linkify skips html tokens, so a URL inside an attribute + * (``) stays inert rather than becoming a live link inside + * text that is supposed to be literal; and an HTML block swallows the markdown + * inside it, so `
      ` / `**bold**` / `
      ` renders literally end to end, + * which is what marked does with the same input. + */ +const literalHtml: CoreRule = (state) => { + for (let i = 0; i < state.tokens.length; i++) { + const token = state.tokens[i]; + + if (token.type === 'html_block') { + state.tokens.splice(i, 1, ...literalParagraph(state, token.content.trim())); + i += 2; + continue; + } + + if (token.type !== 'inline' || !token.children) continue; + for (let j = 0; j < token.children.length; j++) { + const child = token.children[j]; + if (child.type === 'html_inline') { + token.children[j] = textToken(state, child.content); + } + } + } +}; + +/** + * Replace tables with their literal source. + * + * Parsing tables and then collapsing them, rather than `.disable('table')`, + * is what keeps a URL in a cell inert: disabling the rule leaves the pipe rows + * as an ordinary paragraph, which linkify then happily turns into live links + * inside text the webapp shows as plain source. Collapsing discards the parsed + * cells — links and all — and the raw lines take their place. + */ +const literalTables: CoreRule = (state) => { + let lines: string[] | null = null; + for (let i = 0; i < state.tokens.length; i++) { + if (state.tokens[i].type !== 'table_open') continue; + + let end = i + 1; + while (end < state.tokens.length && state.tokens[end].type !== 'table_close') end++; + if (end === state.tokens.length) break; + + // table_open's line map spans the whole table, header row included, so the + // source can be lifted verbatim — the equivalent of marked's `token.raw`. + const map = state.tokens[i].map; + if (!map) continue; + lines = lines ?? state.src.split('\n'); + state.tokens.splice( + i, + end - i + 1, + ...literalParagraph(state, lines.slice(map[0], map[1]).join('\n').trim()), + ); + i += 2; + } +}; + +/** A scheme, or a `www.` prefix — what GFM requires before it autolinks. */ +const GFM_AUTOLINK = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)/; + +/** + * Drop linkify's bare-domain autolinks. + * + * linkify-it is fuzzier than marked's GFM autolinker: it links `example.com`, + * which marked leaves as text (GFM wants a scheme or a `www.` prefix). Turning + * `fuzzyLink` off is not the fix — that would also stop linking + * `www.example.com`, which marked *does* link. So the links are made and the + * over-eager ones are unwrapped here, leaving their text in place. + */ +const gfmAutolinksOnly: CoreRule = (state) => { + for (const token of state.tokens) { + if (token.type !== 'inline' || !token.children) continue; + + const kept: MarkdownToken[] = []; + for (let i = 0; i < token.children.length; i++) { + const child = token.children[i]; + // linkify's own links are always exactly [link_open, text, link_close]. + const label = token.children[i + 1]; + if (child.type === 'link_open' && child.markup === 'linkify' && label) { + if (!GFM_AUTOLINK.test(label.content)) { + kept.push(label); + i += 2; + continue; + } + } + kept.push(child); + } + token.children = kept; + } +}; + +/** + * Render `- [ ]` / `- [x]` list markers as ☐ / ☑. + * + * markdown-it has no task-list support, so the marker survives as literal text + * at the head of the item's first inline token and only has to be swapped out. + * Matching on that position is what keeps checkbox syntax inside a fenced code + * block — or in an ordinary paragraph — untouched, as it is on the webapp. + */ +const unicodeTaskMarkers: CoreRule = (state) => { + for (let i = 2; i < state.tokens.length; i++) { + const token = state.tokens[i]; + if (token.type !== 'inline' || !token.children?.length) continue; + if (state.tokens[i - 1].type !== 'paragraph_open') continue; + if (state.tokens[i - 2].type !== 'list_item_open') continue; + + const first = token.children[0]; + if (first.type !== 'text') continue; + first.content = first.content.replace(/^\[([ xX])\](\s|$)/, (_match, mark: string, tail: string) => + mark === ' ' ? `☐${tail}` : `☑${tail}`, + ); + } +}; + +export function createMarkdownParser(): MarkdownParser { + const md = MarkdownIt({ + // Bare URLs become links, matching marked's gfm autolinking. + linkify: true, + // No smart typography: `--` stays `--` and "quotes" stay straight, because + // marked has no equivalent. This is on by default in + // react-native-markdown-display, so it has to be turned off explicitly. + typographer: false, + // Raw HTML is parsed into html tokens and then neutralised into literal + // text by literalHtml above — see there for why this is not `false`. The + // webapp matches by escaping raw HTML in its renderer; leaving that to the + // DOMPurify allowlist would strip the tag and keep only the words. + html: true, + // Single newlines still break, via react-native-markdown-display's + // softbreak render rule, which emits "\n" regardless of this option. The + // webapp gets there through marked's `breaks: true` — same output, + // different mechanism, so switching this to `true` would look like a + // harmless alignment and silently change nothing until that rule changes. + breaks: false, + }) as unknown as MarkdownParser; + + // Every rule runs after linkify: before it, linkify would turn the URL inside + // a reconstructed `![alt](url)` into a live link. + md.core.ruler.after('linkify', 'jot_literal_images', literalImages); + md.core.ruler.after('jot_literal_images', 'jot_literal_html', literalHtml); + md.core.ruler.after('jot_literal_html', 'jot_literal_tables', literalTables); + md.core.ruler.after('jot_literal_tables', 'jot_gfm_autolinks', gfmAutolinksOnly); + md.core.ruler.after('jot_gfm_autolinks', 'jot_task_markers', unicodeTaskMarkers); + + return md; +} + +/** + * Shared parser instance. markdown-it holds no per-parse state, and + * react-native-markdown-display memoizes on the identity of this prop, so one + * module-level instance avoids rebuilding the parser on every render. + */ +export const markdownParser = createMarkdownParser(); + +interface AstNode { + key: string; + type: string; + content: string; + attributes: Record; +} + +type NodeStyles = Record; + +export const markdownRules = { + /** + * Links outside the allowed schemes render as their label, with no press + * handler — the same thing the webapp does. `onLinkPress` alone would leave a + * link-styled, tappable-looking element that does nothing. + */ + link: ( + node: AstNode, + children: React.ReactNode, + _parent: AstNode[], + styles: NodeStyles, + onLinkPress?: (url: string) => boolean, + ) => { + const href = node.attributes.href ?? ''; + if (!isAllowedLinkHref(href)) { + return {children}; + } + // Same contract as the library's own openUrl helper — an onLinkPress that + // returns false blocks the navigation — but called directly, because the + // untyped export infers a one-argument signature. + const press = () => { + if (onLinkPress && !onLinkPress(href)) return; + // openURL rejects when nothing can handle the URL. The href comes from + // note content, so that is a normal outcome, not a crash — and it must + // not be logged, since note content never goes to the log. + Linking.openURL(href).catch(() => {}); + }; + return ( + + {children} + + ); + }, +}; + +/** + * `onLinkPress` guard for . Belt and braces with the `link` rule + * above: it also covers `blocklink`, which the default rules open with no + * filtering at all, so a collaborator's note could otherwise drive + * `Linking.openURL` into an arbitrary app deep link. + */ +export const allowLinkPress = (url: string): boolean => isAllowedLinkHref(url); diff --git a/mobile/src/utils/markdownStyles.ts b/mobile/src/utils/markdownStyles.ts index 282ce9c87..ca44adfb4 100644 --- a/mobile/src/utils/markdownStyles.ts +++ b/mobile/src/utils/markdownStyles.ts @@ -1,5 +1,10 @@ // Styles for react-native-markdown-display. The library defaults are too large // for mobile note cards (H1=32px, H2=24px), so we override heading sizes. +// +// h4-h6 deliberately get no size of their own: below h3 the steps are +// indistinguishable at note sizes, so they render as bold body text. The webapp +// matches, via .markdown-content in index.css. See +// docs/specs/markdown-rendering.md. export function compactMarkdownStyles(color: string) { return { @@ -7,9 +12,9 @@ export function compactMarkdownStyles(color: string) { heading1: { fontSize: 15, fontWeight: '700' as const, lineHeight: 20 }, heading2: { fontSize: 14, fontWeight: '700' as const, lineHeight: 20 }, heading3: { fontSize: 14, fontWeight: '600' as const, lineHeight: 20 }, - heading4: { fontSize: 13, fontWeight: '600' as const, lineHeight: 20 }, - heading5: { fontSize: 13, fontWeight: '500' as const, lineHeight: 20 }, - heading6: { fontSize: 13, fontWeight: '500' as const, lineHeight: 20 }, + heading4: { fontSize: 14, fontWeight: '700' as const, lineHeight: 20 }, + heading5: { fontSize: 14, fontWeight: '700' as const, lineHeight: 20 }, + heading6: { fontSize: 14, fontWeight: '700' as const, lineHeight: 20 }, }; } @@ -19,21 +24,14 @@ export function fullMarkdownStyles(color: string) { heading1: { fontSize: 22, fontWeight: '700' as const, lineHeight: 30 }, heading2: { fontSize: 18, fontWeight: '600' as const, lineHeight: 26 }, heading3: { fontSize: 16, fontWeight: '600' as const, lineHeight: 24 }, - heading4: { fontSize: 15, fontWeight: '600' as const, lineHeight: 22 }, - heading5: { fontSize: 14, fontWeight: '600' as const, lineHeight: 22 }, - heading6: { fontSize: 14, fontWeight: '500' as const, lineHeight: 22 }, + heading4: { fontSize: 14, fontWeight: '700' as const, lineHeight: 22 }, + heading5: { fontSize: 14, fontWeight: '700' as const, lineHeight: 22 }, + heading6: { fontSize: 14, fontWeight: '700' as const, lineHeight: 22 }, + // The library default is a solid black bar, invisible on a dark background. + hr: { backgroundColor: color, opacity: 0.3, height: 1 }, }; } -// react-native-markdown-display doesn't bundle a markdown-it task-list plugin, -// so "- [x] text" renders as "• [x] text". Replace the markers with unicode -// checkbox characters before passing content to . -export function preprocessMarkdown(content: string): string { - return content - .replace(/^(\s*[-*+]\s+)\[x\]\s*/gim, '$1☑ ') - .replace(/^(\s*[-*+]\s+)\[ \]\s*/gim, '$1☐ '); -} - export function stripMarkdownForPreview(content: string): string { return content .replace(/```[\s\S]*?```/g, '') // fenced code blocks diff --git a/shared/src/__tests__/markdown.test.ts b/shared/src/__tests__/markdown.test.ts new file mode 100644 index 000000000..0084bd749 --- /dev/null +++ b/shared/src/__tests__/markdown.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { formatLiteralImage, isAllowedLinkHref } from '../markdown'; + +describe('isAllowedLinkHref', () => { + it('allows the web and mail schemes', () => { + expect(isAllowedLinkHref('https://example.com')).toBe(true); + expect(isAllowedLinkHref('http://example.com')).toBe(true); + expect(isAllowedLinkHref('mailto:a@b.com')).toBe(true); + }); + + it('ignores scheme casing', () => { + expect(isAllowedLinkHref('HTTPS://example.com')).toBe(true); + expect(isAllowedLinkHref('MailTo:a@b.com')).toBe(true); + }); + + it('rejects app deep links and script schemes', () => { + expect(isAllowedLinkHref('tel:+15550100')).toBe(false); + expect(isAllowedLinkHref('sms:+15550100')).toBe(false); + expect(isAllowedLinkHref('jot://notes/abc')).toBe(false); + expect(isAllowedLinkHref('javascript:alert(1)')).toBe(false); + expect(isAllowedLinkHref('data:text/html,')).toBe(false); + }); + + it('rejects leading whitespace and control characters used to hide a scheme', () => { + expect(isAllowedLinkHref(' javascript:alert(1)')).toBe(false); + expect(isAllowedLinkHref('\njavascript:alert(1)')).toBe(false); + }); + + it('rejects targets with no scheme at all', () => { + expect(isAllowedLinkHref('/dashboard')).toBe(false); + expect(isAllowedLinkHref('example.com')).toBe(false); + expect(isAllowedLinkHref('//example.com')).toBe(false); + expect(isAllowedLinkHref('#anchor')).toBe(false); + expect(isAllowedLinkHref('')).toBe(false); + }); +}); + +describe('formatLiteralImage', () => { + it('reconstructs the source both clients show in place of an image', () => { + expect(formatLiteralImage('alt', 'https://x/y.png')).toBe('![alt](https://x/y.png)'); + }); + + it('includes the title when there is one', () => { + expect(formatLiteralImage('alt', 'https://x/y.png', 'the title')).toBe( + '![alt](https://x/y.png "the title")', + ); + }); + + it('keeps the brackets when the alt text is empty', () => { + expect(formatLiteralImage('', 'https://x/y.png')).toBe('![](https://x/y.png)'); + }); + + it('treats a missing and an empty title the same', () => { + expect(formatLiteralImage('a', 'b', null)).toBe('![a](b)'); + expect(formatLiteralImage('a', 'b', '')).toBe('![a](b)'); + }); +}); diff --git a/shared/src/index.ts b/shared/src/index.ts index 7e22e78fc..a90f60f65 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -5,6 +5,8 @@ export * from './collaborators'; export * from './colors'; export * from './serverUrl'; export * from './text'; +export * from './markdown'; +export * from './markdownCases'; export * from './noteConversion'; export * from './noteSort'; export * from './usernameValidation'; diff --git a/shared/src/markdown.ts b/shared/src/markdown.ts new file mode 100644 index 000000000..6c8e85359 --- /dev/null +++ b/shared/src/markdown.ts @@ -0,0 +1,46 @@ +// Cross-client Markdown helpers. +// +// The webapp renders Markdown with marked + a DOMPurify allowlist and mobile +// renders it with markdown-it + react-native-markdown-display, so the two reach +// the same feature set by completely different routes. The pieces that have to +// agree *exactly* — and would otherwise drift apart unnoticed — live here. +// +// The feature set itself is specified in docs/specs/markdown-rendering.md. + +/** + * URL schemes Jot turns into links. A link with any other scheme, or with no + * scheme at all, renders as plain text on both clients. + */ +export const ALLOWED_LINK_SCHEMES = ['http', 'https', 'mailto'] as const; + +/** + * Whether a link target may be rendered as a link. + * + * Requires an explicit allowed scheme: `tel:`, `sms:` and app deep links are + * rejected, and so are scheme-less targets (`/foo`, `example.com`, + * `//example.com`) — notes are shareable, so a collaborator's note must not be + * able to drive navigation anywhere but the web and mail. + * + * This runs on the target the *parser* produced, which is not always what the + * author typed: both clients autolink `www.example.com` and hand this an + * already-normalized `http://www.example.com`, so that is accepted by design. + * What each parser is willing to autolink in the first place is settled + * upstream of here — see `gfmAutolinksOnly` in mobile/src/utils/markdown.tsx. + */ +export function isAllowedLinkHref(href: string): boolean { + const scheme = /^\s*([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(href); + if (!scheme) return false; + return (ALLOWED_LINK_SCHEMES as readonly string[]).includes(scheme[1].toLowerCase()); +} + +/** + * The literal source shown in place of an image, which Jot does not render + * (images are a separate gallery feature — docs/specs/file-attachments.md). + * + * Both clients reconstruct this from parsed tokens rather than echoing the + * original source, so the format is pinned here: if one side dropped the title + * or the leading `!`, `![a](b "t")` would quietly diverge again. + */ +export function formatLiteralImage(alt: string, src: string, title?: string | null): string { + return `![${alt}](${src}${title ? ` "${title}"` : ''})`; +} diff --git a/shared/src/markdownCases.ts b/shared/src/markdownCases.ts new file mode 100644 index 000000000..e46f8100e --- /dev/null +++ b/shared/src/markdownCases.ts @@ -0,0 +1,123 @@ +// The conformance corpus for Jot's Markdown support. +// +// This is the single list of inputs both clients' renderer tests run against — +// webapp/src/utils/__tests__/markdown.test.ts and +// mobile/__tests__/markdown.test.ts. Each side keeps its own expectations +// (marked emits HTML, markdown-it feeds a React Native AST) but both assert one +// expectation per id and fail if an id here has none, so a case can never be +// covered on one client and forgotten on the other. +// +// Adding a case here deliberately breaks both suites until both are updated. +// The behaviour each case pins is specified in docs/specs/markdown-rendering.md. + +export interface MarkdownCase { + /** Stable identifier; both clients key their expectations off it. */ + id: string; + /** Markdown source handed to the renderer. */ + markdown: string; + /** What the reader ends up seeing, in prose. */ + expected: string; +} + +export const MARKDOWN_CASES: MarkdownCase[] = [ + // Inline emphasis + { id: 'bold', markdown: '**hello**', expected: 'bold text' }, + { id: 'italic', markdown: '*hello*', expected: 'italic text' }, + { id: 'strikethrough', markdown: '~~hello~~', expected: 'struck-through text' }, + + // Headings: h1-h3 get their own sizes, h4-h6 render as bold body text + { id: 'heading-1', markdown: '# Top heading', expected: 'a level 1 heading' }, + { id: 'heading-3', markdown: '### Third heading', expected: 'a level 3 heading' }, + { id: 'heading-4-bold', markdown: '#### Fourth heading', expected: 'bold text at body size' }, + { id: 'heading-6-bold', markdown: '###### Sixth heading', expected: 'bold text at body size' }, + + // Code + { id: 'inline-code', markdown: '`code`', expected: 'inline code' }, + { id: 'fenced-code', markdown: '```js\nconst a = 1;\n```', expected: 'a code block with block layout' }, + { id: 'indented-code', markdown: ' indented code', expected: 'a code block with block layout' }, + { + id: 'task-marker-in-code', + markdown: '```\n- [x] not a checkbox\n```', + expected: 'a code block with the checkbox syntax left alone', + }, + + // Lists + { id: 'bullet-list', markdown: '- item', expected: 'a bullet list' }, + { id: 'ordered-list', markdown: '1. item', expected: 'an ordered list' }, + { id: 'task-unchecked', markdown: '- [ ] todo', expected: 'a list item reading "☐ todo"' }, + { id: 'task-checked', markdown: '- [x] done', expected: 'a list item reading "☑ done"' }, + { id: 'task-checked-uppercase', markdown: '- [X] done', expected: 'a list item reading "☑ done"' }, + { id: 'task-marker-outside-list', markdown: '[x] not a task', expected: 'literal text, no checkbox' }, + + // Blocks + { id: 'blockquote', markdown: '> quote', expected: 'a blockquote' }, + { id: 'hr-dashes', markdown: 'above\n\n---\n\nbelow', expected: 'a horizontal rule' }, + { id: 'hr-stars', markdown: 'above\n\n***\n\nbelow', expected: 'a horizontal rule' }, + + // Links + { id: 'inline-link', markdown: '[text](https://example.com)', expected: 'a link labelled "text"' }, + { id: 'bare-url', markdown: 'visit https://example.com now', expected: 'an autolinked URL' }, + { id: 'bare-url-www', markdown: 'visit www.example.com now', expected: 'an autolinked URL' }, + { + id: 'bare-domain', + markdown: 'visit example.com now', + expected: 'plain text — GFM needs a scheme or www. to autolink', + }, + { id: 'mailto-link', markdown: '[mail](mailto:a@b.com)', expected: 'a link labelled "mail"' }, + { id: 'tel-link', markdown: '[call](tel:+15550100)', expected: 'plain text "call", not a link' }, + { id: 'javascript-link', markdown: '[click](javascript:alert(1))', expected: 'plain text "click", not a link' }, + { id: 'relative-link', markdown: '[rel](/dashboard)', expected: 'plain text "rel", not a link' }, + + // Images: never rendered, shown as literal source + { + id: 'image', + markdown: '![alt text](https://example.com/y.png)', + expected: 'literal source: ![alt text](https://example.com/y.png)', + }, + { + id: 'image-with-title', + markdown: '![alt](https://example.com/y.png "the title")', + expected: 'literal source including the title', + }, + { + id: 'image-empty-alt', + markdown: '![](https://example.com/y.png)', + expected: 'literal source with empty brackets, nothing clickable', + }, + { + id: 'image-inline-in-paragraph', + markdown: 'see ![a](https://example.com/y.png) here', + expected: 'literal source inline in the sentence', + }, + + // Tables: never rendered, shown as literal source + { id: 'table', markdown: 'a | b\n--- | ---\n1 | 2', expected: 'literal source, header row included' }, + { + id: 'table-cell-url', + markdown: 'a | b\n--- | ---\nhttps://example.com | 2', + expected: 'literal source — a URL in a cell is text, not a link', + }, + + // No smart typography + { id: 'typography-dashes', markdown: 'a -- b', expected: 'literal "--", not an en dash' }, + { id: 'typography-quotes', markdown: 'say "hi"', expected: 'literal straight quotes' }, + + // Whitespace and raw HTML + { id: 'soft-break', markdown: 'first\nsecond', expected: 'a line break between the two words' }, + { id: 'raw-html', markdown: 'bold text', expected: 'literal source: bold text' }, + { + id: 'raw-html-attribute-url', + markdown: '
      x', + expected: 'literal source — the URL in the attribute is not a link', + }, + { + id: 'raw-html-block-swallows-markdown', + markdown: '
      \n**bold**\n
      ', + expected: 'literal source end to end, the ** included', + }, + { + id: 'raw-html-script', + markdown: '', + expected: 'literal source, inert — nothing executed', + }, +]; diff --git a/webapp/e2e/tests/markdown.spec.ts b/webapp/e2e/tests/markdown.spec.ts index 9eb37b6ba..28c1c4c17 100644 --- a/webapp/e2e/tests/markdown.spec.ts +++ b/webapp/e2e/tests/markdown.spec.ts @@ -42,6 +42,80 @@ test.describe('Markdown note editing', () => { await dialog.getByRole('button', { name: 'Close' }).click(); }); + // The full feature set is specified in docs/specs/markdown-rendering.md and + // pinned per-construct by the unit tests on both clients; these two cover it + // end to end, on real note content, in the browser. + test('renders the supported syntax in the preview', async ({ page, dashboardPage }) => { + await dashboardPage.goto(); + await dashboardPage.clickNewNote(); + await page.fill( + 'textarea[placeholder="Take a note..."]', + [ + '### Third heading', + '', + '#### Fourth heading', + '', + '**bold** and ~~struck~~', + '', + '- [x] done', + '- [ ] todo', + '', + '```', + 'const a = 1;', + '```', + '', + '---', + '', + 'visit https://example.com now', + ].join('\n'), + ); + await page.keyboard.press('Escape'); + + const preview = page.getByRole('dialog').getByTestId('note-content-preview'); + await expect(preview.locator('h3')).toHaveText('Third heading'); + // h4 and below are headings, but sized as bold body text. + await expect(preview.locator('h4')).toHaveText('Fourth heading'); + const bodySize = await preview.locator('p').first().evaluate((el) => getComputedStyle(el).fontSize); + await expect(preview.locator('h4')).toHaveCSS('font-size', bodySize); + await expect(preview.locator('h4')).toHaveCSS('font-weight', '700'); + await expect(preview.locator('strong')).toHaveText('bold'); + await expect(preview.locator('del')).toHaveText('struck'); + await expect(preview.locator('li').nth(0)).toHaveText('☑ done'); + await expect(preview.locator('li').nth(1)).toHaveText('☐ todo'); + // Checkboxes are glyphs, not inputs — nothing to toggle. + await expect(preview.locator('input')).toHaveCount(0); + await expect(preview.locator('pre')).toContainText('const a = 1;'); + await expect(preview.locator('hr')).toHaveCount(1); + await expect(preview.locator('a[href="https://example.com"]')).toBeVisible(); + }); + + test('shows unsupported syntax as literal source and refuses unsupported link schemes', async ({ page, dashboardPage }) => { + await dashboardPage.goto(); + await dashboardPage.clickNewNote(); + await page.fill( + 'textarea[placeholder="Take a note..."]', + [ + '![alt](https://example.com/y.png)', + '', + 'a | b', + '--- | ---', + '1 | 2', + '', + '[call](tel:+15550100)', + ].join('\n'), + ); + await page.keyboard.press('Escape'); + + const preview = page.getByRole('dialog').getByTestId('note-content-preview'); + await expect(preview).toContainText('![alt](https://example.com/y.png)'); + await expect(preview.locator('img')).toHaveCount(0); + await expect(preview).toContainText('a | b'); + await expect(preview.locator('table')).toHaveCount(0); + // tel: renders as its label, with nothing to follow. + await expect(preview).toContainText('call'); + await expect(preview.locator('a')).toHaveCount(0); + }); + test('two-step Escape dismiss: first Escape collapses to preview, second Escape closes modal', async ({ page, dashboardPage }) => { await dashboardPage.goto(); // Intentionally keep the modal open — see comment in previous test. diff --git a/webapp/src/index.css b/webapp/src/index.css index 792a6ead3..06afa3318 100644 --- a/webapp/src/index.css +++ b/webapp/src/index.css @@ -141,6 +141,11 @@ body { .markdown-content h3 { @apply font-semibold text-[1.1em] mb-1 mt-2 first:mt-0; } + /* h4-h6 get no size of their own — below h3 the steps are indistinguishable + at note sizes, so they render as bold body text. Mobile matches. */ + .markdown-content :is(h4, h5, h6) { + @apply font-bold text-[1em] mb-1 mt-2 first:mt-0; + } .markdown-content p { @apply mb-2 last:mb-0; } @@ -159,6 +164,17 @@ body { .markdown-content code { @apply font-mono bg-black/5 dark:bg-white/10 rounded px-1 text-[0.9em]; } + .markdown-content pre { + @apply bg-black/5 dark:bg-white/10 rounded p-2 mb-2 last:mb-0 overflow-x-auto; + } + /* The block already carries the tint and padding; the inner would + otherwise stack a second background on top of it. */ + .markdown-content pre code { + @apply bg-transparent rounded-none p-0; + } + .markdown-content hr { + @apply border-t border-gray-300 dark:border-gray-600 my-3; + } .markdown-content a { @apply text-blue-500 dark:text-blue-400 underline; } diff --git a/webapp/src/utils/__tests__/markdown.test.ts b/webapp/src/utils/__tests__/markdown.test.ts index 3472b60c7..2318e1665 100644 --- a/webapp/src/utils/__tests__/markdown.test.ts +++ b/webapp/src/utils/__tests__/markdown.test.ts @@ -1,50 +1,176 @@ import { describe, it, expect } from 'vitest'; +import { MARKDOWN_CASES } from '@jot/shared'; import { renderMarkdown } from '../markdown'; -describe('renderMarkdown', () => { - it('renders bold', () => { - expect(renderMarkdown('**hello**')).toContain('hello'); - }); +function render(id: string): string { + const testCase = MARKDOWN_CASES.find((c) => c.id === id); + if (!testCase) throw new Error(`unknown markdown case: ${id}`); + return renderMarkdown(testCase.markdown); +} - it('renders italic', () => { - expect(renderMarkdown('*hello*')).toContain('hello'); - }); +// One assertion per case in the shared conformance corpus (shared/src/ +// markdownCases.ts). The mobile suite runs the same corpus through markdown-it; +// the coverage test below is what keeps the two from drifting apart. +const conformance: Record void> = { + bold: () => expect(render('bold')).toContain('hello'), + italic: () => expect(render('italic')).toContain('hello'), + strikethrough: () => expect(render('strikethrough')).toContain('hello'), - it('renders h2 heading', () => { - expect(renderMarkdown('## Title')).toContain('

      '); - expect(renderMarkdown('## Title')).toContain('Title'); - }); + 'heading-1': () => expect(render('heading-1')).toContain('

      Top heading

      '), + 'heading-3': () => expect(render('heading-3')).toContain('

      Third heading

      '), + // Rendered as real heading elements; index.css styles h4-h6 as bold body text. + 'heading-4-bold': () => expect(render('heading-4-bold')).toContain('

      Fourth heading

      '), + 'heading-6-bold': () => expect(render('heading-6-bold')).toContain('
      Sixth heading
      '), - it('renders unordered list', () => { - expect(renderMarkdown('- item')).toContain('
    9. '); - expect(renderMarkdown('- item')).toContain('item'); - }); + 'inline-code': () => expect(render('inline-code')).toContain('code'), + 'fenced-code': () => { + const html = render('fenced-code'); + expect(html).toContain('
      ');
      +    expect(html).toContain('const a = 1;');
      +  },
      +  'indented-code': () => {
      +    const html = render('indented-code');
      +    expect(html).toContain('
      ');
      +    expect(html).toContain('indented code');
      +  },
      +  'task-marker-in-code': () => {
      +    const html = render('task-marker-in-code');
      +    expect(html).toContain('
      ');
      +    expect(html).toContain('- [x] not a checkbox');
      +    expect(html).not.toContain('☑');
      +  },
       
      -  it('renders blockquote', () => {
      -    expect(renderMarkdown('> quote')).toContain('
      '); - }); + 'bullet-list': () => expect(render('bullet-list')).toContain('
    10. item
    11. '), + 'ordered-list': () => { + const html = render('ordered-list'); + expect(html).toContain('
        '); + expect(html).toContain('
      1. item
      2. '); + }, + 'task-unchecked': () => expect(render('task-unchecked')).toContain('
      3. ☐ todo
      4. '), + 'task-checked': () => expect(render('task-checked')).toContain('
      5. ☑ done
      6. '), + 'task-checked-uppercase': () => + expect(render('task-checked-uppercase')).toContain('
      7. ☑ done
      8. '), + 'task-marker-outside-list': () => { + const html = render('task-marker-outside-list'); + expect(html).toContain('[x] not a task'); + expect(html).not.toContain('☑'); + }, - it('renders inline code', () => { - expect(renderMarkdown('`code`')).toContain('code'); - }); + blockquote: () => expect(render('blockquote')).toContain('
        '), + 'hr-dashes': () => expect(render('hr-dashes')).toContain('
        '), + 'hr-stars': () => expect(render('hr-stars')).toContain('
        '), - it('renders link with safe attributes', () => { - const result = renderMarkdown('[text](https://example.com)'); - expect(result).toContain(' { + const html = render('inline-link'); + expect(html).toContain('href="https://example.com"'); + expect(html).toContain('rel="noopener noreferrer"'); + }, + 'bare-url': () => expect(render('bare-url')).toContain('href="https://example.com"'), + 'bare-url-www': () => expect(render('bare-url-www')).toContain('href="http://www.example.com"'), + 'bare-domain': () => { + const html = render('bare-domain'); + expect(html).toContain('visit example.com now'); + expect(html).not.toContain(' expect(render('mailto-link')).toContain('href="mailto:a@b.com"'), + 'tel-link': () => { + const html = render('tel-link'); + expect(html).toContain('call'); + expect(html).not.toContain(' { + const html = render('javascript-link'); + expect(html).toContain('click'); + expect(html).not.toContain(' { + const html = render('relative-link'); + expect(html).toContain('rel'); + expect(html).not.toContain(' { - const result = renderMarkdown(''); - expect(result).not.toContain('