From 8ae931524b3b19ae4b3b80f938611c64b17ed086 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 24 May 2024 18:03:21 +0900 Subject: [PATCH] Initial commit --- .gitignore | 2 + .vscode/extensions.json | 6 ++ .vscode/settings.json | 25 +++++++ .zed/settings.json | 28 ++++++++ CHANGES.md | 9 +++ LICENSE | 20 ++++++ README.md | 62 +++++++++++++++++ deno.json | 24 +++++++ dnt.ts | 69 +++++++++++++++++++ mod.ts | 4 ++ src/html.test.ts | 21 ++++++ src/html.ts | 20 ++++++ src/label.ts | 13 ++++ src/mod.ts | 5 ++ src/plugin.test.ts | 39 +++++++++++ src/plugin.ts | 144 ++++++++++++++++++++++++++++++++++++++++ 16 files changed, 491 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 .zed/settings.json create mode 100644 CHANGES.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 deno.json create mode 100644 dnt.ts create mode 100644 mod.ts create mode 100644 src/html.test.ts create mode 100644 src/html.ts create mode 100644 src/label.ts create mode 100644 src/mod.ts create mode 100644 src/plugin.test.ts create mode 100644 src/plugin.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38fb2ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.dnt-import-map.json +npm/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..064d0a5 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "denoland.vscode-deno", + "streetsidesoftware.code-spell-checker" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7bbbea7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,25 @@ +{ + "deno.enable": true, + "deno.unstable": true, + "files.eol": "\n", + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "[json]": { + "editor.defaultFormatter": "vscode.json-language-features", + "editor.formatOnSave": true + }, + "[jsonc]": { + "editor.defaultFormatter": "vscode.json-language-features", + "editor.formatOnSave": true + }, + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.sortImports": "always" + } + }, + "cSpell.words": [ + "markdownit" + ] +} diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..56baf38 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,28 @@ +{ + "deno": { + "enable": true + }, + "ensure_final_newline_on_save": true, + "format_on_save": "on", + "formatter": "language_server", + "languages": { + "TypeScript": { + "language_servers": [ + "deno", + "!typescript-language-server", + "!eslint", + "..." + ] + }, + "TSX": { + "language_servers": [ + "deno", + "!typescript-language-server", + "!eslint", + "..." + ] + } + }, + "show_wrap_guides": true, + "wrap_guides": [80] +} diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..c4b1e9d --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,9 @@ + + +Changelog +========= + +Version 0.1.0 +------------- + +To be released. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..946267f --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright 2024 Hong Minhee + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..93fe969 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ + + +@fedify/markdown-it-hashtag +=========================== + +This is a [markdown-it] plugin that parses and renders Mastodon-style #hashtags. +It converts, for example, `#FooBar` into a link: + +~~~~ html +#FooBar +~~~~ + +The value of `href` attributes, other attributes (if any), and the content of +the link can be customized by passing options to the plugin: + +~~~~ typescript +import MarkdownIt from "markdown-it"; +import { hashtag, spanHashAndTag } from "@fedify/markdown-it-hashtag"; + +const md = new MarkdownIt(); +md.use(hashtag, { + link: (tag: string) => `https://example.com/tags/${tag.substring(1)}`, + linkAttributes: (handle: string) => ({ class: "hashtag" }), + label: spanHashAndTag, +}); +~~~~ + +If you want to collect all hashtags in a document, you can pass an environment +object to the `render()` method: + +~~~~ typescript +const env = {}; +md.render( + "Hello, #FooBar\n\n> #BazQux", + env, +); +console.log(env.hashtags); // ["#FooBar", "#BazQux"] +~~~~ + +[markdown-it]: https://github.com/markdown-it/markdown-it + + +Installation +------------ + +### Deno + +~~~~ sh +deno add @fedify/markdown-it-hashtag +~~~~ + +### Node.js + +~~~~ sh +npm add @fedify/markdown-it-hashtag +~~~~ + +### Bun + +~~~~ sh +bun add @fedify/markdown-it-hashtag +~~~~ diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..f136ad4 --- /dev/null +++ b/deno.json @@ -0,0 +1,24 @@ +{ + "name": "@fedify/markdown-it-hashtag", + "version": "0.1.0", + "exports": "./mod.ts", + "imports": { + "@deno/dnt": "jsr:@deno/dnt@^0.41.1", + "@std/assert": "jsr:@std/assert@^0.225.1", + "@std/html": "jsr:@std/html@^0.224.0", + "markdown-it": "npm:@types/markdown-it@^14.1.1", + "markdown-it-impl": "npm:markdown-it@^14.1.0" + }, + "exclude": [ + ".dnt-import-map.json", + "npm/" + ], + "lock": false, + "tasks": { + "check": "deno check **/*.ts && deno lint && deno fmt --check", + "dnt": "deno run --allow-all dnt.ts", + "hooks:install": "deno run --allow-read=deno.json,.git/hooks/ --allow-write=.git/hooks/ jsr:@hongminhee/deno-task-hooks", + "hooks:pre-commit": "deno task check", + "hooks:pre-push": "deno test" + } +} diff --git a/dnt.ts b/dnt.ts new file mode 100644 index 0000000..6d2b4f6 --- /dev/null +++ b/dnt.ts @@ -0,0 +1,69 @@ +import { build, emptyDir } from "@deno/dnt"; +import metadata from "./deno.json" with { type: "json" }; + +await emptyDir("./npm"); + +const importMap = ".dnt-import-map.json"; +await Deno.writeTextFile( + importMap, + JSON.stringify({ + imports: { + ...metadata.imports, + "markdown-it": metadata.imports["markdown-it-impl"], + "markdown-it/": `npm:/${ + metadata.imports["markdown-it-impl"].substring(4) + }/`, + }, + }), +); + +await build({ + package: { + // package.json properties + name: "@fedify/markdown-it-hashtag", + version: Deno.args[0] ?? metadata.version, + description: + "A markdown-it plugin that parses and renders Mastodon-style #hashtags.", + keywords: [ + "markdown", + "markdown-it", + "markdown-it-plugin", + "Mastodon", + "hashtag", + "fediverse", + ], + license: "MIT", + author: { + name: "Hong Minhee", + email: "hong@minhee.org", + url: "https://hongminhee.org/", + }, + homepage: "https://github.com/dahlia/markdown-it-hashtag", + repository: { + type: "git", + url: "git+https://github.com/dahlia/markdown-it-hashtag.git", + }, + bugs: { + url: "https://github.com/dahlia/markdown-it-hashtag/issues", + }, + devDependencies: { + "@types/markdown-it": "^14.1.1", + }, + }, + outDir: "./npm", + entryPoints: ["./mod.ts"], + importMap, + shims: { + deno: true, + }, + typeCheck: "both", + declaration: "separate", + declarationMap: true, + test: true, + async postBuild() { + await Deno.copyFile("LICENSE", "npm/LICENSE"); + await Deno.copyFile("README.md", "npm/README.md"); + }, +}); + +// cSpell: ignore Minhee diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..5ffec8b --- /dev/null +++ b/mod.ts @@ -0,0 +1,4 @@ +export * from "./src/mod.ts"; +import { hashtag } from "./src/mod.ts"; + +export default hashtag; diff --git a/src/html.test.ts b/src/html.test.ts new file mode 100644 index 0000000..12bd46c --- /dev/null +++ b/src/html.test.ts @@ -0,0 +1,21 @@ +import { isLinkClose, isLinkOpen } from "./html.ts"; +import { assert } from "@std/assert/assert"; +import { assertFalse } from "@std/assert/assert-false"; + +Deno.test("isLinkOpen()", () => { + assert(isLinkOpen('')); + assert(isLinkOpen("")); + assertFalse(isLinkOpen("")); + assertFalse(isLinkOpen("")); + assertFalse(isLinkOpen("")); + assertFalse(isLinkOpen("asdf")); +}); + +Deno.test("isLinkClose()", () => { + assert(isLinkClose("")); + assert(isLinkClose("")); + assertFalse(isLinkClose("")); + assertFalse(isLinkClose("")); + assertFalse(isLinkClose("")); + assertFalse(isLinkClose("asdf")); +}); diff --git a/src/html.ts b/src/html.ts new file mode 100644 index 0000000..4fcb104 --- /dev/null +++ b/src/html.ts @@ -0,0 +1,20 @@ +const LINK_OPEN_PATTERN = /^\s]/i; +const LINK_CLOSE_PATTERN = /^<\/a\s*>/i; + +/** + * Tests if the given string is an opening link tag. + * @function + * @param html The string to test. + * @returns `true` if the given string is an opening link tag, + * or `false` otherwise. + */ +export const isLinkOpen = LINK_OPEN_PATTERN.test.bind(LINK_OPEN_PATTERN); + +/** + * Tests if the given string is a closing link tag. + * @function + * @param html The string to test. + * @returns `true` if the given string is a closing link tag, + * or `false` otherwise. + */ +export const isLinkClose = LINK_CLOSE_PATTERN.test.bind(LINK_CLOSE_PATTERN); diff --git a/src/label.ts b/src/label.ts new file mode 100644 index 0000000..1b4c5bb --- /dev/null +++ b/src/label.ts @@ -0,0 +1,13 @@ +import { escape } from "@std/html"; + +/** + * Renders a hashtag into an HTML string. + */ +export function spanHashAndTag(hashtag: string): string { + if (hashtag.startsWith("#")) { + hashtag = hashtag.substring(1); + } + return `#${ + escape(hashtag) + }`; +} diff --git a/src/mod.ts b/src/mod.ts new file mode 100644 index 0000000..0ea5ac3 --- /dev/null +++ b/src/mod.ts @@ -0,0 +1,5 @@ +export * from "./label.ts"; +export * from "./plugin.ts"; +import { hashtag } from "./plugin.ts"; + +export default hashtag; diff --git a/src/plugin.test.ts b/src/plugin.test.ts new file mode 100644 index 0000000..ac8e3b6 --- /dev/null +++ b/src/plugin.test.ts @@ -0,0 +1,39 @@ +import { assertEquals } from "@std/assert/assert-equals"; +import MarkdownIt from "markdown-it-impl"; +import { hashtag } from "./plugin.ts"; + +Deno.test("hashtag()", () => { + const md = new MarkdownIt({ + html: true, + }); + md.use(hashtag); + // deno-lint-ignore no-explicit-any + const env: any = {}; + const html = md.render( + `\ +**Hello**, *#FooBar*! + +> #BazQux + +[This should be ignored: #FooBar](https://example.com/) + +This also should be ignored: #FooBar +`, + env, + ); + assertEquals(env.hashtags, [ + "#FooBar", + "#BazQux", + ]); + assertEquals( + html, + `\ +

Hello, #FooBar!

+
+

#BazQux

+
+

This should be ignored: #FooBar

+

This also should be ignored: #FooBar

+`, + ); +}); diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..3481d11 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,144 @@ +import type MarkdownIt from "markdown-it"; +import type { Options, PluginWithOptions } from "markdown-it"; +import { isLinkClose, isLinkOpen } from "./html.ts"; +import { spanHashAndTag } from "./label.ts"; + +type Renderer = MarkdownIt["renderer"]; +type Token = ReturnType[number]; +type StateCore = Parameters[0]; + +/** + * Options for the plugin. + */ +export interface PluginOptions { + /** + * A function to render a link href for a hashtag. If it returns `null`, + * the hashtag will be rendered as plain text. `#${tag}` by default. + */ + link?: (tag: string) => string | null; + + /** + * A function to render extra attributes for a hashtag link. + */ + linkAttributes?: (handle: string) => Record; + + /** + * A function to render a label for a hashtag link. {@link spanHashAndTag} + * by default. + */ + label?: (handle: string) => string; +} + +/** + * A markdown-it plugin to parse and render Mastodon-style hashtags. + */ +export const hashtag: PluginWithOptions = ( + md: MarkdownIt, + options?: PluginOptions, +): void => { + md.core.ruler.after( + "inline", + "hashtag", + (state: StateCore) => parseHashtag(state, options), + ); + md.renderer.rules.hashtag = renderHashtag; +}; + +function parseHashtag(state: StateCore, options?: PluginOptions) { + for (const blockToken of state.tokens) { + if (blockToken.type !== "inline") continue; + if (blockToken.children == null) continue; + let linkDepth = 0; + let htmlLinkDepth = 0; + blockToken.children = blockToken.children.flatMap((token: Token) => { + if (token.type === "link_open") { + linkDepth++; + } else if (token.type === "link_close") { + linkDepth--; + } else if (token.type === "html_inline") { + if (isLinkOpen(token.content)) { + htmlLinkDepth++; + } else if (isLinkClose(token.content)) { + htmlLinkDepth--; + } + } + if ( + linkDepth > 0 || htmlLinkDepth > 0 || token.type !== "text" + ) { + return [token]; + } + return splitTokens(token, state, options); + }); + } +} + +const HASHTAG_PATTERN = /#\w+/g; + +function splitTokens( + token: Token, + state: StateCore, + options?: PluginOptions, +): Token[] { + const { content, level } = token; + const tokens = []; + let pos = 0; + for (const match of content.matchAll(HASHTAG_PATTERN)) { + if (match.index == null) continue; + + if (match.index > pos) { + const token = new state.Token("text", "", 0); + token.content = content.substring(pos, match.index); + token.level = level; + tokens.push(token); + } + + const href = options?.link?.(match[0]); + if (href == null && options?.link != null) { + const token = new state.Token("text", "", 0); + token.content = match[0]; + token.level = level; + tokens.push(token); + pos = match.index + match[0].length; + continue; + } + + const token = new state.Token("hashtag", "", 0); + token.content = options?.label?.(match[0]) ?? spanHashAndTag(match[0]); + token.level = level; + const attrs = options?.linkAttributes?.(match[0]) ?? {}; + attrs.href = href ?? `${match[0]}`; + token.attrs = Object.entries(attrs); + token.info = match[0]; + tokens.push(token); + pos = match.index + match[0].length; + } + + if (pos < content.length) { + const token = new state.Token("text", "", 0); + token.content = content.substring(pos); + token.level = level; + tokens.push(token); + } + + return tokens; +} + +function renderHashtag( + tokens: Token[], + idx: number, + opts: Options, + // deno-lint-ignore no-explicit-any + env: any, + self: Renderer, +): string { + if (tokens.length <= idx) return ""; + const token = tokens[idx]; + if (token.type !== "hashtag") return self.renderToken(tokens, idx, opts); + if (typeof env === "object" && env !== null) { + if (!("hashtags" in env)) { + env.hashtags = []; + } + env.hashtags.push(token.info); + } + return `${token.content}`; +}