Skip to content

Commit

Permalink
Merge pull request #13 from bsoule/support-for-ids-in-md
Browse files Browse the repository at this point in the history
Fix IDs not being applied to headings with inline styling
  • Loading branch information
lcflight authored Oct 24, 2024
2 parents 6c63621 + e195e6e commit 4ddeffa
Show file tree
Hide file tree
Showing 16 changed files with 320 additions and 168 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
},
"dependencies": {
"cheerio": "1.0.0-rc.12",
"marked": "^12.0.2",
"marked": "^14.1.3",
"memize": "^2.1.0",
"sanitize-html": "^2.13.0",
"smartypants": "^0.2.2"
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

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

17 changes: 0 additions & 17 deletions src/lib/applyIdsToElements.spec.ts

This file was deleted.

31 changes: 0 additions & 31 deletions src/lib/applyIdsToElements.ts

This file was deleted.

63 changes: 63 additions & 0 deletions src/lib/marked/extentions/ids.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { marked } from "marked";
import ids from "./ids.js";

marked.use(ids);

describe("marked ids extension", () => {
it("works", () => {
const r = marked.parse("{#foo}bar");
expect(r).toContain('<p id="foo">bar</p>');
});

it("works in combination with italics", () => {
const r = marked.parse("(words *foo*) {#bar}");
expect(r).toContain('<p id="bar">(words <em>foo</em>)</p>');
});

it("leaves comments intact", () => {
const r = marked.parse("<!-- {#foo} -->\nbar");

expect(r).toContain("<!-- {#foo} -->");
});

it("leaves multiline comments intact", () => {
const r = marked.parse("<!-- {#foo}\nbar -->\nbaz");

expect(r).toContain("<!-- {#foo}\nbar -->");
});

it("does not aggressively apply ids to parents", () => {
const r = marked.parse("> {#foo}bar");

expect(r).toBe('<blockquote>\n<p id="foo">bar</p>\n</blockquote>\n');
});

it("does not decode entities", () => {
const r = marked.parse(
"Anyway, in terms of signals that we&#8217;re alive, we have"
);

expect(r).toContain(
"<p>Anyway, in terms of signals that we&#8217;re alive, we have</p>"
);
});

it("does not concat words surrounding the id", () => {
const r = marked.parse("foo {#bar} baz");

expect(r).toContain('<p id="bar">foo baz</p>');
});

it("handles code blocks", () => {
const r = marked.parse("```\n{#foo}\nbar\n```");

expect(r).toContain('<pre id="foo">');
});

it("handles headings", () => {
const r = marked.parse("# heading {#foo}");

expect(r).toContain('<h1 id="foo">heading</h1>');
});
});
43 changes: 43 additions & 0 deletions src/lib/marked/extentions/ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { type Tokens, type MarkedExtension, Renderer } from "marked";

function parseContent(token: Tokens.Generic, ctx: Renderer): string {
const c = token.tokens ? ctx.parser.parseInline(token.tokens) : token.text;
const r = new RegExp(`\\s?\\{#${token.id}\\}`, "g");
return c.replace(r, "");
}

const ids: MarkedExtension = {
walkTokens(token: Tokens.Generic): void {
if (token.type === "text") return;

const match = token.tokens?.find(
(t) => t.type === "text" && /\{#(.*?)\}/.test(t.raw)
);

if (!match && token.tokens?.length) return;

const idMatch = token.raw.match(/\{#(.*?)\}/);

if (!idMatch) return;

token.id = idMatch[1];
},
renderer: {
paragraph(t: Tokens.Generic) {
if (!t.id) return false;
return `<p id="${t.id}">${parseContent(t, this)}</p>\n`;
},
code(t: Tokens.Generic) {
if (!t.id) return false;
return `<pre id="${t.id}"><code>${parseContent(t, this)}</code></pre>`;
},
heading(t: Tokens.Generic) {
if (!t.id) return false;
return `<h${t.depth} id="${t.id}">${parseContent(t, this)}</h${
t.depth
}>\n`;
},
},
};

export default ids;
13 changes: 13 additions & 0 deletions src/lib/marked/extentions/smartypants.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, it, expect } from "vitest";
import { marked } from "marked";
import smartypants from "./smartypants.js";

marked.use(smartypants);

describe("marked ids extension", () => {
it("handles arrows", () => {
const r = marked.parse("-> `a`");

expect(r).toContain("<p>-&gt; <code>a</code></p>");
});
});
26 changes: 26 additions & 0 deletions src/lib/marked/extentions/smartypants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { MarkedExtension, Tokens } from "marked";
import { smartypants } from "smartypants";

const extension: MarkedExtension = {
tokenizer: {
inlineText(src: string): false | Tokens.Text | undefined {
// don't escape inlineText
const cap = this.rules.inline.text.exec(src);
const raw = cap?.[0] ?? "";
const text = raw.replace("<", "&lt;").replace(">", "&gt;");

return {
type: "text",
raw,
text: text,
};
},
},
hooks: {
postprocess(html: string) {
return smartypants(html, "1");
},
},
};

export default extension;
38 changes: 38 additions & 0 deletions src/lib/marked/extentions/urls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Tokens } from "marked";

export default {
tokenizer: {
url(src: string): Tokens.Link | false {
const urlRegex = /^https?:\/\/[^\s\]]+/;
const match = src.match(urlRegex);

if (match) {
return {
type: "link",
raw: match[0],
href: match[0],
text: match[0],
tokens: [
{
type: "text",
raw: match[0],
text: match[0],
},
],
};
}

return false;
},
},
renderer: {
link({ href, text }: Tokens.Link) {
const emailRegex = /^mailto:\S+@\S+\.\S+$/;
if (emailRegex.test(href)) {
return text;
}

return false;
},
},
};
18 changes: 18 additions & 0 deletions src/lib/marked/parse.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, it, expect } from "vitest";
import parse from "./parse.js";

describe("marked ids extension", () => {
it("preserves HTML entities", () => {
const r = parse(
'```\n{#example} &lt;a id="foo1" href="#foo"&gt;[N]&lt;/a&gt;\n```'
);

expect(r).toContain('&lt;a id="foo1" href="#foo"&gt;[N]&lt;/a&gt;');
});

it("parses ~~ as strikethrough", () => {
const r = parse("~~foo~~");

expect(r).toContain("<del>foo</del>");
});
});
13 changes: 13 additions & 0 deletions src/lib/marked/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { marked } from "marked";
import smartypants from "./extentions/smartypants.js";
import urls from "./extentions/urls.js";
import ids from "./extentions/ids.js";

marked.use(urls, smartypants, ids);

export default function parse(markdown: string): string {
// WORKAROUND: `marked.parse` shouldn't return a promise if
// the `async` option has not been set to `true`
// https://marked.js.org/using_pro#async
return marked.parse(markdown) as string;
}
Loading

0 comments on commit 4ddeffa

Please sign in to comment.