hd-garden 0.14.12 → 0.14.13
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/Makefile +1 -1
- package/app/api/admin/distribution/route.ts +4 -2
- package/app/blog/[...path]/page.tsx +4 -2
- package/app/columns/[column]/[...path]/page.tsx +4 -2
- package/app/components/GardenMediaGuide.tsx +11 -23
- package/app/components/GardenPlayer.tsx +25 -0
- package/app/components/HandbookView.tsx +5 -3
- package/app/components/MarkdownArticle.tsx +7 -2
- package/app/components/PostReadingView.tsx +3 -3
- package/app/garden-modules.d.ts +10 -2
- package/app/globals.css +58 -1
- package/app/lib/content-body.ts +20 -0
- package/app/lib/garden-docs-meta.ts +2 -0
- package/app/lib/markdown-plugins.ts +5 -0
- package/app/lib/remark-video-player.d.mts +10 -0
- package/app/lib/remark-video-player.mjs +70 -0
- package/app/rss.xml/route.ts +3 -2
- package/bin/garden.mjs +18 -78
- package/docs/handbook/components/compose.md +3 -1
- package/docs/handbook/components/diagrams/chart.md +38 -0
- package/docs/handbook/components/diagrams/code-walkthrough.md +38 -0
- package/docs/handbook/components/diagrams/columns-join.md +38 -0
- package/docs/handbook/components/diagrams/compare.md +38 -0
- package/docs/handbook/components/diagrams/converter.md +38 -0
- package/docs/handbook/components/diagrams/facets.md +38 -0
- package/docs/handbook/components/diagrams/fan-out.md +38 -0
- package/docs/handbook/components/diagrams/icon-rail.md +38 -0
- package/docs/handbook/components/diagrams/inspect-grid.md +38 -0
- package/docs/handbook/components/diagrams/lanes.md +38 -0
- package/docs/handbook/components/diagrams/layers.md +38 -0
- package/docs/handbook/components/diagrams/other.md +37 -0
- package/docs/handbook/components/diagrams/plan.md +38 -0
- package/docs/handbook/components/diagrams/previews/chart.svg +7 -0
- package/docs/handbook/components/diagrams/previews/code-walkthrough.svg +5 -0
- package/docs/handbook/components/diagrams/previews/columns-join.svg +6 -0
- package/docs/handbook/components/diagrams/previews/compare.svg +5 -0
- package/docs/handbook/components/diagrams/previews/converter.svg +6 -0
- package/docs/handbook/components/diagrams/previews/facets.svg +6 -0
- package/docs/handbook/components/diagrams/previews/fan-out.svg +8 -0
- package/docs/handbook/components/diagrams/previews/icon-rail.svg +5 -0
- package/docs/handbook/components/diagrams/previews/inspect-grid.svg +5 -0
- package/docs/handbook/components/diagrams/previews/lanes.svg +5 -0
- package/docs/handbook/components/diagrams/previews/layers.svg +6 -0
- package/docs/handbook/components/diagrams/previews/other.svg +6 -0
- package/docs/handbook/components/diagrams/previews/plan.svg +5 -0
- package/docs/handbook/components/diagrams/previews/sequence.svg +7 -0
- package/docs/handbook/components/diagrams/previews/stepper.svg +5 -0
- package/docs/handbook/components/diagrams/previews/steps.svg +7 -0
- package/docs/handbook/components/diagrams/previews/tabs.svg +6 -0
- package/docs/handbook/components/diagrams/previews/timeline.svg +5 -0
- package/docs/handbook/components/diagrams/previews/trace.svg +7 -0
- package/docs/handbook/components/diagrams/previews/tunable.svg +4 -0
- package/docs/handbook/components/diagrams/previews/two-row.svg +4 -0
- package/docs/handbook/components/diagrams/sequence.md +38 -0
- package/docs/handbook/components/diagrams/stepper.md +38 -0
- package/docs/handbook/components/diagrams/steps.md +38 -0
- package/docs/handbook/components/diagrams/tabs.md +38 -0
- package/docs/handbook/components/diagrams/timeline.md +38 -0
- package/docs/handbook/components/diagrams/trace.md +38 -0
- package/docs/handbook/components/diagrams/tunable.md +38 -0
- package/docs/handbook/components/diagrams/two-row.md +38 -0
- package/docs/handbook/components/diagrams.md +100 -0
- package/docs/handbook/components/link-card.md +1 -1
- package/docs/handbook/components.md +1 -0
- package/docs/handbook/en/components/compose.md +3 -1
- package/docs/handbook/en/components/diagrams/chart.md +40 -0
- package/docs/handbook/en/components/diagrams/code-walkthrough.md +40 -0
- package/docs/handbook/en/components/diagrams/columns-join.md +40 -0
- package/docs/handbook/en/components/diagrams/compare.md +40 -0
- package/docs/handbook/en/components/diagrams/converter.md +40 -0
- package/docs/handbook/en/components/diagrams/facets.md +40 -0
- package/docs/handbook/en/components/diagrams/fan-out.md +40 -0
- package/docs/handbook/en/components/diagrams/icon-rail.md +40 -0
- package/docs/handbook/en/components/diagrams/inspect-grid.md +40 -0
- package/docs/handbook/en/components/diagrams/lanes.md +40 -0
- package/docs/handbook/en/components/diagrams/layers.md +40 -0
- package/docs/handbook/en/components/diagrams/other.md +39 -0
- package/docs/handbook/en/components/diagrams/plan.md +40 -0
- package/docs/handbook/en/components/diagrams/sequence.md +40 -0
- package/docs/handbook/en/components/diagrams/stepper.md +40 -0
- package/docs/handbook/en/components/diagrams/steps.md +40 -0
- package/docs/handbook/en/components/diagrams/tabs.md +40 -0
- package/docs/handbook/en/components/diagrams/timeline.md +40 -0
- package/docs/handbook/en/components/diagrams/trace.md +40 -0
- package/docs/handbook/en/components/diagrams/tunable.md +40 -0
- package/docs/handbook/en/components/diagrams/two-row.md +40 -0
- package/docs/handbook/en/components/diagrams.md +100 -0
- package/docs/handbook/en/components/link-card.md +1 -1
- package/docs/handbook/en/components.md +1 -0
- package/docs/handbook/en/start.md +1 -1
- package/docs/handbook/en/workflow.md +2 -2
- package/docs/handbook/en/writing.md +82 -1
- package/docs/handbook/start.md +1 -1
- package/docs/handbook/workflow.md +2 -2
- package/docs/handbook/writing.md +82 -1
- package/editors/dist/{garden-typora-0.14.12.zip → garden-typora-0.14.13.zip} +0 -0
- package/editors/dist/typora/main.js +175 -175
- package/editors/dist/typora/manifest.json +1 -1
- package/editors/dist/typora/style.css +44 -6
- package/editors/dist/vscode/{garden-ui-0.14.12.vsix → garden-ui-0.14.13.vsix} +0 -0
- package/package.json +1 -1
- package/scripts/build-content.mjs +23 -8
- package/scripts/dev-server.mjs +30 -2
- package/scripts/fetch-embed.mjs +39 -27
- package/scripts/handbook-diagrams.mjs +434 -0
- package/scripts/link-assets.mjs +6 -0
- package/scripts/vinext-runtime.mjs +80 -0
- package/templates/site/Makefile +1 -1
- package/templates/site/Makefile.en +1 -1
- package/ui/compose/compose.css +43 -5
- package/ui/garden/markdown-links.mjs +8 -5
- package/ui/garden/picker-placement.mjs +106 -2
- package/ui/garden/player.mjs +286 -0
- package/ui/garden/rehype-keep-punctuation.mjs +87 -18
- package/ui/preview/dist/garden-ui.css +43 -5
- package/ui/preview/dist/garden-ui.esm.js +4 -4
- package/ui/preview/dist/garden-ui.iife.js +4 -4
- package/vite.config.ts +84 -23
|
@@ -56,6 +56,85 @@ export function viewportRect(win = globalThis) {
|
|
|
56
56
|
return rect(0, 0, width, height);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Typora chrome that can cover a position:fixed #ty-auto-suggest (z-index 10).
|
|
61
|
+
* `header` is z-index 900; mac-seamless `titlebar` sits at the top of the same
|
|
62
|
+
* document; the editor `content` box is already below that chrome.
|
|
63
|
+
*/
|
|
64
|
+
export const TYPORA_CHROME_SELECTORS = [
|
|
65
|
+
"header",
|
|
66
|
+
"titlebar",
|
|
67
|
+
"#top-titlebar",
|
|
68
|
+
"footer.ty-footer",
|
|
69
|
+
"#md-searchpanel",
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export function intersectRects(a, b) {
|
|
73
|
+
const left = Math.max(a.left, b.left);
|
|
74
|
+
const top = Math.max(a.top, b.top);
|
|
75
|
+
const right = Math.min(a.right, b.right);
|
|
76
|
+
const bottom = Math.min(a.bottom, b.bottom);
|
|
77
|
+
return rect(left, top, Math.max(0, right - left), Math.max(0, bottom - top));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function bandOverlapsAxis(box, chrome, axis) {
|
|
81
|
+
if (axis === "horizontal") {
|
|
82
|
+
return Math.min(box.right, chrome.right) - Math.max(box.left, chrome.left) > box.width * 0.5;
|
|
83
|
+
}
|
|
84
|
+
return Math.min(box.bottom, chrome.bottom) - Math.max(box.top, chrome.top) > box.height * 0.5;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Shrink a visible box by a chrome rect that covers a top, bottom, or side band. */
|
|
88
|
+
export function subtractOverlappingBand(box, chrome) {
|
|
89
|
+
if (!box || !chrome || chrome.width <= 0 || chrome.height <= 0) return box;
|
|
90
|
+
let next = box;
|
|
91
|
+
if (bandOverlapsAxis(next, chrome, "horizontal") && chrome.top <= next.top + 1 && chrome.bottom > next.top && chrome.bottom < next.bottom) {
|
|
92
|
+
next = rect(next.left, chrome.bottom, next.width, next.bottom - chrome.bottom);
|
|
93
|
+
}
|
|
94
|
+
if (bandOverlapsAxis(next, chrome, "horizontal") && chrome.bottom >= next.bottom - 1 && chrome.top < next.bottom && chrome.top > next.top) {
|
|
95
|
+
next = rect(next.left, next.top, next.width, chrome.top - next.top);
|
|
96
|
+
}
|
|
97
|
+
if (bandOverlapsAxis(next, chrome, "vertical") && chrome.left <= next.left + 1 && chrome.right > next.left && chrome.right < next.right) {
|
|
98
|
+
next = rect(chrome.right, next.top, next.right - chrome.right, next.height);
|
|
99
|
+
}
|
|
100
|
+
if (bandOverlapsAxis(next, chrome, "vertical") && chrome.right >= next.right - 1 && chrome.left < next.right && chrome.left > next.left) {
|
|
101
|
+
next = rect(next.left, next.top, chrome.left - next.left, next.height);
|
|
102
|
+
}
|
|
103
|
+
return next;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isDisplayed(el, win) {
|
|
107
|
+
if (!el) return false;
|
|
108
|
+
const style = win?.getComputedStyle?.(el);
|
|
109
|
+
if (style && (style.display === "none" || style.visibility === "hidden")) return false;
|
|
110
|
+
const r = el.getBoundingClientRect?.();
|
|
111
|
+
return Boolean(r && (r.width > 0 || r.height > 0));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Visible editor box in the same client coordinates as position:fixed.
|
|
116
|
+
* Typora is one document: #ty-auto-suggest is inside <content> but is
|
|
117
|
+
* position:fixed to the window, so window (0,0) includes the titlebar/tabs
|
|
118
|
+
* that paint over the menu (z-index 900 vs 10).
|
|
119
|
+
*/
|
|
120
|
+
export function editorVisibleRect(win = globalThis, doc = win.document) {
|
|
121
|
+
const viewport = viewportRect(win);
|
|
122
|
+
if (!doc?.querySelector) return viewport;
|
|
123
|
+
let box = viewport;
|
|
124
|
+
const content = doc.querySelector("content");
|
|
125
|
+
if (content && isDisplayed(content, win)) {
|
|
126
|
+
const cr = copyRect(content.getBoundingClientRect());
|
|
127
|
+
if (cr.width > 0 && cr.height > 0) box = intersectRects(box, cr);
|
|
128
|
+
}
|
|
129
|
+
for (const selector of TYPORA_CHROME_SELECTORS) {
|
|
130
|
+
const el = doc.querySelector(selector);
|
|
131
|
+
if (!isDisplayed(el, win)) continue;
|
|
132
|
+
box = subtractOverlappingBand(box, copyRect(el.getBoundingClientRect()));
|
|
133
|
+
}
|
|
134
|
+
if (box.width < 32 || box.height < 32) return viewport;
|
|
135
|
+
return box;
|
|
136
|
+
}
|
|
137
|
+
|
|
59
138
|
export function caretClientRect(win = globalThis) {
|
|
60
139
|
try {
|
|
61
140
|
const selection = win.getSelection?.();
|
|
@@ -113,6 +192,7 @@ export function bindGardenPickerPlacement({
|
|
|
113
192
|
let destroyed = false;
|
|
114
193
|
const teardowns = [];
|
|
115
194
|
let last = null;
|
|
195
|
+
let restoreStyle = null;
|
|
116
196
|
const watchedImages = new WeakSet();
|
|
117
197
|
|
|
118
198
|
const handle = { apply, destroy };
|
|
@@ -122,13 +202,27 @@ export function bindGardenPickerPlacement({
|
|
|
122
202
|
return measureMenuIntrinsicSize(container);
|
|
123
203
|
}
|
|
124
204
|
|
|
205
|
+
function ensureMenuScroll(style) {
|
|
206
|
+
if (!style) return;
|
|
207
|
+
if (!restoreStyle) {
|
|
208
|
+
restoreStyle = {
|
|
209
|
+
overflowX: style.overflowX,
|
|
210
|
+
overflowY: style.overflowY,
|
|
211
|
+
overscrollBehavior: style.overscrollBehavior,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
style.overflowX = "hidden";
|
|
215
|
+
style.overflowY = "auto";
|
|
216
|
+
style.overscrollBehavior = "contain";
|
|
217
|
+
}
|
|
218
|
+
|
|
125
219
|
function apply() {
|
|
126
220
|
if (destroyed || applying) return last;
|
|
127
221
|
if (!container.classList?.contains?.("ty-show") || !isGardenMenu(container)) {
|
|
128
222
|
destroy();
|
|
129
223
|
return null;
|
|
130
224
|
}
|
|
131
|
-
const viewport = (getViewport ||
|
|
225
|
+
const viewport = (getViewport || editorVisibleRect)(win);
|
|
132
226
|
const anchor = getAnchorRect?.() || caretClientRect(win);
|
|
133
227
|
if (!anchor || !viewport?.width || !viewport?.height) return last;
|
|
134
228
|
|
|
@@ -138,11 +232,12 @@ export function bindGardenPickerPlacement({
|
|
|
138
232
|
const menu = measure();
|
|
139
233
|
const preferredLeft = readPreferredLeft(container, anchor);
|
|
140
234
|
const placed = placePickerMenu({ anchor: copyRect(anchor), viewport, menu, preferredLeft });
|
|
235
|
+
const style = container.style;
|
|
141
236
|
if (samePlacement(last, placed)) {
|
|
237
|
+
ensureMenuScroll(style);
|
|
142
238
|
scrollActive(container);
|
|
143
239
|
return last;
|
|
144
240
|
}
|
|
145
|
-
const style = container.style;
|
|
146
241
|
style.top = `${placed.top}px`;
|
|
147
242
|
style.left = `${placed.left}px`;
|
|
148
243
|
style.right = "auto";
|
|
@@ -151,6 +246,7 @@ export function bindGardenPickerPlacement({
|
|
|
151
246
|
style.minWidth = `${placed.width}px`;
|
|
152
247
|
style.width = `${placed.width}px`;
|
|
153
248
|
style.maxWidth = `${placed.width}px`;
|
|
249
|
+
ensureMenuScroll(style);
|
|
154
250
|
container.classList.add(GARDEN_PICKER_CLASS);
|
|
155
251
|
container.dataset.gardenPlacement = placed.placement;
|
|
156
252
|
last = placed;
|
|
@@ -226,7 +322,15 @@ export function bindGardenPickerPlacement({
|
|
|
226
322
|
}
|
|
227
323
|
container.classList?.remove?.(GARDEN_PICKER_CLASS);
|
|
228
324
|
if (container.dataset) delete container.dataset.gardenPlacement;
|
|
325
|
+
const style = container.style;
|
|
326
|
+
const restore = restoreStyle;
|
|
327
|
+
if (style && restore) {
|
|
328
|
+
style.overflowX = restore.overflowX ?? "";
|
|
329
|
+
style.overflowY = restore.overflowY ?? "";
|
|
330
|
+
style.overscrollBehavior = restore.overscrollBehavior ?? "";
|
|
331
|
+
}
|
|
229
332
|
if (bound.get(container) === handle) bound.delete(container);
|
|
333
|
+
restoreStyle = null;
|
|
230
334
|
last = null;
|
|
231
335
|
}
|
|
232
336
|
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-article YouTube / Bilibili players.
|
|
3
|
+
*
|
|
4
|
+
* Authors paste a platform video URL on its own line. Only exact hostnames and
|
|
5
|
+
* recognized video addresses become a player; lookalike domains, in-sentence
|
|
6
|
+
* links, code, and an explicit `{card}` stay ordinary links or cards.
|
|
7
|
+
* Short links that need a network hop are not resolved at render time.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const YOUTUBE_ID = /^[0-9A-Za-z_-]{11}$/u;
|
|
11
|
+
const BVID = /^BV[0-9A-Za-z]{10}$/u;
|
|
12
|
+
const AID = /^\d{1,16}$/u;
|
|
13
|
+
const MAX_START = 24 * 60 * 60;
|
|
14
|
+
|
|
15
|
+
/** @typedef {{ ok: true, provider: "youtube" | "bilibili", pageUrl: string, playerUrl: string, label: string }} PlayerSuccess */
|
|
16
|
+
/** @typedef {{ ok: false, reason: string }} PlayerFailure */
|
|
17
|
+
/** @typedef {PlayerSuccess | PlayerFailure} PlayerResult */
|
|
18
|
+
|
|
19
|
+
export const PLAYER_IFRAME_ORIGINS = Object.freeze([
|
|
20
|
+
"https://www.youtube-nocookie.com",
|
|
21
|
+
"https://player.bilibili.com",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} hostname
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function normalizedHostname(hostname) {
|
|
29
|
+
return String(hostname ?? "").trim().replace(/\.$/u, "").toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Exact host match, plus an optional `www.` prefix. Never a substring test.
|
|
34
|
+
* @param {string} hostname
|
|
35
|
+
* @param {...string} allowed
|
|
36
|
+
*/
|
|
37
|
+
export function isExactHost(hostname, ...allowed) {
|
|
38
|
+
const host = normalizedHostname(hostname);
|
|
39
|
+
return allowed.some((name) => host === name || host === `www.${name}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isYoutubeHost(host) {
|
|
43
|
+
return isExactHost(host, "youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be", "youtube-nocookie.com");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isBilibiliHost(host) {
|
|
47
|
+
const name = normalizedHostname(host);
|
|
48
|
+
return name === "bilibili.com" || name.endsWith(".bilibili.com");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Bilibili short hosts such as b23.tv need a network redirect to know the
|
|
53
|
+
* video id. Render time does not follow them; the CLI may expand them.
|
|
54
|
+
* @param {string} hostname
|
|
55
|
+
*/
|
|
56
|
+
export function isUnsupportedVideoShortHost(hostname) {
|
|
57
|
+
return isExactHost(hostname, "b23.tv", "bili22.cn", "bili2233.cn", "bili23.cn", "bili33.cn");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fail(reason) {
|
|
61
|
+
return { ok: false, reason };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {unknown} raw
|
|
66
|
+
* @returns {URL | null}
|
|
67
|
+
*/
|
|
68
|
+
export function readHttpUrl(raw) {
|
|
69
|
+
const text = String(raw ?? "").trim();
|
|
70
|
+
if (!text) return null;
|
|
71
|
+
let url;
|
|
72
|
+
try {
|
|
73
|
+
url = new URL(text);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (!["http:", "https:"].includes(url.protocol)) return null;
|
|
78
|
+
if (url.username || url.password) return null;
|
|
79
|
+
if (!url.hostname) return null;
|
|
80
|
+
return url;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function youtubeIdFrom(url) {
|
|
84
|
+
const host = normalizedHostname(url.hostname);
|
|
85
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
86
|
+
if (isExactHost(host, "youtu.be")) {
|
|
87
|
+
return parts.length === 1 ? parts[0] : undefined;
|
|
88
|
+
}
|
|
89
|
+
if (url.searchParams.get("v")) return url.searchParams.get("v") ?? undefined;
|
|
90
|
+
if (parts[0] === "watch" && parts.length === 1) return undefined;
|
|
91
|
+
if (parts.length === 2 && ["embed", "shorts", "live", "v"].includes(parts[0])) return parts[1];
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseClock(value) {
|
|
96
|
+
const text = String(value ?? "").trim();
|
|
97
|
+
if (!text) return undefined;
|
|
98
|
+
if (/^\d+$/u.test(text)) {
|
|
99
|
+
const seconds = Number(text);
|
|
100
|
+
return Number.isInteger(seconds) && seconds > 0 && seconds <= MAX_START ? seconds : undefined;
|
|
101
|
+
}
|
|
102
|
+
const match = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/iu.exec(text);
|
|
103
|
+
if (!match || match[0] === "") return undefined;
|
|
104
|
+
const hours = Number(match[1] ?? 0);
|
|
105
|
+
const minutes = Number(match[2] ?? 0);
|
|
106
|
+
const seconds = Number(match[3] ?? 0);
|
|
107
|
+
const total = hours * 3600 + minutes * 60 + seconds;
|
|
108
|
+
return Number.isInteger(total) && total > 0 && total <= MAX_START ? total : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function youtubeStart(url) {
|
|
112
|
+
return parseClock(url.searchParams.get("start")) ?? parseClock(url.searchParams.get("t")) ?? parseClock(url.hash.replace(/^#t=/u, ""));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function youtubePageUrl(id, start) {
|
|
116
|
+
const url = new URL(`https://www.youtube.com/watch?v=${id}`);
|
|
117
|
+
if (start) url.searchParams.set("t", String(start));
|
|
118
|
+
return url.toString();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function youtubePlayerUrl(id, start) {
|
|
122
|
+
const url = new URL(`https://www.youtube-nocookie.com/embed/${id}`);
|
|
123
|
+
if (start) url.searchParams.set("start", String(start));
|
|
124
|
+
return url.toString();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function bilibiliIdFrom(url) {
|
|
128
|
+
const host = normalizedHostname(url.hostname);
|
|
129
|
+
if (isExactHost(host, "player.bilibili.com")) {
|
|
130
|
+
const bvid = url.searchParams.get("bvid");
|
|
131
|
+
const aid = url.searchParams.get("aid");
|
|
132
|
+
if (bvid && BVID.test(normalizeBvid(bvid))) return { key: "bvid", value: normalizeBvid(bvid) };
|
|
133
|
+
if (aid && AID.test(aid)) return { key: "aid", value: aid };
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
const match = url.pathname.match(/\/video\/(BV[0-9A-Za-z]{10}|av\d{1,16}|AV\d{1,16})\b/u);
|
|
137
|
+
if (!match) return undefined;
|
|
138
|
+
const token = match[1];
|
|
139
|
+
if (BVID.test(normalizeBvid(token))) return { key: "bvid", value: normalizeBvid(token) };
|
|
140
|
+
const aid = token.replace(/^av/iu, "");
|
|
141
|
+
if (AID.test(aid)) return { key: "aid", value: aid };
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeBvid(value) {
|
|
146
|
+
const text = String(value ?? "");
|
|
147
|
+
return text.length >= 2 ? `BV${text.slice(2)}` : text;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function bilibiliPageUrl(id) {
|
|
151
|
+
return id.key === "bvid"
|
|
152
|
+
? `https://www.bilibili.com/video/${id.value}`
|
|
153
|
+
: `https://www.bilibili.com/video/av${id.value}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function bilibiliPlayerUrl(id) {
|
|
157
|
+
const url = new URL("https://player.bilibili.com/player.html");
|
|
158
|
+
url.searchParams.set(id.key, id.value);
|
|
159
|
+
url.searchParams.set("high_quality", "1");
|
|
160
|
+
url.searchParams.set("danmaku", "0");
|
|
161
|
+
url.searchParams.set("autoplay", "0");
|
|
162
|
+
return url.toString();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {unknown} raw
|
|
167
|
+
* @returns {PlayerResult}
|
|
168
|
+
*/
|
|
169
|
+
export function resolvePlayer(raw) {
|
|
170
|
+
const url = readHttpUrl(raw);
|
|
171
|
+
if (!url) return fail("not-http-url");
|
|
172
|
+
const host = url.hostname;
|
|
173
|
+
if (isUnsupportedVideoShortHost(host)) return fail("short-link");
|
|
174
|
+
if (isYoutubeHost(host)) {
|
|
175
|
+
const id = youtubeIdFrom(url);
|
|
176
|
+
if (!id || !YOUTUBE_ID.test(id)) return fail("unsupported-youtube-url");
|
|
177
|
+
const start = youtubeStart(url);
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
provider: "youtube",
|
|
181
|
+
pageUrl: youtubePageUrl(id, start),
|
|
182
|
+
playerUrl: youtubePlayerUrl(id, start),
|
|
183
|
+
label: "YouTube",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (isBilibiliHost(host)) {
|
|
187
|
+
const id = bilibiliIdFrom(url);
|
|
188
|
+
if (!id) return fail("unsupported-bilibili-url");
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
provider: "bilibili",
|
|
192
|
+
pageUrl: bilibiliPageUrl(id),
|
|
193
|
+
playerUrl: bilibiliPlayerUrl(id),
|
|
194
|
+
label: "Bilibili",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return fail("unsupported-host");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function isPlayablePlayerUrl(raw) {
|
|
201
|
+
return resolvePlayer(raw).ok;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function escapeAttribute(value) {
|
|
205
|
+
return String(value)
|
|
206
|
+
.replaceAll("&", "&")
|
|
207
|
+
.replaceAll('"', """)
|
|
208
|
+
.replaceAll("<", "<")
|
|
209
|
+
.replaceAll(">", ">");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function escapeText(value) {
|
|
213
|
+
return String(value)
|
|
214
|
+
.replaceAll("&", "&")
|
|
215
|
+
.replaceAll("<", "<")
|
|
216
|
+
.replaceAll(">", ">");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Safe player markup. The iframe src is always a whitelisted player URL
|
|
221
|
+
* produced by resolvePlayer, never the author's raw address.
|
|
222
|
+
* @param {PlayerSuccess} player
|
|
223
|
+
* @param {{ title?: string }} [options]
|
|
224
|
+
*/
|
|
225
|
+
export function renderPlayerHtml(player, options = {}) {
|
|
226
|
+
if (!player?.ok) return "";
|
|
227
|
+
if (!PLAYER_IFRAME_ORIGINS.some((origin) => player.playerUrl.startsWith(`${origin}/`))) return "";
|
|
228
|
+
const title = String(options.title ?? "").trim() || `${player.label} video`;
|
|
229
|
+
const open = player.provider === "youtube" ? "在 YouTube 打开" : "在 Bilibili 打开";
|
|
230
|
+
return [
|
|
231
|
+
`<figure class="g-player g-player-${player.provider}">`,
|
|
232
|
+
`<div class="g-player-frame">`,
|
|
233
|
+
`<iframe src="${escapeAttribute(player.playerUrl)}" title="${escapeAttribute(title)}" loading="lazy" allow="fullscreen; picture-in-picture; encrypted-media" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>`,
|
|
234
|
+
`</div>`,
|
|
235
|
+
`<figcaption><a class="ext" href="${escapeAttribute(player.pageUrl)}" target="_blank" rel="noopener noreferrer">${escapeText(open)}</a></figcaption>`,
|
|
236
|
+
`</figure>`,
|
|
237
|
+
].join("");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function renderPlayerError(message) {
|
|
241
|
+
const text = String(message ?? "无法嵌入这个视频。请检查链接是否为可播放的 YouTube 或 Bilibili 地址。").trim();
|
|
242
|
+
return `<p class="g-player-error">${escapeText(text)}</p>`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function yamlScalar(value) {
|
|
246
|
+
const text = String(value ?? "").trim();
|
|
247
|
+
if (!text) return "";
|
|
248
|
+
const unquoted = text.replace(/^['"]|['"]$/gu, "");
|
|
249
|
+
return unquoted;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Parse a historical ```embed fence. Playable URLs become players; anything
|
|
254
|
+
* else is a visible fallback, never an arbitrary iframe.
|
|
255
|
+
* @param {string} source
|
|
256
|
+
*/
|
|
257
|
+
export function renderLegacyEmbed(source) {
|
|
258
|
+
const text = String(source ?? "").trim();
|
|
259
|
+
const urlMatch = /(?:^|\n)\s*url\s*:\s*(\S[^\n]*)/iu.exec(text);
|
|
260
|
+
const titleMatch = /(?:^|\n)\s*title\s*:\s*([^\n]+)/iu.exec(text);
|
|
261
|
+
const rawUrl = yamlScalar(urlMatch?.[1] ?? "");
|
|
262
|
+
const title = yamlScalar(titleMatch?.[1] ?? "");
|
|
263
|
+
if (!rawUrl) {
|
|
264
|
+
return renderPlayerError("无法解析这段历史 embed 围栏,请改成单独一行的 YouTube 或 Bilibili 链接。");
|
|
265
|
+
}
|
|
266
|
+
const player = resolvePlayer(rawUrl);
|
|
267
|
+
if (player.ok) return renderPlayerHtml(player, { title });
|
|
268
|
+
const url = readHttpUrl(rawUrl);
|
|
269
|
+
if (url) {
|
|
270
|
+
const label = title || url.toString();
|
|
271
|
+
return `<p class="g-player-fallback"><a href="${escapeAttribute(url.toString())}" target="_blank" rel="noopener noreferrer">${escapeText(label)}</a></p>`;
|
|
272
|
+
}
|
|
273
|
+
return renderPlayerError("无法解析这段历史 embed 围栏里的地址。");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Paste-ready author line for the CLI. Playable URLs are normalized; other
|
|
278
|
+
* addresses are returned unchanged. Metadata belongs on stderr, not in a fence.
|
|
279
|
+
* @param {unknown} raw
|
|
280
|
+
*/
|
|
281
|
+
export function authorPasteUrl(raw) {
|
|
282
|
+
const player = resolvePlayer(raw);
|
|
283
|
+
if (player.ok) return player.pageUrl;
|
|
284
|
+
const url = readHttpUrl(raw);
|
|
285
|
+
return url ? url.toString() : "";
|
|
286
|
+
}
|
|
@@ -1,10 +1,26 @@
|
|
|
1
|
-
/** Keep closing punctuation with the preceding inline formula
|
|
2
|
-
*
|
|
3
|
-
*
|
|
1
|
+
/** Keep closing punctuation with the preceding inline formula, and with the
|
|
2
|
+
* last typographic cluster of ordinary prose (text, emoji, emphasis, links).
|
|
3
|
+
*
|
|
4
|
+
* KaTeX is an atomic inline-block, so CSS line-break / overflow-wrap cannot
|
|
5
|
+
* see the formula and the following punctuation as one cluster. ASCII `~`
|
|
6
|
+
* (U+007E) and the fullwidth / wave tildes are not CSS/Unicode line-end
|
|
7
|
+
* punctuation either, so they can sit alone after a CJK character or emoji
|
|
8
|
+
* when `.markdown-body` uses overflow-wrap: anywhere. Cluster only the last
|
|
9
|
+
* grapheme (ZWJ / variation-selector sequences stay intact) plus a trailing
|
|
10
|
+
* closing-punctuation run — never a whole paragraph, and never opening
|
|
11
|
+
* brackets.
|
|
12
|
+
*
|
|
4
13
|
* Keep KaTeX markup/MathML intact; the outer viewport contains an oversized
|
|
5
14
|
* atomic formula whether or not it has following punctuation.
|
|
6
15
|
*/
|
|
7
|
-
export const CLOSING_PUNCTUATION = /^[,.;:!?,。、;:!?))\]}]}〉》」』】〕〗〙〛’”'"
|
|
16
|
+
export const CLOSING_PUNCTUATION = /^[,.;:!?,。、;:!?))\]}]}〉》」』】〕〗〙〛’”'"…~~〜]+/u;
|
|
17
|
+
export const CLOSING_PUNCTUATION_END = /[,.;:!?,。、;:!?))\]}]}〉》」』】〕〗〙〛’”'"…~~〜]+$/u;
|
|
18
|
+
|
|
19
|
+
const SKIP_PARENTS = new Set(["pre", "code", "kbd", "samp", "script", "style", "textarea", "svg", "math"]);
|
|
20
|
+
const PHRASING = new Set([
|
|
21
|
+
"a", "abbr", "b", "cite", "code", "em", "i", "kbd", "mark", "q", "s", "samp",
|
|
22
|
+
"small", "span", "strong", "sub", "sup", "time", "u", "var", "ruby", "rt",
|
|
23
|
+
]);
|
|
8
24
|
|
|
9
25
|
function classNames(node) {
|
|
10
26
|
const value = node.properties?.className;
|
|
@@ -15,27 +31,80 @@ function isInlineKatex(node) {
|
|
|
15
31
|
return node.type === "element" && classNames(node).has("katex") && !classNames(node).has("katex-display");
|
|
16
32
|
}
|
|
17
33
|
|
|
34
|
+
function keepSpan(children) {
|
|
35
|
+
return { type: "element", tagName: "span", properties: { className: ["g-keep-punct"] }, children };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function lastGrapheme(value) {
|
|
39
|
+
const segments = [...new Intl.Segmenter("und", { granularity: "grapheme" }).segment(value)];
|
|
40
|
+
return segments.at(-1)?.segment ?? "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Split a trailing [grapheme][spaces][closing punct run] off a text value. */
|
|
44
|
+
export function splitTrailingKeep(value) {
|
|
45
|
+
const match = String(value ?? "").match(CLOSING_PUNCTUATION_END);
|
|
46
|
+
if (!match) return null;
|
|
47
|
+
const before = value.slice(0, -match[0].length);
|
|
48
|
+
if (!before) return null;
|
|
49
|
+
const trimmed = before.replace(/[ \t\u00a0]+$/u, "");
|
|
50
|
+
if (!trimmed) return null;
|
|
51
|
+
const last = lastGrapheme(trimmed);
|
|
52
|
+
if (!last) return null;
|
|
53
|
+
return {
|
|
54
|
+
head: trimmed.slice(0, -last.length),
|
|
55
|
+
cluster: last + before.slice(trimmed.length) + match[0],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isProtected(node) {
|
|
60
|
+
return isInlineKatex(node) || ["katex-display", "g-keep-punct", "g-inline-math"].some((name) => classNames(node).has(name));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function canTakeFollowingPunct(node) {
|
|
64
|
+
if (!node || node.type !== "element") return false;
|
|
65
|
+
if (isInlineKatex(node)) return true;
|
|
66
|
+
if (isProtected(node)) return false;
|
|
67
|
+
return PHRASING.has(node.tagName);
|
|
68
|
+
}
|
|
69
|
+
|
|
18
70
|
export function keepClosingPunctuation(tree) {
|
|
19
71
|
const walk = (parent) => {
|
|
20
|
-
if (!Array.isArray(parent?.children) ||
|
|
21
|
-
["katex-display", "g-keep-punct", "g-inline-math"].some(name => classNames(parent).has(name))) return;
|
|
72
|
+
if (!Array.isArray(parent?.children) || isProtected(parent) || SKIP_PARENTS.has(parent.tagName)) return;
|
|
22
73
|
const next = [];
|
|
23
74
|
for (let index = 0; index < parent.children.length; index += 1) {
|
|
24
75
|
const child = parent.children[index];
|
|
25
76
|
walk(child);
|
|
26
77
|
const following = parent.children[index + 1];
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
78
|
+
const leading = following?.type === "text" ? following.value.match(CLOSING_PUNCTUATION) : null;
|
|
79
|
+
|
|
80
|
+
if (child.type === "text") {
|
|
81
|
+
const split = splitTrailingKeep(child.value);
|
|
82
|
+
if (!split) { next.push(child); continue; }
|
|
83
|
+
if (split.head) next.push({ type: "text", value: split.head });
|
|
84
|
+
next.push(keepSpan([{ type: "text", value: split.cluster }]));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (canTakeFollowingPunct(child) && leading) {
|
|
89
|
+
next.push(keepSpan([child, { type: "text", value: leading[0] }]));
|
|
90
|
+
const rest = following.value.slice(leading[0].length);
|
|
91
|
+
if (rest) next.push({ type: "text", value: rest });
|
|
92
|
+
index += 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Bare inline formulas still get a local overflow viewport.
|
|
97
|
+
if (isInlineKatex(child)) {
|
|
98
|
+
next.push({
|
|
99
|
+
type: "element",
|
|
100
|
+
tagName: "span",
|
|
101
|
+
properties: { className: ["g-inline-math"] },
|
|
102
|
+
children: [child],
|
|
103
|
+
});
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
next.push(child);
|
|
39
108
|
}
|
|
40
109
|
parent.children = next;
|
|
41
110
|
};
|
|
@@ -2135,16 +2135,51 @@
|
|
|
2135
2135
|
.g-compose-grid-item[data-span="4"] { grid-column: span 4; }
|
|
2136
2136
|
|
|
2137
2137
|
.g-compose-icon {
|
|
2138
|
+
--gc-icon-size: 15px;
|
|
2138
2139
|
display: inline-block;
|
|
2139
2140
|
flex: 0 0 auto;
|
|
2140
|
-
width:
|
|
2141
|
-
height:
|
|
2141
|
+
width: var(--gc-icon-size);
|
|
2142
|
+
height: var(--gc-icon-size);
|
|
2142
2143
|
color: var(--ink);
|
|
2143
2144
|
vertical-align: -2px;
|
|
2144
2145
|
}
|
|
2145
|
-
.g-compose-icon[data-size="sm"] {
|
|
2146
|
-
.g-compose-icon[data-size="lg"] {
|
|
2147
|
-
.g-compose-icon[data-size="xl"] {
|
|
2146
|
+
.g-compose-icon[data-size="sm"] { --gc-icon-size: 13px; }
|
|
2147
|
+
.g-compose-icon[data-size="lg"] { --gc-icon-size: 18px; }
|
|
2148
|
+
.g-compose-icon[data-size="xl"] { --gc-icon-size: 22px; }
|
|
2149
|
+
/* Align row-leading icons with the first line of adjacent text/headings rather than the top of half-leading. */
|
|
2150
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-text:not([data-size="sm"]):not([data-size="lg"]):not([data-size="xl"])),
|
|
2151
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-group > .g-compose-text:first-child:not([data-size="sm"]):not([data-size="lg"]):not([data-size="xl"])),
|
|
2152
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-group > .g-compose-content:first-child) {
|
|
2153
|
+
margin-top: max(0px, calc((1lh - var(--gc-icon-size)) / 2));
|
|
2154
|
+
}
|
|
2155
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-text[data-size="sm"]),
|
|
2156
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-group > .g-compose-text[data-size="sm"]:first-child) {
|
|
2157
|
+
margin-top: max(0px, calc((1.3125em - var(--gc-icon-size)) / 2));
|
|
2158
|
+
}
|
|
2159
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-text[data-size="lg"]),
|
|
2160
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-group > .g-compose-text[data-size="lg"]:first-child) {
|
|
2161
|
+
margin-top: max(0px, calc((1.125 * 1lh - var(--gc-icon-size)) / 2));
|
|
2162
|
+
}
|
|
2163
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-text[data-size="xl"]),
|
|
2164
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-group > .g-compose-text[data-size="xl"]:first-child) {
|
|
2165
|
+
margin-top: max(0px, calc((1.25 * 1lh - var(--gc-icon-size)) / 2));
|
|
2166
|
+
}
|
|
2167
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:not([data-size="xl"]):has(+ .g-compose-h:not([data-size="sm"]):not([data-size="lg"]):not([data-size="xl"])),
|
|
2168
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:not([data-size="xl"]):has(+ .g-compose-group > .g-compose-h:first-child:not([data-size="sm"]):not([data-size="lg"]):not([data-size="xl"])) {
|
|
2169
|
+
margin-top: max(0px, calc((1.4em - var(--gc-icon-size)) / 2));
|
|
2170
|
+
}
|
|
2171
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-h[data-size="sm"]),
|
|
2172
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-group > .g-compose-h[data-size="sm"]:first-child) {
|
|
2173
|
+
margin-top: max(0px, calc((1.1375em - var(--gc-icon-size)) / 2));
|
|
2174
|
+
}
|
|
2175
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-h[data-size="lg"]),
|
|
2176
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-group > .g-compose-h[data-size="lg"]:first-child) {
|
|
2177
|
+
margin-top: max(0px, calc((1.575em - var(--gc-icon-size)) / 2));
|
|
2178
|
+
}
|
|
2179
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-h[data-size="xl"]),
|
|
2180
|
+
.g-compose-row:not([data-align="center"]):not([data-align="end"]) > .g-compose-icon:has(+ .g-compose-group > .g-compose-h[data-size="xl"]:first-child) {
|
|
2181
|
+
margin-top: max(0px, calc((1.75em - var(--gc-icon-size)) / 2));
|
|
2182
|
+
}
|
|
2148
2183
|
.g-compose-card[data-size="sm"] > .g-compose-icon {
|
|
2149
2184
|
width: 14px;
|
|
2150
2185
|
height: 14px;
|
|
@@ -2531,6 +2566,9 @@
|
|
|
2531
2566
|
width: 100%;
|
|
2532
2567
|
min-width: 0;
|
|
2533
2568
|
}
|
|
2569
|
+
.g-compose-row:not([data-wrap="nowrap"]) > .g-compose-icon {
|
|
2570
|
+
margin-top: 0;
|
|
2571
|
+
}
|
|
2534
2572
|
|
|
2535
2573
|
/* Columnized centered rows stretch labels to 100% width, so align-items
|
|
2536
2574
|
can center arrows but not text. Center direct labels only; explicit
|