hd-garden 0.5.17 → 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/app/admin/preview/[...path]/page.tsx +3 -2
- package/app/styles/a2.css +12 -0
- package/package.json +1 -1
- package/scripts/build-content.mjs +5 -4
- package/scripts/check-post.mjs +4 -2
- 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
|
@@ -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;
|
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
|
}
|
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(
|
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
|
|
|
@@ -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, " ")
|