hd-garden 0.10.2 → 0.10.3
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.en.md +11 -1
- package/README.md +11 -1
- package/app/components/ArticleMeasure.tsx +3 -2
- package/app/styles/a2.css +13 -0
- package/docs/handbook/components/image.md +10 -0
- package/docs/handbook/en/components/image.md +10 -0
- package/docs/handbook/en/writing.md +1 -0
- package/docs/handbook/writing.md +1 -0
- package/package.json +1 -1
- package/scripts/build-content.mjs +41 -16
- package/scripts/excalidraw.mjs +372 -0
- package/ui/core/transform.mjs +9 -1
- package/ui/garden/excalidraw-inline.mjs +175 -0
package/README.en.md
CHANGED
|
@@ -44,7 +44,17 @@ npx garden ui check --staged # Check staged posts for uncompi
|
|
|
44
44
|
npx garden ui catalog # Generate an offline component gallery at .garden/ui-catalog.html
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
## 4.
|
|
47
|
+
## 4. Excalidraw
|
|
48
|
+
|
|
49
|
+
Point a Markdown image at the `.excalidraw` source. `garden build` exports one light flat SVG beside that file, with a transparent background, and embeds that SVG in the article so its text can be selected. No separate export command.
|
|
50
|
+
|
|
51
|
+
```markdown
|
|
52
|
+

|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The first page that references `.excalidraw` downloads the exporter (Chromium is not required; fonts are embedded in the SVG). See [Local Image](docs/handbook/en/components/image.md).
|
|
56
|
+
|
|
57
|
+
## 5. Handbook and reference docs
|
|
48
58
|
|
|
49
59
|
Start `make dev` and open `/garden/en` (Chinese at `/garden`), or read the markdown sources directly:
|
|
50
60
|
|
package/README.md
CHANGED
|
@@ -46,7 +46,17 @@ npx garden ui check --staged # 检查暂存区稿件中的想
|
|
|
46
46
|
npx garden ui catalog # 在 .garden/ui-catalog.html 生成离线组件清单
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
## 4.
|
|
49
|
+
## 4. Excalidraw
|
|
50
|
+
|
|
51
|
+
正文用 Markdown 图片指向 `.excalidraw` 源文件。`garden build` 会在同目录导出一张浅色扁平 SVG,画布背景透明,并把这张 SVG 嵌进正文,图里的文字可以选中。不需要单独的导出命令。
|
|
52
|
+
|
|
53
|
+
```markdown
|
|
54
|
+

|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
页面里第一次出现 `.excalidraw` 时,构建会下载导出程序(不需要 Chromium;字体写进 SVG)。说明见[本地图片](docs/handbook/components/image.md)。
|
|
58
|
+
|
|
59
|
+
## 5. 使用说明与深入文档
|
|
50
60
|
|
|
51
61
|
本地运行 `make dev` 后访问 `/garden`(英文版在 `/garden/en`),或直接阅读仓库内的文档:
|
|
52
62
|
|
|
@@ -137,7 +137,7 @@ export function ArticleMeasure() {
|
|
|
137
137
|
});
|
|
138
138
|
// Images: as wide as they display (2x screenshots at half their pixels), up to 1040px in total.
|
|
139
139
|
article.querySelectorAll<HTMLElement>(".fig").forEach((figure) => {
|
|
140
|
-
const image = figure.querySelector("img");
|
|
140
|
+
const image = figure.querySelector<HTMLImageElement>("img, svg.excalidraw");
|
|
141
141
|
resetWide(figure);
|
|
142
142
|
// From here on the browser decides; the build-time breakout CSS steps aside.
|
|
143
143
|
figure.classList.remove("fig-wide");
|
|
@@ -148,8 +148,9 @@ export function ArticleMeasure() {
|
|
|
148
148
|
if (baseWidth) image.style.width = `${baseWidth}px`;
|
|
149
149
|
else image.style.removeProperty("width");
|
|
150
150
|
// Sizes read at build time let this run before the file arrives; otherwise wait for load.
|
|
151
|
+
// An inline Excalidraw SVG has no naturalWidth; its width attribute is the scene size.
|
|
151
152
|
const known = Number(image.dataset.nw) || 0;
|
|
152
|
-
const pixels = image.naturalWidth || known;
|
|
153
|
+
const pixels = image.naturalWidth || known || Number(image.getAttribute("width")) || 0;
|
|
153
154
|
if (!pixels) return;
|
|
154
155
|
// width="" written by the build is the pixel size, not an author's choice.
|
|
155
156
|
const attrWidth = known ? Number(image.dataset.aw) || 0 : Number(image.getAttribute("width")) || 0;
|
package/app/styles/a2.css
CHANGED
|
@@ -720,6 +720,19 @@ html[data-theme="dark"] .markdown-body > p > img:not([class]) {
|
|
|
720
720
|
border-radius: 8px;
|
|
721
721
|
}
|
|
722
722
|
|
|
723
|
+
.markdown-body .fig-card > svg.excalidraw,
|
|
724
|
+
.markdown-body svg.excalidraw {
|
|
725
|
+
display: block;
|
|
726
|
+
width: auto;
|
|
727
|
+
max-width: 100%;
|
|
728
|
+
height: auto;
|
|
729
|
+
margin: 0 auto;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
.markdown-body svg.excalidraw text {
|
|
733
|
+
user-select: text;
|
|
734
|
+
}
|
|
735
|
+
|
|
723
736
|
.markdown-body .fig figcaption {
|
|
724
737
|
margin-top: 12px;
|
|
725
738
|
font-size: var(--fs-small);
|
|
@@ -23,4 +23,14 @@ order: 45
|
|
|
23
23
|
|
|
24
24
|
随想里的图片写在 Front Matter 的 `media` 中,多张拼成宫格,不使用这套图注。见[写文章与随想](/garden/writing)。
|
|
25
25
|
|
|
26
|
+
## Excalidraw
|
|
27
|
+
|
|
28
|
+
`.excalidraw` 和别的配图放在一起。正文仍用 Markdown 图片,地址指向源文件,例如 `./my-post/flow.excalidraw`。没有单独的 garden 组件。
|
|
29
|
+
|
|
30
|
+
`garden build`、`make dev` 和 `garden sync` 会找出这些图片。源文件有改动,或旁边的 `.svg` 缺失、过期时,先删掉旧的 `.svg`,再导出一张新的扁平 SVG,里面不嵌可再编辑的场景。上面的例子会得到同目录的 `flow.svg`。只这一张浅色 SVG:画布背景透明,场景里的线条和填色保持绘制时的样子,不另出深色文件,也不做 CSS 反色。页面把这张 SVG 嵌进正文,图里的文字可以选中复制;说明写在 SVG 的 `title` 里。文章脚本不会加载 Excalidraw。
|
|
31
|
+
|
|
32
|
+
导出程序是 [excalidraw-image](https://github.com/rickardp/excalidraw-image) `0.2.0`,走的是 Excalidraw 自己的 SVG 导出。第一次遇到 `.excalidraw` 时,构建从 GitHub Releases 下载对应系统的可执行文件(压缩包约 48MB)并缓存在本机。不需要安装 Chromium。字体写进 SVG,页面不请求 excalidraw.com。已经下载过,或用 `GARDEN_EXCALIDRAW_IMAGE` 指向本机程序时,离线构建(`GARDEN_OFFLINE` 设为 `1`)不会再次下载。
|
|
33
|
+
|
|
34
|
+
源文件缺失,或导出失败时,构建直接停止,并写明路径和原因。
|
|
35
|
+
|
|
26
36
|
这不是 `garden` 围栏,没有字段表。
|
|
@@ -23,4 +23,14 @@ The caption under this figure is its alt text:
|
|
|
23
23
|
|
|
24
24
|
Images on a thought go in the `media` list in Front Matter and form a grid, without this caption. See [Writing Posts & Thoughts](/garden/en/writing).
|
|
25
25
|
|
|
26
|
+
## Excalidraw
|
|
27
|
+
|
|
28
|
+
Put the `.excalidraw` file with the post's other images. Keep using a Markdown image, and point the address at the source file, for example `./my-post/flow.excalidraw`. There is no separate garden component for this.
|
|
29
|
+
|
|
30
|
+
`garden build`, `make dev`, and `garden sync` find those images. When the source has changed, or the sibling `.svg` is missing or out of date, the old `.svg` is deleted and a new flat SVG is exported, with no editable scene embedded. The example above produces `flow.svg` in the same directory. That is the only export: one light SVG with a transparent canvas. Strokes and fills stay as drawn. There is no dark file and no CSS invert. The page embeds that SVG in the article, so the text in the figure can be selected and copied. The alt text is the SVG `title`. The article never loads Excalidraw.
|
|
31
|
+
|
|
32
|
+
The exporter is [excalidraw-image](https://github.com/rickardp/excalidraw-image) `0.2.0`, which runs Excalidraw's own SVG export. The first `.excalidraw` reference downloads the matching executable from GitHub Releases (about 48 MB compressed) and caches it on the machine. Chromium is not required. Fonts are written into the SVG, so the page does not request excalidraw.com. After that download, or when `GARDEN_EXCALIDRAW_IMAGE` points at a local binary, an offline build (`GARDEN_OFFLINE` set to `1`) does not download again.
|
|
33
|
+
|
|
34
|
+
A missing source file or a failed export stops the build and reports the path and the reason.
|
|
35
|
+
|
|
26
36
|
This is not a `garden` fence, so it has no field table.
|
|
@@ -115,6 +115,7 @@ func openStore(path string) (*Store, error) {
|
|
|
115
115
|
These three are written in the Markdown body. Rendering rules live on the component pages. This section only records where the files go:
|
|
116
116
|
|
|
117
117
|
- **Local images**: For a post at `content/posts/my-post.md`, put images in `content/posts/my-post/` and reference them as `./my-post/diagram.svg`. Captions are documented on [Local Image](/garden/en/components/image).
|
|
118
|
+
- **Excalidraw**: Put the source with the other images and reference it with a Markdown image whose address ends in `.excalidraw`, for example `./my-post/flow.excalidraw`. The build writes one light `flow.svg` beside it, with a transparent background, and embeds that SVG in the article so its text can be selected. See [Local Image](/garden/en/components/image).
|
|
118
119
|
- **Source chips** and **link cards** stay in the prose. See [Source Chip](/garden/en/components/source) and [Link Card](/garden/en/components/link-card).
|
|
119
120
|
|
|
120
121
|
## 5. Math Formulas and Mermaid Diagrams
|
package/docs/handbook/writing.md
CHANGED
|
@@ -115,6 +115,7 @@ func openStore(path string) (*Store, error) {
|
|
|
115
115
|
这三项写在 Markdown 正文里。渲染规则在组件页,这里只保留文件放哪:
|
|
116
116
|
|
|
117
117
|
- **本地图片**:长文 `content/posts/my-post.md` 的配图放在同名目录 `content/posts/my-post/`,正文用 `./my-post/diagram.svg` 引用。图注见[本地图片](/garden/components/image)。
|
|
118
|
+
- **Excalidraw**:源文件和配图放在一起,用 Markdown 图片引用,地址以 `.excalidraw` 结尾,例如 `./my-post/flow.excalidraw`。构建生成旁边的一张浅色 `flow.svg`,画布背景透明,并把 SVG 嵌进正文,图里的文字可以选中。见[本地图片](/garden/components/image)。
|
|
118
119
|
- **来源胶囊**与**链接卡片**不另建文件。见[来源胶囊](/garden/components/source)和[链接卡片](/garden/components/link-card)。
|
|
119
120
|
|
|
120
121
|
## 5. 数学公式与 Mermaid 图表
|
package/package.json
CHANGED
|
@@ -19,6 +19,8 @@ import { buildFullIndex, buildSearchIndex } from "../ui/garden/search-index.mjs"
|
|
|
19
19
|
import { coverImageSet, loadSharp } from "./cover-images.mjs";
|
|
20
20
|
import { readFileSizeCache } from "./file-sizes.mjs";
|
|
21
21
|
import { previewDocument, previewFileName, renderFilePreview } from "./file-preview.mjs";
|
|
22
|
+
import { ensureExcalidrawSvg, isExcalidrawPath } from "./excalidraw.mjs";
|
|
23
|
+
import { embedExcalidrawImages, embedExcalidrawImgTags } from "../ui/garden/excalidraw-inline.mjs";
|
|
22
24
|
import { parseGardenBlock, parseGardenInfo, scanFences } from "../ui/garden/parse.mjs";
|
|
23
25
|
import { checkGardenMarkdown } from "../ui/garden/check.mjs";
|
|
24
26
|
import {
|
|
@@ -152,23 +154,40 @@ const assetCache = new Map();
|
|
|
152
154
|
/** Published URL → original file and its copy, for making resized covers later. */
|
|
153
155
|
const assetFiles = new Map();
|
|
154
156
|
|
|
155
|
-
function
|
|
156
|
-
if (!value || isExternalAsset(value)) return
|
|
157
|
-
|
|
157
|
+
function locateLocalAsset(value, markdownPath, sourcePath, options = {}) {
|
|
158
|
+
if (!value || isExternalAsset(value)) return null;
|
|
158
159
|
const baseDir = options.baseDir ?? path.dirname(markdownPath);
|
|
159
160
|
const allowedRoot = options.allowedRoot ?? contentRoot;
|
|
160
|
-
const mediaPrefix = options.mediaPrefix ?? "";
|
|
161
161
|
const [assetPath, suffix = ""] = value.split(/(?=[?#])/u, 2);
|
|
162
162
|
const decodedPath = decodeURIComponent(assetPath.replace(/^<|>$/g, ""));
|
|
163
163
|
const absoluteSource = path.resolve(baseDir, decodedPath);
|
|
164
164
|
const relativeSource = path.relative(allowedRoot, absoluteSource);
|
|
165
|
+
if (relativeSource.startsWith("..") || path.isAbsolute(relativeSource)) {
|
|
166
|
+
fail(sourcePath, options.missingMessage ?? `找不到本地媒体文件 ${value}`);
|
|
167
|
+
}
|
|
168
|
+
return { absoluteSource, relativeSource, decodedPath, suffix, mediaPrefix: options.mediaPrefix ?? "" };
|
|
169
|
+
}
|
|
165
170
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
/** Export the sibling flat SVG and return its text. The page inlines this; it is not an image URL. */
|
|
172
|
+
function readExportedSvg(value, markdownPath, sourcePath, options = {}) {
|
|
173
|
+
const located = locateLocalAsset(value, markdownPath, sourcePath, options);
|
|
174
|
+
if (!located || !isExcalidrawPath(located.decodedPath)) {
|
|
175
|
+
fail(sourcePath, `找不到 Excalidraw 源文件 ${value}`);
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const svgPath = ensureExcalidrawSvg(located.absoluteSource);
|
|
179
|
+
return fs.readFileSync(svgPath, "utf8");
|
|
180
|
+
} catch (error) {
|
|
181
|
+
fail(sourcePath, error instanceof Error ? error.message : String(error));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function copyLocalAsset(value, markdownPath, sourcePath, options = {}) {
|
|
186
|
+
const located = locateLocalAsset(value, markdownPath, sourcePath, options);
|
|
187
|
+
if (!located) return value;
|
|
188
|
+
const { absoluteSource, relativeSource, suffix, mediaPrefix } = located;
|
|
189
|
+
|
|
190
|
+
if (!fs.existsSync(absoluteSource) || !fs.statSync(absoluteSource).isFile()) {
|
|
172
191
|
fail(sourcePath, options.missingMessage ?? `找不到本地媒体文件 ${value}`);
|
|
173
192
|
}
|
|
174
193
|
|
|
@@ -194,15 +213,21 @@ function copyLocalAsset(value, markdownPath, sourcePath, options = {}) {
|
|
|
194
213
|
function rewriteMarkdownAssets(content, markdownPath, sourcePath, options = {}) {
|
|
195
214
|
// `.png>)` keeps its angle brackets: without them the space
|
|
196
215
|
// and the parentheses would no longer be one destination.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
)
|
|
216
|
+
// `.excalidraw` images stay in the source until the AST embed below, which
|
|
217
|
+
// swaps them for the inline SVG instead of an image URL.
|
|
218
|
+
const markdownImages = replaceInlineLinks(content, (link) => {
|
|
219
|
+
if (!link.image || isExcalidrawPath(link.url)) return undefined;
|
|
220
|
+
return formatInlineLink(link, copyLocalAsset(link.url, markdownPath, sourcePath, options));
|
|
221
|
+
});
|
|
200
222
|
|
|
201
|
-
|
|
223
|
+
const withAssets = markdownImages.replace(
|
|
202
224
|
/(<(?:img|audio|video|source)\b[^>]*\bsrc=["'])([^"']+)(["'])/giu,
|
|
203
|
-
(
|
|
204
|
-
`${prefix}${copyLocalAsset(src, markdownPath, sourcePath, options)}${suffix}`,
|
|
225
|
+
(tag, prefix, src, suffix) =>
|
|
226
|
+
isExcalidrawPath(src) ? tag : `${prefix}${copyLocalAsset(src, markdownPath, sourcePath, options)}${suffix}`,
|
|
205
227
|
);
|
|
228
|
+
|
|
229
|
+
const read = (url) => readExportedSvg(url, markdownPath, sourcePath, options);
|
|
230
|
+
return embedExcalidrawImgTags(embedExcalidrawImages(withAssets, read), read);
|
|
206
231
|
}
|
|
207
232
|
|
|
208
233
|
function markdownStem(markdownPath) {
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time export of `.excalidraw` scenes to a sibling flat SVG.
|
|
3
|
+
*
|
|
4
|
+
* Authors reference the source (``). Content
|
|
5
|
+
* sync calls `ensureExcalidrawSvg`, which deletes a missing or stale sibling
|
|
6
|
+
* `foo.svg` and writes a new one. The SVG is what gets published.
|
|
7
|
+
*
|
|
8
|
+
* Rendering goes through the pinned `excalidraw-image` binary (the official
|
|
9
|
+
* `@excalidraw/excalidraw` export path, fonts inlined as data URIs). One light
|
|
10
|
+
* SVG is written: `--no-background` drops the canvas plate, and scene strokes
|
|
11
|
+
* and fills stay as drawn. There is no dark twin and no Chromium step. The
|
|
12
|
+
* article bundle never loads Excalidraw. The first export downloads that
|
|
13
|
+
* binary from GitHub Releases into the user cache.
|
|
14
|
+
* `GARDEN_EXCALIDRAW_IMAGE` points at a local binary instead.
|
|
15
|
+
* `GARDEN_OFFLINE=1` refuses to download.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import crypto from "node:crypto";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
export const EXCALIDRAW_IMAGE_VERSION = "0.2.0";
|
|
24
|
+
|
|
25
|
+
const RELEASE_BASE = `https://github.com/rickardp/excalidraw-image/releases/download/v${EXCALIDRAW_IMAGE_VERSION}`;
|
|
26
|
+
|
|
27
|
+
/** @type {Record<string, { file: string, sha256: string }>} */
|
|
28
|
+
const ASSETS = {
|
|
29
|
+
"darwin-arm64": {
|
|
30
|
+
file: "excalidraw-image-aarch64-apple-darwin.tar.gz",
|
|
31
|
+
sha256: "387f7ef44a75528e4c6cca9f0af4bba34e017986f34581d983bb2a96afb5d9ba",
|
|
32
|
+
},
|
|
33
|
+
"darwin-x64": {
|
|
34
|
+
file: "excalidraw-image-x86_64-apple-darwin.tar.gz",
|
|
35
|
+
sha256: "bef1198f6237123ecc7f63c1fc6a456413e5f6cc91ae4c548dc26d661066fc21",
|
|
36
|
+
},
|
|
37
|
+
"linux-arm64": {
|
|
38
|
+
file: "excalidraw-image-aarch64-unknown-linux-gnu.tar.gz",
|
|
39
|
+
sha256: "c6b222e128965568e281af96edfa437393b2ddb4e3ba360ec2b9b583f273143e",
|
|
40
|
+
},
|
|
41
|
+
"linux-x64": {
|
|
42
|
+
file: "excalidraw-image-x86_64-unknown-linux-gnu.tar.gz",
|
|
43
|
+
sha256: "6a86f5188b55aef092d9cedda786565bfdb399bb58e57a1bea26335ed8f19ac6",
|
|
44
|
+
},
|
|
45
|
+
"win32-x64": {
|
|
46
|
+
file: "excalidraw-image-x86_64-pc-windows-msvc.zip",
|
|
47
|
+
sha256: "19b387b2322d3b52f8003d2e8bea9ab5693086c5886986f86b5ac6c36009aeef",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const REASONS = {
|
|
52
|
+
"missing-source": "找不到 Excalidraw 源文件",
|
|
53
|
+
"export-failed": "导出 Excalidraw 失败",
|
|
54
|
+
offline: "离线构建找不到 excalidraw-image",
|
|
55
|
+
"download-failed": "下载 excalidraw-image 失败",
|
|
56
|
+
checksum: "excalidraw-image 校验和不匹配",
|
|
57
|
+
"embedded-scene": "导出的 SVG 仍包含可编辑场景",
|
|
58
|
+
"remote-asset": "导出的 SVG 引用了外部地址",
|
|
59
|
+
"not-svg": "导出结果不是 SVG",
|
|
60
|
+
"canvas-background": "导出的 SVG 仍带画布底色",
|
|
61
|
+
"unsupported-platform": "当前系统没有 excalidraw-image 预编译包",
|
|
62
|
+
"binary-missing": "GARDEN_EXCALIDRAW_IMAGE 指向的文件不存在",
|
|
63
|
+
"no-output": "导出程序没有写出 SVG",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const EXPORT_POLICY = "light-transparent";
|
|
67
|
+
const STAMP = /<!--\s*garden-excalidraw-source:([0-9a-f]{64})(?:\s+([a-z0-9-]+))?\s*-->/u;
|
|
68
|
+
const LOCK_WAIT_MS = 5 * 60 * 1000;
|
|
69
|
+
|
|
70
|
+
export class ExcalidrawExportError extends Error {
|
|
71
|
+
/**
|
|
72
|
+
* @param {keyof typeof REASONS} code
|
|
73
|
+
* @param {string} message
|
|
74
|
+
* @param {string} file
|
|
75
|
+
*/
|
|
76
|
+
constructor(code, message, file) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.name = "ExcalidrawExportError";
|
|
79
|
+
this.code = code;
|
|
80
|
+
this.file = file;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {keyof typeof REASONS} code
|
|
86
|
+
* @param {string} file
|
|
87
|
+
* @param {string} [detail]
|
|
88
|
+
*/
|
|
89
|
+
function failExport(code, file, detail) {
|
|
90
|
+
const lead = REASONS[code];
|
|
91
|
+
if (!lead) {
|
|
92
|
+
const unknown = /** @type {never} */ (code);
|
|
93
|
+
throw new Error(`未知的 Excalidraw 错误码 ${unknown}`);
|
|
94
|
+
}
|
|
95
|
+
const tail = detail ? `(${detail})` : "";
|
|
96
|
+
return new ExcalidrawExportError(code, `[excalidraw:${code}] ${lead} ${file}${tail}`, file);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** sha256 of the `.excalidraw` bytes. Any edit changes the stamp. */
|
|
100
|
+
export function sourceHash(contents) {
|
|
101
|
+
return crypto.createHash("sha256").update(contents).digest("hex");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `flow.excalidraw` → `flow.svg` beside it. The extension match is case-insensitive. */
|
|
105
|
+
export function siblingSvgPath(excalidrawPath) {
|
|
106
|
+
return String(excalidrawPath).replace(/\.excalidraw$/iu, ".svg");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True for a Markdown path whose file is an `.excalidraw` source, not an `.excalidraw.svg`. */
|
|
110
|
+
export function isExcalidrawPath(value) {
|
|
111
|
+
const clean = String(value ?? "").split(/[?#]/u, 1)[0];
|
|
112
|
+
return /\.excalidraw$/iu.test(clean);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stampSvg(svg, hash) {
|
|
116
|
+
const stripped = svg.replace(/<!--\s*garden-excalidraw-source:[0-9a-f]{64}(?:\s+[a-z0-9-]+)?\s*-->/gu, "");
|
|
117
|
+
const at = stripped.search(/<svg[\s>]/iu);
|
|
118
|
+
if (at < 0) throw failExport("not-svg", "");
|
|
119
|
+
return `${stripped.slice(0, at)}<!-- garden-excalidraw-source:${hash} ${EXPORT_POLICY} -->${stripped.slice(at)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A full-viewport `<rect>` is the exporter's canvas plate, not a scene shape. */
|
|
123
|
+
function hasCanvasBackground(svg) {
|
|
124
|
+
const svgTag = /<svg\b[^>]*>/iu.exec(svg)?.[0] ?? "";
|
|
125
|
+
const attr = (tag, name) => new RegExp(`\\b${name}="([^"]*)"`, "iu").exec(tag)?.[1];
|
|
126
|
+
const width = Number(attr(svgTag, "width"));
|
|
127
|
+
const height = Number(attr(svgTag, "height"));
|
|
128
|
+
if (!(width > 0) || !(height > 0)) return false;
|
|
129
|
+
const same = (left, right) => Math.abs(left - right) < 0.01;
|
|
130
|
+
for (const match of svg.matchAll(/<rect\b[^>]*>/giu)) {
|
|
131
|
+
const tag = match[0];
|
|
132
|
+
const x = Number(attr(tag, "x") ?? 0);
|
|
133
|
+
const y = Number(attr(tag, "y") ?? 0);
|
|
134
|
+
const rectWidth = Number(attr(tag, "width"));
|
|
135
|
+
const rectHeight = Number(attr(tag, "height"));
|
|
136
|
+
if (same(x, 0) && same(y, 0) && same(rectWidth, width) && same(rectHeight, height)) return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A published figure must be a flat SVG: no embedded scene, no host the
|
|
143
|
+
* reader's browser would have to call.
|
|
144
|
+
* @param {string} svg
|
|
145
|
+
* @param {string} svgPath
|
|
146
|
+
*/
|
|
147
|
+
function assertFlatSvg(svg, svgPath) {
|
|
148
|
+
if (!/<svg[\s>]/iu.test(svg)) throw failExport("not-svg", svgPath);
|
|
149
|
+
if (/payload-start|application\/vnd\.excalidraw\+json/iu.test(svg)) {
|
|
150
|
+
throw failExport("embedded-scene", svgPath);
|
|
151
|
+
}
|
|
152
|
+
if (hasCanvasBackground(svg)) throw failExport("canvas-background", svgPath);
|
|
153
|
+
const urls = [...svg.matchAll(/https?:\/\/[^"')\s]+/giu)]
|
|
154
|
+
.map((match) => match[0])
|
|
155
|
+
.filter((url) => !/^https?:\/\/www\.w3\.org\//iu.test(url));
|
|
156
|
+
if (urls.length) throw failExport("remote-asset", svgPath, urls.slice(0, 3).join(" "));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function svgIsCurrent(svg, hash) {
|
|
160
|
+
const stamp = STAMP.exec(svg);
|
|
161
|
+
if (!stamp || stamp[1] !== hash || stamp[2] !== EXPORT_POLICY) return false;
|
|
162
|
+
try {
|
|
163
|
+
assertFlatSvg(svg, "");
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function platformKey() {
|
|
171
|
+
return `${process.platform}-${process.arch}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function platformAsset() {
|
|
175
|
+
const asset = ASSETS[platformKey()];
|
|
176
|
+
if (!asset) {
|
|
177
|
+
throw failExport(
|
|
178
|
+
"unsupported-platform",
|
|
179
|
+
`${process.platform} ${process.arch}`,
|
|
180
|
+
"设置 GARDEN_EXCALIDRAW_IMAGE 指向本机的 excalidraw-image",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return asset;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {{ homedir?: string }} [options]
|
|
188
|
+
*/
|
|
189
|
+
function cachePath(options = {}) {
|
|
190
|
+
const home = options.homedir ?? os.homedir();
|
|
191
|
+
const exe = process.platform === "win32" ? "excalidraw-image.exe" : "excalidraw-image";
|
|
192
|
+
return path.join(home, ".cache", "garden", "excalidraw-image", EXCALIDRAW_IMAGE_VERSION, platformKey(), exe);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function sleep(ms) {
|
|
196
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function withLock(lockDir, fn) {
|
|
200
|
+
fs.mkdirSync(path.dirname(lockDir), { recursive: true });
|
|
201
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
202
|
+
for (;;) {
|
|
203
|
+
try {
|
|
204
|
+
fs.mkdirSync(lockDir);
|
|
205
|
+
break;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error?.code !== "EEXIST") throw error;
|
|
208
|
+
let stale = false;
|
|
209
|
+
try {
|
|
210
|
+
stale = Date.now() - fs.statSync(lockDir).mtimeMs > LOCK_WAIT_MS;
|
|
211
|
+
} catch {
|
|
212
|
+
stale = true;
|
|
213
|
+
}
|
|
214
|
+
if (stale) {
|
|
215
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (Date.now() > deadline) throw failExport("download-failed", lockDir, "等待下载锁超时");
|
|
219
|
+
sleep(100);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
return fn();
|
|
224
|
+
} finally {
|
|
225
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function run(command, args, { timeout = 120_000 } = {}) {
|
|
230
|
+
return spawnSync(command, args, {
|
|
231
|
+
encoding: "utf8",
|
|
232
|
+
timeout,
|
|
233
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
234
|
+
windowsHide: true,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function commandFailure(result, fallback) {
|
|
239
|
+
if (result.error) return result.error.message;
|
|
240
|
+
return (result.stderr || result.stdout || fallback).trim();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function findExportedBinary(dir) {
|
|
244
|
+
const names = new Set(["excalidraw-image", "excalidraw-image.exe"]);
|
|
245
|
+
const found = [];
|
|
246
|
+
const stack = [dir];
|
|
247
|
+
while (stack.length) {
|
|
248
|
+
const current = stack.pop();
|
|
249
|
+
if (!current) break;
|
|
250
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
251
|
+
const full = path.join(current, entry.name);
|
|
252
|
+
if (entry.isDirectory()) stack.push(full);
|
|
253
|
+
else if (names.has(entry.name)) found.push(full);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (found.length !== 1) {
|
|
257
|
+
throw failExport("download-failed", dir, `压缩包里应有一个 excalidraw-image,实际 ${found.length} 个`);
|
|
258
|
+
}
|
|
259
|
+
return found[0];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param {{ homedir?: string, env?: NodeJS.ProcessEnv }} options
|
|
264
|
+
*/
|
|
265
|
+
function installBinary(options) {
|
|
266
|
+
const asset = platformAsset();
|
|
267
|
+
const cached = cachePath(options);
|
|
268
|
+
return withLock(`${cached}.lock`, () => {
|
|
269
|
+
if (fs.existsSync(cached)) return cached;
|
|
270
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), "garden-excalidraw-"));
|
|
271
|
+
try {
|
|
272
|
+
const archive = path.join(work, asset.file);
|
|
273
|
+
const url = `${RELEASE_BASE}/${asset.file}`;
|
|
274
|
+
console.error(`正在下载 excalidraw-image ${EXCALIDRAW_IMAGE_VERSION}(${asset.file})…`);
|
|
275
|
+
const curl = run("curl", ["-fsSL", "--retry", "3", "--retry-delay", "2", "-o", archive, url]);
|
|
276
|
+
if (curl.status !== 0) throw failExport("download-failed", url, commandFailure(curl, "curl 失败"));
|
|
277
|
+
const got = crypto.createHash("sha256").update(fs.readFileSync(archive)).digest("hex");
|
|
278
|
+
if (got !== asset.sha256) throw failExport("checksum", asset.file, `期望 ${asset.sha256},实际 ${got}`);
|
|
279
|
+
const unpacked = path.join(work, "unpacked");
|
|
280
|
+
fs.mkdirSync(unpacked, { recursive: true });
|
|
281
|
+
const extracted = run("tar", ["-xf", archive, "-C", unpacked]);
|
|
282
|
+
if (extracted.status !== 0) {
|
|
283
|
+
throw failExport("download-failed", archive, commandFailure(extracted, "tar 无法解压"));
|
|
284
|
+
}
|
|
285
|
+
const bin = findExportedBinary(unpacked);
|
|
286
|
+
fs.mkdirSync(path.dirname(cached), { recursive: true });
|
|
287
|
+
const partial = `${cached}.partial`;
|
|
288
|
+
fs.copyFileSync(bin, partial);
|
|
289
|
+
fs.chmodSync(partial, 0o755);
|
|
290
|
+
const version = run(partial, ["--version"]);
|
|
291
|
+
if (version.status !== 0 || !/excalidraw-image/u.test(version.stdout || "")) {
|
|
292
|
+
fs.rmSync(partial, { force: true });
|
|
293
|
+
throw failExport("download-failed", partial, commandFailure(version, "excalidraw-image --version 失败"));
|
|
294
|
+
}
|
|
295
|
+
fs.renameSync(partial, cached);
|
|
296
|
+
return cached;
|
|
297
|
+
} finally {
|
|
298
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @param {{ homedir?: string, env?: NodeJS.ProcessEnv }} [options]
|
|
305
|
+
*/
|
|
306
|
+
function resolveBinary(options = {}) {
|
|
307
|
+
const env = options.env ?? process.env;
|
|
308
|
+
const override = env.GARDEN_EXCALIDRAW_IMAGE?.trim();
|
|
309
|
+
if (override) {
|
|
310
|
+
if (!fs.existsSync(override)) throw failExport("binary-missing", override);
|
|
311
|
+
return override;
|
|
312
|
+
}
|
|
313
|
+
platformAsset();
|
|
314
|
+
const cached = cachePath(options);
|
|
315
|
+
if (fs.existsSync(cached)) return cached;
|
|
316
|
+
if (env.GARDEN_OFFLINE === "1") throw failExport("offline", cached);
|
|
317
|
+
return installBinary(options);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let defaultBinary = "";
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @param {string} input
|
|
324
|
+
* @param {string} output
|
|
325
|
+
* @param {{ homedir?: string, env?: NodeJS.ProcessEnv }} [options]
|
|
326
|
+
*/
|
|
327
|
+
function renderWithBinary(input, output, options = {}) {
|
|
328
|
+
const custom = options.env !== undefined || options.homedir !== undefined;
|
|
329
|
+
const bin = !custom && defaultBinary && fs.existsSync(defaultBinary) ? defaultBinary : resolveBinary(options);
|
|
330
|
+
if (!custom) defaultBinary = bin;
|
|
331
|
+
const result = run(bin, [input, "--non-editable", "--no-background", "--format", "svg", "-o", output]);
|
|
332
|
+
if (result.status !== 0) {
|
|
333
|
+
throw failExport("export-failed", input, commandFailure(result, `退出码 ${result.status}`).slice(0, 800));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Write a current flat SVG next to `sourcePath`.
|
|
339
|
+
* A sibling SVG is reused only when it carries this source's hash and is flat.
|
|
340
|
+
* Otherwise it is deleted, then exported again. Export failure leaves no SVG.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} sourcePath
|
|
343
|
+
* @param {{ render?: (input: string, output: string) => void, homedir?: string, env?: NodeJS.ProcessEnv }} [options]
|
|
344
|
+
* @returns {string} absolute path of the sibling SVG
|
|
345
|
+
*/
|
|
346
|
+
export function ensureExcalidrawSvg(sourcePath, options = {}) {
|
|
347
|
+
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) {
|
|
348
|
+
throw failExport("missing-source", sourcePath);
|
|
349
|
+
}
|
|
350
|
+
const svgPath = siblingSvgPath(sourcePath);
|
|
351
|
+
if (svgPath === sourcePath) throw failExport("not-svg", sourcePath, "无法把源文件路径换成 .svg");
|
|
352
|
+
const hash = sourceHash(fs.readFileSync(sourcePath));
|
|
353
|
+
if (fs.existsSync(svgPath)) {
|
|
354
|
+
const current = fs.readFileSync(svgPath, "utf8");
|
|
355
|
+
if (svgIsCurrent(current, hash)) return svgPath;
|
|
356
|
+
fs.rmSync(svgPath, { force: true });
|
|
357
|
+
}
|
|
358
|
+
const render = options.render ?? ((input, output) => renderWithBinary(input, output, options));
|
|
359
|
+
try {
|
|
360
|
+
render(sourcePath, svgPath);
|
|
361
|
+
if (!fs.existsSync(svgPath) || !fs.statSync(svgPath).isFile()) throw failExport("no-output", svgPath);
|
|
362
|
+
const raw = fs.readFileSync(svgPath, "utf8");
|
|
363
|
+
assertFlatSvg(raw, svgPath);
|
|
364
|
+
fs.writeFileSync(svgPath, stampSvg(raw, hash));
|
|
365
|
+
} catch (error) {
|
|
366
|
+
fs.rmSync(svgPath, { force: true });
|
|
367
|
+
if (error instanceof ExcalidrawExportError) throw error;
|
|
368
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
369
|
+
throw failExport("export-failed", sourcePath, reason);
|
|
370
|
+
}
|
|
371
|
+
return svgPath;
|
|
372
|
+
}
|
package/ui/core/transform.mjs
CHANGED
|
@@ -86,9 +86,17 @@ const absolute = (src, siteUrl) => (src && siteUrl && src.startsWith("/") ? `${s
|
|
|
86
86
|
const escapeHtml = (value) =>
|
|
87
87
|
String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
88
88
|
|
|
89
|
+
/** Text a reader can select inside an inlined Excalidraw SVG; path data and fonts stay out of search. */
|
|
90
|
+
function excalidrawVisibleText(svg) {
|
|
91
|
+
const parts = [];
|
|
92
|
+
for (const match of svg.matchAll(/<(?:text|title)\b[^>]*>([^<]*)<\/(?:text|title)>/giu)) parts.push(match[1]);
|
|
93
|
+
return parts.join(" ");
|
|
94
|
+
}
|
|
95
|
+
|
|
89
96
|
/** Readable text for search and word count: captions and snapshot text, never intent. */
|
|
90
97
|
export function contentToSearchText(content) {
|
|
91
|
-
|
|
98
|
+
const withoutDiagramMarkup = String(content ?? "").replace(/<svg\b[^>]*class="[^"]*\bexcalidraw\b[^"]*"[\s\S]*?<\/svg>/giu, (svg) => ` ${excalidrawVisibleText(svg)} `);
|
|
99
|
+
return replaceFences(stripCardMarkers(withoutDiagramMarkup), (fence) => {
|
|
92
100
|
if (fence.lang === "ui") {
|
|
93
101
|
const payload = readPayload(fence.body);
|
|
94
102
|
if (!payload) return " ";
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn an exported Excalidraw SVG into article HTML.
|
|
3
|
+
*
|
|
4
|
+
* The Markdown image stays the author's syntax until content sync. This walks
|
|
5
|
+
* the Markdown AST, swaps each `.excalidraw` image for the sanitized SVG, and
|
|
6
|
+
* leaves every other byte of the post alone. rehype-raw then parses that SVG
|
|
7
|
+
* into the article tree, so the text is part of the page rather than an
|
|
8
|
+
* `<img src>`.
|
|
9
|
+
*/
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
12
|
+
import { fromHtml } from "hast-util-from-html";
|
|
13
|
+
import { toHtml } from "hast-util-to-html";
|
|
14
|
+
import { visit } from "unist-util-visit";
|
|
15
|
+
import { figureLayout } from "./enhance.mjs";
|
|
16
|
+
import { mapOutsideCode } from "./markdown-links.mjs";
|
|
17
|
+
|
|
18
|
+
/** Same rule as scripts/excalidraw.mjs: a source file, not an `.excalidraw.svg`. */
|
|
19
|
+
function isExcalidrawPath(value) {
|
|
20
|
+
const clean = String(value ?? "").split(/[?#]/u, 1)[0];
|
|
21
|
+
return /\.excalidraw$/iu.test(clean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DROP_TAGS = new Set([
|
|
25
|
+
"script",
|
|
26
|
+
"noscript",
|
|
27
|
+
"iframe",
|
|
28
|
+
"object",
|
|
29
|
+
"embed",
|
|
30
|
+
"foreignobject",
|
|
31
|
+
"audio",
|
|
32
|
+
"video",
|
|
33
|
+
"canvas",
|
|
34
|
+
"link",
|
|
35
|
+
"meta",
|
|
36
|
+
"base",
|
|
37
|
+
"form",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const escapeHtml = (value) =>
|
|
41
|
+
String(value)
|
|
42
|
+
.replaceAll("&", "&")
|
|
43
|
+
.replaceAll("<", "<")
|
|
44
|
+
.replaceAll(">", ">")
|
|
45
|
+
.replaceAll('"', """);
|
|
46
|
+
|
|
47
|
+
function meaningfulChildren(node) {
|
|
48
|
+
return (node?.children ?? []).filter((child) => !(child.type === "text" && !String(child.value).trim()));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Same caption rule as article figures: title, otherwise alt, unless the alt is a placeholder. */
|
|
52
|
+
function imageCaption(image) {
|
|
53
|
+
const title = String(image.title ?? "").trim();
|
|
54
|
+
const alt = String(image.alt ?? "").trim();
|
|
55
|
+
if (title) return title;
|
|
56
|
+
if (alt && !/^image|^截图|^img/iu.test(alt)) return alt;
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function unsafeUrl(value) {
|
|
61
|
+
const url = String(value ?? "").trim().replace(/[\u0000-\u001f\s]+/gu, "");
|
|
62
|
+
if (!url || url.startsWith("#") || url.startsWith("/")) return false;
|
|
63
|
+
if (/^javascript:/iu.test(url)) return true;
|
|
64
|
+
if (/^https?:/iu.test(url)) return false;
|
|
65
|
+
if (/^data:image\/(?:png|gif|jpe?g|webp|svg\+xml);/iu.test(url)) return false;
|
|
66
|
+
if (/^data:font\//iu.test(url)) return false;
|
|
67
|
+
if (/^data:/iu.test(url)) return true;
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sanitizeNode(node) {
|
|
72
|
+
if (!node || node.type === "text" || node.type === "comment") return node;
|
|
73
|
+
if (node.type !== "element") return null;
|
|
74
|
+
if (DROP_TAGS.has(String(node.tagName).toLowerCase())) return null;
|
|
75
|
+
const properties = { ...(node.properties ?? {}) };
|
|
76
|
+
for (const key of Object.keys(properties)) {
|
|
77
|
+
const value = properties[key];
|
|
78
|
+
if (/^on/iu.test(key)) {
|
|
79
|
+
delete properties[key];
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (typeof value === "string" && /^(?:href|src|xlinkHref)$/iu.test(key) && unsafeUrl(value)) {
|
|
83
|
+
delete properties[key];
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (key === "style" && typeof value === "string" && /javascript:/iu.test(value)) delete properties[key];
|
|
87
|
+
}
|
|
88
|
+
node.properties = properties;
|
|
89
|
+
node.children = (node.children ?? []).map(sanitizeNode).filter(Boolean);
|
|
90
|
+
return node;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Sanitized SVG markup for the page. Alt or title becomes `<title>` and
|
|
95
|
+
* `aria-labelledby`. Scene `<text>` nodes stay.
|
|
96
|
+
* @param {string} svgText
|
|
97
|
+
* @param {{ alt?: string, title?: string }} [labels]
|
|
98
|
+
*/
|
|
99
|
+
export function inlineSvgDocument(svgText, { alt = "", title = "" } = {}) {
|
|
100
|
+
const stripped = String(svgText).replace(/<!--\s*garden-excalidraw-source:[^>]*-->/gu, "");
|
|
101
|
+
const root = fromHtml(stripped, { fragment: true });
|
|
102
|
+
const svg = root.children.find((node) => node.type === "element" && node.tagName === "svg");
|
|
103
|
+
if (!svg) throw new Error("导出结果不是 SVG");
|
|
104
|
+
sanitizeNode(svg);
|
|
105
|
+
const label = String(title || alt || "").trim();
|
|
106
|
+
if (label) {
|
|
107
|
+
const id = `garden-excalidraw-${crypto.createHash("sha256").update(`${label}\0${stripped}`).digest("hex").slice(0, 10)}`;
|
|
108
|
+
svg.properties.ariaLabelledby = id;
|
|
109
|
+
svg.children.unshift({
|
|
110
|
+
type: "element",
|
|
111
|
+
tagName: "title",
|
|
112
|
+
properties: { id },
|
|
113
|
+
children: [{ type: "text", value: label }],
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const classes = Array.isArray(svg.properties.className)
|
|
117
|
+
? svg.properties.className
|
|
118
|
+
: svg.properties.className
|
|
119
|
+
? [svg.properties.className]
|
|
120
|
+
: [];
|
|
121
|
+
if (!classes.includes("excalidraw")) classes.push("excalidraw");
|
|
122
|
+
svg.properties.className = classes;
|
|
123
|
+
const width = Number(svg.properties.width) || 0;
|
|
124
|
+
const height = Number(svg.properties.height) || 0;
|
|
125
|
+
if (width > 0) svg.properties.dataNw = String(width);
|
|
126
|
+
if (width > 0) svg.properties.dataAw = String(width);
|
|
127
|
+
return { html: toHtml(svg), width, height };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function figureHtml(svgHtml, caption, width) {
|
|
131
|
+
const side = figureLayout({ naturalWidth: width }).side;
|
|
132
|
+
const classes = side > 0 ? "fig fig-wide" : "fig";
|
|
133
|
+
const style = side > 0 ? ` style="--wl:${side}px;--wr:${side}px"` : "";
|
|
134
|
+
const cap = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "";
|
|
135
|
+
return `<figure class="${classes}"${style}><div class="fig-card">${svgHtml}</div>${cap}</figure>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Replace `.excalidraw` Markdown images with inline SVG HTML.
|
|
140
|
+
* `readSvg(url)` returns the exported SVG text or throws.
|
|
141
|
+
* @param {string} markdown
|
|
142
|
+
* @param {(url: string) => string} readSvg
|
|
143
|
+
*/
|
|
144
|
+
export function embedExcalidrawImages(markdown, readSvg) {
|
|
145
|
+
const source = String(markdown ?? "");
|
|
146
|
+
const tree = fromMarkdown(source);
|
|
147
|
+
const edits = [];
|
|
148
|
+
visit(tree, "image", (node, _index, parent) => {
|
|
149
|
+
const url = String(node.url ?? "");
|
|
150
|
+
if (!isExcalidrawPath(url)) return;
|
|
151
|
+
const start = node.position?.start?.offset;
|
|
152
|
+
const end = node.position?.end?.offset;
|
|
153
|
+
if (start == null || end == null) return;
|
|
154
|
+
const { html, width } = inlineSvgDocument(readSvg(url), { alt: node.alt, title: node.title });
|
|
155
|
+
const alone = parent?.type === "paragraph" && meaningfulChildren(parent).length === 1 && meaningfulChildren(parent)[0] === node;
|
|
156
|
+
const caption = imageCaption(node);
|
|
157
|
+
edits.push(alone && parent.position?.start?.offset != null && parent.position?.end?.offset != null
|
|
158
|
+
? { start: parent.position.start.offset, end: parent.position.end.offset, replacement: figureHtml(html, caption, width) }
|
|
159
|
+
: { start, end, replacement: html });
|
|
160
|
+
});
|
|
161
|
+
edits.sort((left, right) => right.start - left.start);
|
|
162
|
+
let output = source;
|
|
163
|
+
for (const edit of edits) output = `${output.slice(0, edit.start)}${edit.replacement}${output.slice(edit.end)}`;
|
|
164
|
+
return output;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Raw `<img src="….excalidraw">` outside code becomes the same inline SVG. */
|
|
168
|
+
export function embedExcalidrawImgTags(markdown, readSvg) {
|
|
169
|
+
return mapOutsideCode(markdown, (text) => text.replace(/<img\b[^>]*>/giu, (tag) => {
|
|
170
|
+
const src = /\bsrc\s*=\s*["']([^"']+)["']/iu.exec(tag)?.[1];
|
|
171
|
+
if (!src || !isExcalidrawPath(src)) return tag;
|
|
172
|
+
const alt = /\balt\s*=\s*["']([^"']*)["']/iu.exec(tag)?.[1] ?? "";
|
|
173
|
+
return inlineSvgDocument(readSvg(src), { alt }).html;
|
|
174
|
+
}));
|
|
175
|
+
}
|