Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@storybook/react-vite": "^10.6.0",
"@tailwindcss/vite": "^4.3.3",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/mdast": "^4.0.4",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@typescript-eslint/eslint-plugin": "^5.62.0",
Expand All @@ -49,6 +50,9 @@
"eslint": "^8.57.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-storybook": "^10.6.0",
"mdast-util-from-markdown": "^2.1.0",
"micromark-util-types": "^2.0.3",
"remark-parse": "^11.0.0",
"storybook": "^10.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
Expand All @@ -60,9 +64,15 @@
"@pbnotes/pages": "*",
"electron-squirrel-startup": "^1.0.1",
"katex": "^0.16.47",
"mdast-util-gfm-strikethrough": "^2.0.1",
"micromark-extension-gfm-strikethrough": "^2.1.0",
"micromark-util-chunked": "^2.0.1",
"micromark-util-classify-character": "^2.0.1",
"micromark-util-resolve-all": "^2.0.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1"
"rehype-katex": "^7.0.1",
"unified": "^11.0.5"
}
}
6 changes: 5 additions & 1 deletion app/src/renderer/project/Block/Block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ import Markdown from 'react-markdown';
import type { Components } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import { IMAGE_SCHEME } from '../../../shared/pages';
import { remarkPlugins } from '@pbnotes/pages';
import { remarkPlugins as reading } from '@pbnotes/pages';
import { remarkHighlight, remarkStrikethrough } from './marks/marks';
import { bracketEdit } from './brackets/brackets';
import type { Edit } from './brackets/brackets';
import { completeLink, linkQuery } from './linkQuery/linkQuery';
import type { LinkQuery } from './linkQuery/linkQuery';

/** Read the way `@pbnotes/pages` reads a block, plus marks that only draw. */
const remarkPlugins = [...reading, remarkStrikethrough, remarkHighlight];

/**
* KaTeX throws on malformed TeX by default, which would take the whole page
* down over a half-typed formula. Bad math renders as flagged source instead;
Expand Down
55 changes: 55 additions & 0 deletions app/src/renderer/project/Block/marks/marks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/** Run with: npm test */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import { remarkPlugins } from '@pbnotes/pages';
import { remarkHighlight, remarkStrikethrough } from './marks.ts';

type Node = {
type: string;
value?: string;
children?: Node[];
data?: { hProperties?: { href?: string } };
};

const md = unified()
.use(remarkParse)
.use([...remarkPlugins, remarkStrikethrough, remarkHighlight]);
const parse = (source: string) => md.runSync(md.parse(source)) as Node;

/** The text a node reads as, formatting dropped. */
const read = (node: Node): string =>
node.value ?? (node.children ?? []).map(read).join('');

/** Every node of a type, in document order. */
const all = (node: Node, type: string): Node[] => [
...(node.type === type ? [node] : []),
...(node.children ?? []).flatMap((child) => all(child, type)),
];

const pages = (node: Node) =>
all(node, 'wikilink').map((link) => link.data?.hProperties?.href?.slice(1));

/** A link struck through is still a link; tildes in code, math or alone stay
what was typed. */
test('~~strikethrough~~ wraps its words, links and all', () => {
const tree = parse('~~done [[Mira]]~~ `~~a~~` $~~b~~$ ~c~');
assert.deepEqual(all(tree, 'delete').map(read), ['done [[Mira]]']);
assert.deepEqual(pages(tree), ['Mira']);
assert.match(read(tree), /~~a~~.*~~b~~.*~c~/);
});

/** Both spellings highlight, links inside still count; code, math, single
markers, spaced markers and mismatched pairs stay what was typed. */
test('==highlight== and ^^highlight^^ wrap their words, links and all', () => {
const tree = parse(
'==done [[Mira]]== ^^#Ada^^ `==a==` $==b==$ a = b 2^3 x == y == z ==c^^ ===d===',
);
assert.deepEqual(all(tree, 'highlight').map(read), ['done [[Mira]]', '#Ada']);
assert.deepEqual(pages(tree), ['Mira', 'Ada']);
assert.match(
read(tree),
/==a==.*==b==.*a = b 2\^3 x == y == z ==c\^\^ ===d===/,
);
});
193 changes: 193 additions & 0 deletions app/src/renderer/project/Block/marks/marks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* Marks drawn over a block's words: `~~struck~~`, `==highlighted==` and
* `^^highlighted^^`. Rendering only — nothing in `@pbnotes/pages` reads
* them, so they ride alongside its plugins here instead of inside them.
*/
import type { RootContent } from 'mdast';
import type { Processor } from 'unified';
import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough';
import { gfmStrikethroughFromMarkdown } from 'mdast-util-gfm-strikethrough';
import type { Extension as FromMarkdownExtension } from 'mdast-util-from-markdown';
import type {
Code,
Construct,
Event,
Resolver,
State,
Token,
Tokenizer,
} from 'micromark-util-types';
import { splice } from 'micromark-util-chunked';
import { classifyCharacter } from 'micromark-util-classify-character';
import { resolveAll } from 'micromark-util-resolve-all';

/**
* `~~foo~~`, and only that piece of GFM: `remark-gfm` would also bring
* tables, task lists, footnotes and bare-URL autolinks, each of which changes
* how existing notes look. A single `~` stays text, so `~5 min` is safe.
* Rendering needs nothing more — the `delete` node already becomes `<del>`.
*/
export function remarkStrikethrough(this: Processor) {
const data = this.data();
(data.micromarkExtensions ??= []).push(
gfmStrikethrough({ singleTilde: false }),
);
(data.fromMarkdownExtensions ??= []).push(gfmStrikethroughFromMarkdown());
}

declare module 'micromark-util-types' {
interface TokenTypeMap {
highlight: 'highlight';
highlightSequence: 'highlightSequence';
highlightSequenceTemporary: 'highlightSequenceTemporary';
highlightText: 'highlightText';
}
}

const EQUALS = 61;
const CARET = 94;

/**
* `==foo==`, Obsidian's highlight, and `^^foo^^`, Roam's: one node, drawn as
* `<mark>`. The tokenizer is GFM strikethrough's with the tilde swapped out —
* the same flanking rules, so `a == b` and `x ^^ y` stay text, and the same
* doubled marker, so `a = b` and `2^3` do too. What it adds is that a closer
* has to be the marker its opener was: `==a^^` highlights nothing.
*
* ponytail: a bare URL right before the closer, `==see https://a.com==`,
* takes the `==` into the URL and nothing is highlighted — the autolink
* extension's list of trailing punctuation to drop is fixed and has `~` but
* not `=` or `^`. A space before the closer does not help either. Fork that
* tokenizer's trail check if anyone highlights links.
*/
export function remarkHighlight(this: Processor) {
const tokenizer: Construct = {
name: 'highlight',
tokenize: tokenizeHighlight,
resolveAll: resolveAllHighlight,
};
const data = this.data();
(data.micromarkExtensions ??= []).push({
text: { [EQUALS]: tokenizer, [CARET]: tokenizer },
insideSpan: { null: [tokenizer] },
attentionMarkers: { null: [EQUALS, CARET] },
});
(data.fromMarkdownExtensions ??= []).push({
canContainEols: ['highlight'],
enter: {
highlight(token) {
const node = {
type: 'highlight',
children: [] as RootContent[],
data: { hName: 'mark' },
};
this.enter(node as never, token);
},
},
exit: {
highlight(token) {
this.exit(token);
},
},
} satisfies FromMarkdownExtension);
}

/** Pair each closer with the nearest opener spelled the same way. */
const resolveAllHighlight: Resolver = (events, context) => {
for (let index = 0; index < events.length; index++) {
const close = events[index][1];
if (
events[index][0] !== 'enter' ||
close.type !== 'highlightSequenceTemporary' ||
!close._close
) {
continue;
}
let open = index;
while (open--) {
const opener = events[open][1];
if (
events[open][0] === 'exit' &&
opener.type === 'highlightSequenceTemporary' &&
opener._open &&
context.sliceSerialize(opener) === context.sliceSerialize(close)
) {
opener.type = 'highlightSequence';
close.type = 'highlightSequence';
const highlight: Token = {
type: 'highlight',
start: { ...opener.start },
end: { ...close.end },
};
const text: Token = {
type: 'highlightText',
start: { ...opener.end },
end: { ...close.start },
};
const next: Event[] = [
['enter', highlight, context],
['enter', opener, context],
['exit', opener, context],
['enter', text, context],
];
const insideSpan = context.parser.constructs.insideSpan.null;
if (insideSpan) {
splice(
next,
next.length,
0,
resolveAll(insideSpan, events.slice(open + 1, index), context),
);
}
splice(next, next.length, 0, [
['exit', text, context],
['enter', close, context],
['exit', close, context],
['exit', highlight, context],
]);
splice(events, open - 1, index - open + 3, next);
index = open + next.length - 2;
break;
}
}
}
for (const [, token] of events) {
if (token.type === 'highlightSequenceTemporary') token.type = 'data';
}
return events;
};

/** Exactly two of one marker; whether they open, close or both is down to
what is either side of them, as with `~~`. */
const tokenizeHighlight: Tokenizer = function (effects, ok, nok) {
const previous = this.previous;
const events = this.events;
let marker: Code = null;
let size = 0;
const more: State = (code) => {
if (code === marker) {
if (size > 1) return nok(code);
effects.consume(code);
size++;
return more;
}
if (size < 2) return nok(code);
const token = effects.exit('highlightSequenceTemporary');
const before = classifyCharacter(previous);
const after = classifyCharacter(code);
token._open = !after || (after === 2 && Boolean(before));
token._close = !before || (before === 2 && Boolean(after));
return ok(code);
};
return (code) => {
marker = code;
if (
previous === marker &&
events[events.length - 1][1].type !== 'characterEscape'
) {
return nok(code);
}
effects.enter('highlightSequenceTemporary');
return more(code);
};
};
33 changes: 18 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading