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('- item
');
+ },
+ 'task-unchecked': () => expect(html('task-unchecked')).toContain('- ☐ todo
'),
+ 'task-checked': () => expect(html('task-checked')).toContain('- ☑ done
'),
+ 'task-checked-uppercase': () =>
+ expect(html('task-checked-uppercase')).toContain('- ☑ done
'),
+ '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('');
+ expect(tappableText(tree)).toEqual([]);
+ },
+ 'image-with-title': () =>
+ expect(visibleText(renderCase('image-with-title'))).toContain(
+ '',
+ ),
+ 'image-empty-alt': () => {
+ const tree = renderCase('image-empty-alt');
+ expect(visibleText(tree)).toContain('');
+ // .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  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 `` 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('');
+ });
+
+ it('includes the title when there is one', () => {
+ expect(formatLiteralImage('alt', 'https://x/y.png', 'the title')).toBe(
+ '',
+ );
+ });
+
+ it('keeps the brackets when the alt text is empty', () => {
+ expect(formatLiteralImage('', 'https://x/y.png')).toBe('');
+ });
+
+ it('treats a missing and an empty title the same', () => {
+ expect(formatLiteralImage('a', 'b', null)).toBe('');
+ expect(formatLiteralImage('a', 'b', '')).toBe('');
+ });
+});
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 `!`, `` would quietly diverge again.
+ */
+export function formatLiteralImage(alt: string, src: string, title?: string | null): string {
+ return ``;
+}
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: '',
+ expected: 'literal source: ',
+ },
+ {
+ id: 'image-with-title',
+ markdown: '',
+ expected: 'literal source including the title',
+ },
+ {
+ id: 'image-empty-alt',
+ markdown: '',
+ expected: 'literal source with empty brackets, nothing clickable',
+ },
+ {
+ id: 'image-inline-in-paragraph',
+ markdown: 'see  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..."]',
+ [
+ '',
+ '',
+ '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('');
+ 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('
- ');
- 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('- item
'),
+ 'ordered-list': () => {
+ const html = render('ordered-list');
+ expect(html).toContain('');
+ expect(html).toContain('- item
');
+ },
+ 'task-unchecked': () => expect(render('task-unchecked')).toContain('- ☐ todo
'),
+ 'task-checked': () => expect(render('task-checked')).toContain('- ☑ done
'),
+ 'task-checked-uppercase': () =>
+ expect(render('task-checked-uppercase')).toContain('- ☑ done
'),
+ '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('