hd-garden 0.5.16 → 0.5.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,8 @@ npm install
14
14
 
15
15
  推荐把 GitHub 仓库命名为 `<你的 GitHub 用户名>.github.io`,并在 **Settings → Pages** 把 Source 设为 **GitHub Actions**。然后修改 `site.config.yaml`,再写第一篇 Markdown。公开包名是 `hd-garden`;装进站点之后也可以用 `garden`。不要运行 `npx garden init`,npm 上的 `garden` 是别人的样式库。
16
16
 
17
+ 在国内想用 npm 镜像(例如 npmmirror),把 `registry` 写在自己的 `~/.npmrc` 或不提交的项目 `.npmrc` 里,不要让镜像地址进 `package-lock.json`:GitHub Actions 连不上镜像时 `npm ci` 会一直卡住。镜像会把新装包的 `resolved` 写成镜像地址,提交前运行 `node node_modules/hd-garden/scripts/check-lockfile-registry.mjs` 检查(Garden 仓库的 CI 也会检查);报错时把这些地址的域名换回 `https://registry.npmjs.org/`,路径和 integrity 都不用改。
18
+
17
19
  也可以继续用 **Use this template**;该仓库同时是可运行的演示站。
18
20
 
19
21
  完成后运行:
@@ -7,6 +7,7 @@ import { AdminUiShell, isStaticExport, requireAdminPage, StaticAdminNotice } fro
7
7
  import { MarkdownArticle } from "../../../components/MarkdownArticle";
8
8
  import { repositoryContentPath } from "../../../lib/repository-path.mjs";
9
9
  import { readMarkdown, readText } from "../../../lib/ui-content";
10
+ import { formatInlineLink, replaceInlineLinks } from "../../../../ui/garden/markdown-destination.mjs";
10
11
 
11
12
  export const dynamic = process.env.STATIC_EXPORT === "1" ? "force-static" : "force-dynamic";
12
13
  export const dynamicParams = false;
@@ -22,8 +23,8 @@ const isRelative = (value: string) => !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/iu.test(val
22
23
  /** Same-name-directory files go through the authenticated, sandboxed proxy. */
23
24
  function proxyAssets(content: string, post: string) {
24
25
  const proxy = (relative: string) => `/api/admin/preview/asset?post=${encodeURIComponent(post)}&path=${encodeURIComponent(relative)}`;
25
- let output = content.replace(/(!\[[^\]]*\]\()<?([^)\s>]+)>?/gu, (match, prefix: string, src: string) =>
26
- isRelative(src) ? `${prefix}${proxy(src)}` : match,
26
+ let output = replaceInlineLinks(content, (link) =>
27
+ link.image && isRelative(link.url) ? formatInlineLink(link, proxy(link.url)) : undefined,
27
28
  );
28
29
  for (const fence of [...scanFences(output)].reverse()) {
29
30
  if (fence.lang !== "widget") continue;
@@ -33,7 +33,7 @@ import { LinkCardRefresh } from "./LinkCardRefresh";
33
33
  import { linkHost } from "../../scripts/link-meta-core.mjs";
34
34
  import { cardDate } from "../../ui/garden/card-refresh.mjs";
35
35
  import { siteHref } from "../lib/site-href";
36
- import { codeLanguageLabel } from "../../ui/garden/code-language.mjs";
36
+ import { codeLanguageLabel, fenceLanguage } from "../../ui/garden/code-language.mjs";
37
37
 
38
38
  // Full URLs back to this site are internal links, written as root-relative paths.
39
39
  const sameSite = sameSiteMatcher(siteLinks?.address ?? "", siteLinks?.basePath ?? "");
@@ -333,7 +333,7 @@ export function MarkdownArticle({ content }: { content: string }) {
333
333
  const className = isValidElement<{ className?: string }>(child)
334
334
  ? child.props.className
335
335
  : undefined;
336
- const language = className?.match(/language-([\w-]+)/)?.[1];
336
+ const language = fenceLanguage(className);
337
337
  const preProps = { ...props };
338
338
  delete preProps.node;
339
339
 
package/app/globals.css CHANGED
@@ -2188,7 +2188,7 @@ button {
2188
2188
  position: absolute;
2189
2189
  top: 8px;
2190
2190
  right: 12px;
2191
- color: #8b929c;
2191
+ color: var(--muted);
2192
2192
  font-size: 9px;
2193
2193
  font-weight: 700;
2194
2194
  letter-spacing: 0.12em;
package/app/layout.tsx CHANGED
@@ -31,12 +31,11 @@ export async function generateMetadata(): Promise<Metadata> {
31
31
  icon: [{ url: brand.favicon, type: brand.favicon.endsWith(".svg") ? "image/svg+xml" : undefined }],
32
32
  ...(brand.appleTouchIcon ? { apple: [{ url: brand.appleTouchIcon, sizes: "180x180" }] } : {}),
33
33
  },
34
+ // index and follow are the default, so no robots meta is written here: the
35
+ // 404 page keeps exactly one, the noindex vinext adds, and noindex pages set
36
+ // their own. Only Google's preview limits are stated.
34
37
  robots: {
35
- index: true,
36
- follow: true,
37
38
  googleBot: {
38
- index: true,
39
- follow: true,
40
39
  "max-image-preview": "large",
41
40
  "max-snippet": -1,
42
41
  "max-video-preview": -1,
package/app/styles/a2.css CHANGED
@@ -685,6 +685,18 @@ html[data-theme="dark"] .markdown-body .markdown-alert code {
685
685
  height: auto;
686
686
  }
687
687
 
688
+ /* Dark mode: article images sit on the light paper colour, so a transparent
689
+ image (black lines on nothing, like most SVG diagrams) stays legible and an
690
+ opaque one looks the same. The backing is the image's own background, so it
691
+ takes the image's own corner radius; no padding, no filters. Favicons, link
692
+ card thumbnails, covers, avatars and the logo are not article images. */
693
+ html[data-theme="dark"] .markdown-image-trigger img,
694
+ html[data-theme="dark"] .markdown-image-lightbox-content img,
695
+ html[data-theme="dark"] .markdown-body > img,
696
+ html[data-theme="dark"] .markdown-body > p > img:not([class]) {
697
+ background: #faf9f5;
698
+ }
699
+
688
700
  .markdown-body .fig {
689
701
  margin: 36px 0;
690
702
  }
@@ -1267,9 +1279,10 @@ html[data-theme="dark"] .markdown-body .markdown-alert code {
1267
1279
  visibility: hidden;
1268
1280
  }
1269
1281
 
1270
- /* Narrow screens: only the copy button; the code block layout stays as it is. */
1282
+ /* Narrow screens: no language label, in the article (only the copy button
1283
+ stays) and in the file preview drawer alike; nothing else changes. */
1271
1284
  @media (max-width: 639.98px) {
1272
- .markdown-body .ux-cb > pre[data-language]::before {
1285
+ .markdown-body pre[data-language]::before {
1273
1286
  display: none;
1274
1287
  }
1275
1288
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.5.16",
3
+ "version": "0.5.18",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -10,6 +10,7 @@ import { countDemoWords } from "../ui/garden/word-count.mjs";
10
10
  import { referenceDomain } from "../ui/garden/references.mjs";
11
11
  import { linkAssetsForRender } from "./link-assets.mjs";
12
12
  import { cardUrls } from "../ui/garden/markdown-links.mjs";
13
+ import { formatInlineLink, replaceInlineLinks } from "../ui/garden/markdown-destination.mjs";
13
14
  import { configuredSiteAddress, sameSiteMatcher } from "../ui/garden/same-site.mjs";
14
15
  import { resolveBrand } from "./brand.mjs";
15
16
  import { imageSources, readImageCache, sizeFromBytes } from "./image-sizes.mjs";
@@ -173,10 +174,10 @@ function copyLocalAsset(value, markdownPath, sourcePath, options = {}) {
173
174
  }
174
175
 
175
176
  function rewriteMarkdownAssets(content, markdownPath, sourcePath) {
176
- const markdownImages = content.replace(
177
- /(!\[[^\]]*\]\()<?([^)\s>]+)>?((?:\s+["'][^"']*["'])?\))/gu,
178
- (_, prefix, src, suffix) =>
179
- `${prefix}${copyLocalAsset(src, markdownPath, sourcePath)}${suffix}`,
177
+ // `![alt](<a b (1).png>)` keeps its angle brackets: without them the space
178
+ // and the parentheses would no longer be one destination.
179
+ const markdownImages = replaceInlineLinks(content, (link) =>
180
+ link.image ? formatInlineLink(link, copyLocalAsset(link.url, markdownPath, sourcePath)) : undefined,
180
181
  );
181
182
 
182
183
  return markdownImages.replace(
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ // Fails when package-lock.json downloads anything from somewhere other than the
3
+ // public npm registry. A mirror (for example a China mirror) belongs in your own
4
+ // ~/.npmrc or a project .npmrc that you keep out of git, never in the lockfile:
5
+ // CI runners elsewhere may not reach it, and `npm ci` then hangs without output.
6
+ // Usage: node scripts/check-lockfile-registry.mjs [path/to/package-lock.json]
7
+ import fs from "node:fs";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ export const ALLOWED_REGISTRY = "https://registry.npmjs.org/";
11
+
12
+ /** Entries of a lockfile whose `resolved` URL is not on registry.npmjs.org. */
13
+ export function foreignResolved(lock) {
14
+ const out = [];
15
+ for (const [name, entry] of Object.entries(lock?.packages ?? {})) {
16
+ const resolved = entry?.resolved;
17
+ // Workspace links and the root package have no download URL.
18
+ if (typeof resolved !== "string" || entry.link) continue;
19
+ if (!resolved.startsWith(ALLOWED_REGISTRY)) out.push({ name: name || "(root)", resolved });
20
+ }
21
+ return out;
22
+ }
23
+
24
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
25
+ const file = process.argv[2] ?? "package-lock.json";
26
+ const offenders = foreignResolved(JSON.parse(fs.readFileSync(file, "utf8")));
27
+ if (offenders.length) {
28
+ console.error(`${file}: ${offenders.length} 个包的 resolved 不在 ${ALLOWED_REGISTRY}:`);
29
+ for (const { name, resolved } of offenders.slice(0, 20)) console.error(` ${name} ${resolved}`);
30
+ if (offenders.length > 20) console.error(` …… 另外 ${offenders.length - 20} 个`);
31
+ console.error("镜像请写在自己的 .npmrc 里,不要进锁文件。把这些地址改回 registry.npmjs.org 后再提交。");
32
+ process.exit(1);
33
+ }
34
+ console.log(`${file}: 全部 resolved 都在 ${ALLOWED_REGISTRY}`);
35
+ }
@@ -7,6 +7,7 @@ import { gardenSiteRoot } from "./garden-paths.mjs";
7
7
  import { checkUiBlock } from "../ui/core/validate.mjs";
8
8
  import { checkGardenMarkdown } from "../ui/garden/check.mjs";
9
9
  import { checkFileSources } from "../ui/garden/file-paths.mjs";
10
+ import { inlineLinkParts, inlineLinkPattern } from "../ui/garden/markdown-destination.mjs";
10
11
 
11
12
  const siteRoot = gardenSiteRoot();
12
13
  const calloutTypes = new Set([
@@ -247,8 +248,9 @@ const scanBody = (errors, content, markdownPath, sourcePath, { warnings = [], dr
247
248
  error(errors, "callout-type", lineNumber, `${sourcePath}: 不支持的警告框类型 ${callout[1]}`);
248
249
  }
249
250
 
250
- for (const image of line.matchAll(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/gu)) {
251
- assertLocalFile(errors, image[2], path.dirname(markdownPath), sourcePath, lineNumber, "图片");
251
+ for (const match of line.matchAll(inlineLinkPattern())) {
252
+ const image = inlineLinkParts(match);
253
+ if (image.image) assertLocalFile(errors, image.url, path.dirname(markdownPath), sourcePath, lineNumber, "图片");
252
254
  }
253
255
  });
254
256
 
@@ -23,7 +23,7 @@ import remarkRehype from "remark-rehype";
23
23
  import { unified } from "unified";
24
24
  import { visit } from "unist-util-visit";
25
25
  import { markdownSyntaxPlugins } from "../ui/garden/markdown-syntax.mjs";
26
- import { codeLanguageLabel } from "../ui/garden/code-language.mjs";
26
+ import { codeLanguageLabel, fenceLanguage } from "../ui/garden/code-language.mjs";
27
27
 
28
28
  const DROPPED_ELEMENTS = new Set(["script", "noscript", "iframe", "frame", "frameset", "object", "embed", "applet", "base", "link", "meta", "form", "template"]);
29
29
  const URL_PROPERTIES = new Set(["href", "src", "action", "formAction", "poster", "cite", "background", "xLinkHref"]);
@@ -82,7 +82,7 @@ export function rehypePreviewShape() {
82
82
  }
83
83
  if (node.tagName === "pre") {
84
84
  const code = node.children.find((child) => child.type === "element" && child.tagName === "code");
85
- const language = [].concat(code?.properties?.className ?? []).map(String).find((name) => name.startsWith("language-"))?.slice(9);
85
+ const language = fenceLanguage([].concat(code?.properties?.className ?? []));
86
86
  node.properties.className = [...[].concat(node.properties.className ?? []), "code"];
87
87
  if (codeLanguageLabel(language)) node.properties.dataLanguage = codeLanguageLabel(language);
88
88
  }
@@ -3,7 +3,8 @@
3
3
  * drawer and the standalone preview page all go through here, so ```py and
4
4
  * ```python read the same everywhere. Common aliases map to one language; JS and
5
5
  * TS use their abbreviations, every other language its full name in capitals
6
- * (PYTHON, GO, RUST, BASH), and an unknown fence language is shown in capitals.
6
+ * (PYTHON, GO, RUST), every shell (sh, zsh, bash, shell) reads SHELL, and an
7
+ * unknown fence language is shown in capitals.
7
8
  */
8
9
  const ALIASES = {
9
10
  py: "python",
@@ -15,8 +16,9 @@ const ALIASES = {
15
16
  ts: "typescript",
16
17
  mts: "typescript",
17
18
  tsx: "tsx",
18
- sh: "bash",
19
- zsh: "bash",
19
+ sh: "shell",
20
+ zsh: "shell",
21
+ bash: "shell",
20
22
  shell: "shell",
21
23
  console: "shell",
22
24
  golang: "go",
@@ -27,6 +29,7 @@ const ALIASES = {
27
29
  md: "markdown",
28
30
  htm: "html",
29
31
  "c++": "cpp",
32
+ "c#": "csharp",
30
33
  cc: "cpp",
31
34
  hpp: "cpp",
32
35
  cs: "csharp",
@@ -39,6 +42,17 @@ const ALIASES = {
39
42
  /** JavaScript and TypeScript go by their common abbreviations; everything else by its full name. */
40
43
  const NAMES = { javascript: "JS", typescript: "TS", cpp: "C++", csharp: "C#" };
41
44
 
45
+ /**
46
+ * The fence language from a code element's class (`language-c#` → `c#`). Takes
47
+ * a class string or a class list. The name is everything after `language-` up
48
+ * to the next space, so symbols stay: c#, c++, f#, objective-c, shell-session.
49
+ */
50
+ export function fenceLanguage(className) {
51
+ const names = Array.isArray(className) ? className.map(String) : String(className ?? "").split(/\s+/u);
52
+ const token = names.find((name) => name.startsWith("language-") && name.length > 9);
53
+ return token ? token.slice(9) : "";
54
+ }
55
+
42
56
  /** Display label for a fence language, or "" when there is none. */
43
57
  export function codeLanguageLabel(language) {
44
58
  const raw = String(language ?? "").trim().toLowerCase();
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Inline links and images, `[label](destination "title")` and
3
+ * `![alt](destination "title")`, read the way CommonMark reads them. A
4
+ * destination is either
5
+ * - `<…>`: anything but a line break or an unescaped `<` / `>`, so it may hold
6
+ * spaces and unbalanced parentheses: `![a](<./my shot (1).png>)`; or
7
+ * - a bare run without spaces, where parentheses must balance:
8
+ * `[w](https://en.wikipedia.org/wiki/Trie_(data_structure))`.
9
+ * The build, the checker, the draft preview, link cards and the search index
10
+ * all use this one reading, so a destination that works in the article also
11
+ * works everywhere else, and a rewritten destination keeps its brackets.
12
+ */
13
+
14
+ const ESCAPED = String.raw`\\.`;
15
+ const ANGLE = String.raw`<((?:[^<>\n\\]|${ESCAPED})*)>`;
16
+ const PLAIN = String.raw`(?:[^\s()<\\]|${ESCAPED})`;
17
+ const INNER = String.raw`(?:[^\s()\\]|${ESCAPED})`;
18
+ // Balanced parentheses, nested up to three deep.
19
+ const PARENS = [1, 2, 3].reduce((inner) => String.raw`\((?:${INNER}|${inner})*\)`, String.raw`\((?:${INNER})*\)`);
20
+ const BARE = String.raw`((?:${PLAIN}|${PARENS})(?:${INNER}|${PARENS})*)`;
21
+ const LABEL = String.raw`(?:[^\[\]\\]|${ESCAPED}|\[(?:[^\[\]\\]|${ESCAPED})*\])*`;
22
+
23
+ /**
24
+ * Source of the inline link pattern. Groups: 1 `!` for an image, 2 label,
25
+ * 3 destination written in `<…>`, 4 bare destination, 5 title (with the space
26
+ * before it).
27
+ */
28
+ export const INLINE_LINK_SOURCE = String.raw`(!?)\[(${LABEL})\]\(\s*(?:${ANGLE}|${BARE})((?:\s+(?:"(?:[^"\\]|${ESCAPED})*"|'(?:[^'\\]|${ESCAPED})*'|\((?:[^()\\]|${ESCAPED})*\)))?)\s*\)`;
29
+
30
+ /**
31
+ * A fresh global pattern for inline links and images (`flags` adds e.g. `i`).
32
+ * @param {string} [flags]
33
+ */
34
+ export const inlineLinkPattern = (flags = "") => new RegExp(INLINE_LINK_SOURCE, `gu${flags.replace(/[gu]/gu, "")}`);
35
+
36
+ /**
37
+ * @typedef {{ whole: string, image: boolean, label: string, url: string, angle: boolean, title: string }} InlineLink
38
+ */
39
+
40
+ /**
41
+ * The parts of one match of inlineLinkPattern().
42
+ * @param {ArrayLike<string | undefined>} match
43
+ * @returns {InlineLink}
44
+ */
45
+ export function inlineLinkParts(match) {
46
+ const angle = match[3] !== undefined;
47
+ return { whole: String(match[0]), image: match[1] === "!", label: String(match[2] ?? ""), url: String((angle ? match[3] : match[4]) ?? ""), angle, title: match[5] ?? "" };
48
+ }
49
+
50
+ /**
51
+ * A destination written back the way it was written: in `<…>` if it was.
52
+ * @param {string} url
53
+ * @param {boolean} angle
54
+ */
55
+ export const formatDestination = (url, angle) => (angle ? `<${url}>` : url);
56
+
57
+ /**
58
+ * `[label](destination title)` or `![…](…)` from parts, with a new url.
59
+ * @param {Pick<InlineLink, "image" | "label" | "angle" | "title">} link
60
+ * @param {string} url
61
+ */
62
+ export const formatInlineLink = ({ image, label, angle, title }, url) =>
63
+ `${image ? "!" : ""}[${label}](${formatDestination(url, angle)}${title})`;
64
+
65
+ /**
66
+ * Replace every inline link and image. `replace(parts)` returns the new text,
67
+ * or undefined to leave that link as written.
68
+ * @param {string} text
69
+ * @param {(link: InlineLink) => string | undefined} replace
70
+ * @returns {string}
71
+ */
72
+ export function replaceInlineLinks(text, replace) {
73
+ return String(text ?? "").replace(inlineLinkPattern(), (...match) => {
74
+ const next = replace(inlineLinkParts(/** @type {string[]} */ (match)));
75
+ return next === undefined ? match[0] : next;
76
+ });
77
+ }
@@ -2,6 +2,7 @@
2
2
  * External links written in a post body. Code (fenced blocks, indented blocks
3
3
  * and inline spans) is skipped, so a URL in a snippet is never a link.
4
4
  */
5
+ import { INLINE_LINK_SOURCE } from "./markdown-destination.mjs";
5
6
 
6
7
  const FENCE = /^ {0,3}(`{3,}|~{3,})/u;
7
8
 
@@ -47,7 +48,12 @@ function mapInline(text, visit) {
47
48
  return result + visit(text.slice(index));
48
49
  }
49
50
 
50
- const LINK = /(!?)\[(?:[^\[\]]|\[[^\]]*\])*\]\(\s*<?(https?:\/\/[^\s)>]+)>?(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)(\{card\})?/giu;
51
+ // Inline links read as CommonMark reads them (ui/garden/markdown-destination.mjs):
52
+ // `[t](<https://e.com/a (1)>)` and `[t](https://e.com/Trie_(x))` are whole addresses.
53
+ // Groups: 1 `!`, 3 or 4 the address, 6 `{card}`.
54
+ const LINK = new RegExp(`${INLINE_LINK_SOURCE}(\\{card\\})?`, "giu");
55
+ const linkUrl = (match) => match[3] ?? match[4];
56
+ const isWebUrl = (url) => /^https?:\/\//iu.test(url);
51
57
  const AUTOLINK = /<(https?:\/\/[^\s>]+)>/giu;
52
58
  const BARE = /(^|[\s((::,,。])(https?:\/\/[^\s<>()()\],。、]+[^\s<>()()\].,;:!?,。、;:!?'"])/giu;
53
59
 
@@ -56,7 +62,7 @@ export function markdownLinks(markdown) {
56
62
  const links = [];
57
63
  mapOutsideCode(markdown, (text) => {
58
64
  for (const match of text.matchAll(LINK)) {
59
- if (match[1] !== "!") links.push({ url: match[2], card: Boolean(match[3]) });
65
+ if (match[1] !== "!" && isWebUrl(linkUrl(match))) links.push({ url: linkUrl(match), card: Boolean(match[6]) });
60
66
  }
61
67
  const rest = text.replace(LINK, " ");
62
68
  for (const match of rest.matchAll(AUTOLINK)) links.push({ url: match[1], card: false });
@@ -75,7 +81,7 @@ export const CARD_MARKER = "{card}";
75
81
  */
76
82
  export function stripCardMarkers(markdown) {
77
83
  if (!String(markdown ?? "").includes(CARD_MARKER)) return markdown;
78
- return mapOutsideCode(markdown, (text) => text.replace(LINK, (whole, bang, url, card) => (card ? whole.slice(0, -CARD_MARKER.length) : whole)));
84
+ return mapOutsideCode(markdown, (text) => text.replace(LINK, (...match) => (match[6] && isWebUrl(linkUrl(match)) ? match[0].slice(0, -CARD_MARKER.length) : match[0])));
79
85
  }
80
86
 
81
87
  /** URLs the author asked to show as cards, in order, without repeats. */
@@ -8,13 +8,17 @@
8
8
  */
9
9
  import crypto from "node:crypto";
10
10
  import { contentToSearchText } from "../core/transform.mjs";
11
+ import { inlineLinkPattern } from "./markdown-destination.mjs";
12
+
13
+ const INLINE_LINK = inlineLinkPattern();
11
14
 
12
15
  /** Plain text of a post body, as search and word counts see it. */
13
16
  export function searchableText(markdown) {
14
17
  return contentToSearchText(markdown)
15
18
  .replace(/```[^\n]*\n([\s\S]*?)```/g, " $1 ")
16
- .replace(/!\[([^\]]*)\]\([^)]+\)/g, " $1 ")
17
- .replace(/\[([^\]]+)\]\([^)]+\)/g, " $1 ")
19
+ // Links and images leave their text; the address goes whole, even with
20
+ // parentheses or in <…> (ui/garden/markdown-destination.mjs).
21
+ .replace(INLINE_LINK, (...match) => ` ${match[2]} `)
18
22
  .replace(/<[^>]+>/g, " ")
19
23
  .replace(/[#>*_~`|[\]{}()]/g, " ")
20
24
  .replace(/\s+/g, " ")