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.
@@ -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;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.5.17",
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(
@@ -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
 
@@ -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, " ")