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 +2 -0
- package/app/admin/preview/[...path]/page.tsx +3 -2
- package/app/components/MarkdownArticle.tsx +2 -2
- package/app/globals.css +1 -1
- package/app/layout.tsx +3 -4
- package/app/styles/a2.css +15 -2
- package/package.json +1 -1
- package/scripts/build-content.mjs +5 -4
- package/scripts/check-lockfile-registry.mjs +35 -0
- package/scripts/check-post.mjs +4 -2
- package/scripts/file-preview.mjs +2 -2
- package/ui/garden/code-language.mjs +17 -3
- package/ui/garden/markdown-destination.mjs +77 -0
- package/ui/garden/markdown-links.mjs +9 -3
- package/ui/garden/search-index.mjs +6 -2
- package/ui/preview/dist/garden-ui.esm.js +107 -107
- package/ui/preview/dist/garden-ui.iife.js +107 -107
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
|
|
26
|
-
isRelative(
|
|
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
|
|
336
|
+
const language = fenceLanguage(className);
|
|
337
337
|
const preProps = { ...props };
|
|
338
338
|
delete preProps.node;
|
|
339
339
|
|
package/app/globals.css
CHANGED
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:
|
|
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
|
|
1285
|
+
.markdown-body pre[data-language]::before {
|
|
1273
1286
|
display: none;
|
|
1274
1287
|
}
|
|
1275
1288
|
}
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
// `.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
|
+
}
|
package/scripts/check-post.mjs
CHANGED
|
@@ -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
|
|
251
|
-
|
|
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
|
|
package/scripts/file-preview.mjs
CHANGED
|
@@ -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 ?? [])
|
|
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
|
|
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: "
|
|
19
|
-
zsh: "
|
|
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
|
+
* ``, 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: `.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
|
-
|
|
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
|
|
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, (
|
|
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
|
-
|
|
17
|
-
|
|
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, " ")
|