@windcoder/astro-theme-orchid 0.4.0 → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windcoder/astro-theme-orchid",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "清新简约的 Astro 博客主题,以 Integration 形式提供,支持版本化升级与组件覆盖",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -169,8 +169,10 @@ function collectRemoteUrls() {
169
169
  const walkMd = (dir) => {
170
170
  const out = [];
171
171
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
172
- if (entry.isDirectory()) out.push(...walkMd(path.join(dir, entry.name)));
173
- else if (/\.(md|mdx)$/i.test(entry.name)) out.push(path.join(dir, entry.name));
172
+ if (entry.isDirectory())
173
+ out.push(...walkMd(path.join(dir, entry.name)));
174
+ else if (/\.(md|mdx)$/i.test(entry.name))
175
+ out.push(path.join(dir, entry.name));
174
176
  }
175
177
  return out;
176
178
  };
@@ -229,7 +231,9 @@ async function mapPool(items, limit, fn) {
229
231
  try {
230
232
  out[idx] = await fn(items[idx]);
231
233
  } catch (err) {
232
- console.warn(`[lqip] 外链抓取失败,跳过: ${items[idx]} — ${err.message}`);
234
+ console.warn(
235
+ `[lqip] 外链抓取失败,跳过: ${items[idx]} — ${err.message}`,
236
+ );
233
237
  }
234
238
  }
235
239
  }
@@ -282,11 +286,12 @@ try {
282
286
 
283
287
  fs.writeFileSync(
284
288
  lqipFile,
285
- JSON.stringify(Object.fromEntries(lqipEntries), null, 2) + "\n",
289
+ // tab 缩进与 Biome format 一致:生成物入库即合规,重跑不会反复触发 CI format 报错
290
+ JSON.stringify(Object.fromEntries(lqipEntries), null, "\t") + "\n",
286
291
  );
287
292
  fs.writeFileSync(
288
293
  thumbsFile,
289
- JSON.stringify(Object.fromEntries(thumbEntries), null, 2) + "\n",
294
+ JSON.stringify(Object.fromEntries(thumbEntries), null, "\t") + "\n",
290
295
  );
291
296
  console.log(
292
297
  `[lqip] public ${files.length} 张 + 外链 ${remoteUrls.length - pending.length + fetched.filter(Boolean).length} 条 → ${path.relative(cwd, lqipFile)} + ${path.relative(cwd, thumbsFile)}`,
@@ -97,7 +97,8 @@ const staticSub = typerOn ? "" : wpSubs.length ? wpPick(wpSubs) : "";
97
97
  const heroTitle = isActualHome ? homeText.title : title;
98
98
  const heroSub = isActualHome ? staticSub : "";
99
99
  const heroTyperOn = isActualHome && typerOn;
100
- const heroInStage = Boolean(stageMode) && (isActualHome ? hasHeroText : Boolean(title));
100
+ const heroInStage =
101
+ Boolean(stageMode) && (isActualHome ? hasHeroText : Boolean(title));
101
102
  /* 底部装饰分端归一化:布尔简写展开为两端同值 */
102
103
  const wpWaves =
103
104
  typeof wp.common.waves === "boolean"
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import config from "virtual:theme/config";
3
3
  import { t } from "../i18n/index.js";
4
+ import { resolveSiteImage } from "../utils/image.js";
4
5
  import { getLqipStyle } from "../utils/lqip.js";
5
6
  import { getThumbInfo } from "../utils/thumb.js";
6
7
  import { formatDate, type PostLike, resolveCover } from "../utils.js";
@@ -25,7 +26,10 @@ const { cover: rawCover, retries } = resolveCover(
25
26
  config.postList.randomCover.apis,
26
27
  post.id,
27
28
  );
28
- const cover = config.postList.showCover ? rawCover : null;
29
+ /* §4.110:封面三格式统一语义(对齐 §4.108 其余消费点)——相对路径(站点 src/
30
+ 下)构建期解析为产物 URL,public / 外链原样直通 */
31
+ const resolvedCover = rawCover ? await resolveSiteImage(rawCover) : rawCover;
32
+ const cover = config.postList.showCover ? resolvedCover : null;
29
33
  /* §4.106:列表封面用缩略图(外链/未命中自动回退原图;点击进文章页看正文原图) */
30
34
  const thumb = cover ? getThumbInfo(cover) : null;
31
35
  const classes = [
package/src/index.ts CHANGED
@@ -257,6 +257,17 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
257
257
  updateConfig({
258
258
  // 主题路由全部按尾斜杠组织(如 /posts/xxx/),统一强制,dev 与 prod 行为一致
259
259
  trailingSlash: "always",
260
+ // 尾斜杠强制的连带修复(§4.109):trailingSlash:"always" 会把 astro
261
+ // 内部图片端点 /_image 的注册 pattern 变成 ^/_image/$(routing/pattern.js
262
+ // getPattern 按 trailingSlash 生成),而图片服务生成的 URL 恒为
263
+ // /_image?href=...(assets/services/service.js getURL 不加尾斜杠),
264
+ // 两者永远匹配不上 → dev 下 /_image 全量 404(落到 404 兜底页,
265
+ // 头像/md 内联图/封面等所有走图片管线的图全部裂图)。
266
+ // 端点路由改为带尾斜杠的 /_image/:注册 pattern(parseRoute 对
267
+ // "/_image/" 同样生成 ^/_image/$)与生成 URL(getURL 直接取本配置
268
+ // 拼 query)自动同步;parseURL 只读 query 不看 pathname,不受影响。
269
+ // 静态构建不走该端点(构建期直接编码出图),仅影响 dev/SSR 行为。
270
+ image: { endpoint: { route: "/_image/" } },
260
271
  vite: {
261
272
  plugins: [tailwindcss()],
262
273
  // 构建优化说明(§4.63):参考站的 vite.build 块(minify:"esbuild"、
@@ -15,6 +15,7 @@ import Sidebar from "virtual:theme/components/Sidebar.astro";
15
15
  import Toc from "virtual:theme/components/Toc.astro";
16
16
  import config from "virtual:theme/config";
17
17
  import { t } from "../../i18n/index.js";
18
+ import { resolveSiteImage } from "../../utils/image.js";
18
19
  import { getLqipStyle } from "../../utils/lqip.js";
19
20
  import { getRelatedPosts } from "../../utils/related.js";
20
21
  import {
@@ -98,14 +99,18 @@ export async function getStaticPaths() {
98
99
 
99
100
  const { post, prev, next, related, posts, seriesInfo } = Astro.props;
100
101
  const d = post.data;
101
- /* 封面解析(§4.33):image:"api" 时取确定性随机图;figure 与分享海报共用同一 URL */
102
- const { cover: articleCover, retries: coverRetries } = resolveCover(
102
+ /* 封面解析(§4.33):image:"api" 时取确定性随机图;figure 与分享海报共用同一 URL。
103
+ §4.110:三格式统一语义(同 PostCard)——相对路径构建期解析为产物 URL */
104
+ const { cover: rawArticleCover, retries: coverRetries } = resolveCover(
103
105
  d.image,
104
106
  d.firstImage,
105
107
  config.postList.fallbackCover,
106
108
  config.postList.randomCover.apis,
107
109
  post.id,
108
110
  );
111
+ const articleCover = rawArticleCover
112
+ ? await resolveSiteImage(rawArticleCover)
113
+ : rawArticleCover;
109
114
  const { Content, headings, remarkPluginFrontmatter } = await render(post);
110
115
  const { words, minutes } = remarkPluginFrontmatter as {
111
116
  words?: number;
package/src/schema.ts CHANGED
@@ -42,7 +42,8 @@ const sidebarWidgetBase = z.object({
42
42
  * 四种模式 html[data-wallpaper-mode] 驱动:banner 横幅 / fullscreen 全屏首屏 /
43
43
  * overlay 覆盖透明垫底 / none 纯色不渲染。
44
44
  * 图源遵循三格式约定(§4.97):"/" 开头按 public 路径原样输出;http(s)://、//、data:
45
- * 外链原样输出;其余相对路径相对站点 src/ 目录解析(构建期查表,支持 assets 内图片)。
45
+ * 外链原样输出;其余相对路径相对站点 src/ 目录解析(构建期查表,支持图片/视频,
46
+ * §4.108:不写死 assets/ 前缀)。
46
47
  * 提取为模块级 const 以便使用函数默认值 .default(() => wallpaperSchema.parse({})):
47
48
  * Zod 的 .default(x) 在输入缺失时直接取 x、不做二次 parse(v3/v4 行为一致),
48
49
  * 本段嵌套深达 4 层,最外层再写一份完整字面量维护成本过高;改用 parse({}) 在
@@ -196,7 +196,10 @@ async function initLightbox() {
196
196
  优先打开原图;无数据时沿用 img 自身 src 与 natural 尺寸 */
197
197
  if (img.dataset.fullSrc) {
198
198
  itemData.src = img.dataset.fullSrc;
199
- if (Number(img.dataset.fullWidth) > 0 && Number(img.dataset.fullHeight) > 0) {
199
+ if (
200
+ Number(img.dataset.fullWidth) > 0 &&
201
+ Number(img.dataset.fullHeight) > 0
202
+ ) {
200
203
  itemData.width = Number(img.dataset.fullWidth);
201
204
  itemData.height = Number(img.dataset.fullHeight);
202
205
  }
@@ -685,13 +688,16 @@ function pagerNums(page: number, total: number): Array<number | "ellipsis"> {
685
688
 
686
689
  function searchPageHref(q: string, page: number): string {
687
690
  return (
688
- "/search/?q=" +
689
- encodeURIComponent(q) +
690
- (page > 1 ? "&page=" + page : "")
691
+ "/search/?q=" + encodeURIComponent(q) + (page > 1 ? "&page=" + page : "")
691
692
  );
692
693
  }
693
694
 
694
- function renderSearchPager(pager: HTMLElement, q: string, cur: number, pages: number) {
695
+ function renderSearchPager(
696
+ pager: HTMLElement,
697
+ q: string,
698
+ cur: number,
699
+ pages: number,
700
+ ) {
695
701
  if (pages <= 1) {
696
702
  pager.hidden = true;
697
703
  return;
@@ -712,7 +718,8 @@ function renderSearchPager(pager: HTMLElement, q: string, cur: number, pages: nu
712
718
  s.textContent = label;
713
719
  return s;
714
720
  };
715
- if (cur > 1) nums.appendChild(mk(searchPageHref(q, cur - 1), "‹", "page-item"));
721
+ if (cur > 1)
722
+ nums.appendChild(mk(searchPageHref(q, cur - 1), "‹", "page-item"));
716
723
  else nums.appendChild(span("‹"));
717
724
  for (const n of pagerNums(cur, pages)) {
718
725
  if (n === "ellipsis") {
@@ -730,19 +737,22 @@ function renderSearchPager(pager: HTMLElement, q: string, cur: number, pages: nu
730
737
  nums.appendChild(mk(searchPageHref(q, n), String(n), "page-item"));
731
738
  }
732
739
  }
733
- if (cur < pages) nums.appendChild(mk(searchPageHref(q, cur + 1), "›", "page-item"));
740
+ if (cur < pages)
741
+ nums.appendChild(mk(searchPageHref(q, cur + 1), "›", "page-item"));
734
742
  else nums.appendChild(span("›"));
735
743
 
736
744
  const adj = doc.createElement("div");
737
745
  adj.className = "pg-adj";
738
- if (cur > 1) adj.appendChild(mk(searchPageHref(q, cur - 1), ui("prevPage"), "pg-prev"));
746
+ if (cur > 1)
747
+ adj.appendChild(mk(searchPageHref(q, cur - 1), ui("prevPage"), "pg-prev"));
739
748
  else adj.appendChild(span(ui("prevPage")));
740
749
  const mid = doc.createElement("span");
741
750
  mid.textContent = ui("pageOf")
742
751
  .replace("{current}", String(cur))
743
752
  .replace("{total}", String(pages));
744
753
  adj.appendChild(mid);
745
- if (cur < pages) adj.appendChild(mk(searchPageHref(q, cur + 1), ui("nextPage"), "pg-next"));
754
+ if (cur < pages)
755
+ adj.appendChild(mk(searchPageHref(q, cur + 1), ui("nextPage"), "pg-next"));
746
756
  else adj.appendChild(span(ui("nextPage")));
747
757
 
748
758
  pager.replaceChildren(nums, adj);
@@ -750,7 +760,9 @@ function renderSearchPager(pager: HTMLElement, q: string, cur: number, pages: nu
750
760
 
751
761
  async function renderSearchPage() {
752
762
  const seq = ++searchPageSeq;
753
- const input = doc.getElementById("searchPageInput") as HTMLInputElement | null;
763
+ const input = doc.getElementById(
764
+ "searchPageInput",
765
+ ) as HTMLInputElement | null;
754
766
  const meta = doc.getElementById("searchPageMeta");
755
767
  const box = doc.getElementById("searchPageResults");
756
768
  const pager = doc.getElementById("searchPagePager");
@@ -710,7 +710,9 @@ html.dark #modeToggle .i-sun {
710
710
  border-radius: 12px;
711
711
  background: var(--surface);
712
712
  color: var(--muted);
713
- transition: border-color 0.15s, color 0.15s;
713
+ transition:
714
+ border-color 0.15s,
715
+ color 0.15s;
714
716
  }
715
717
  .search-page-bar:focus-within {
716
718
  border-color: var(--accent-ink);
@@ -790,7 +792,9 @@ html[data-wallpaper-mode="fullscreen"]:not([data-wp-is-home]):not(
790
792
  height: var(--banner-h, 60vh);
791
793
  margin-top: calc(-1 * var(--header-h));
792
794
  }
793
- html[data-wallpaper-mode="fullscreen"]:not([data-wp-is-home])[data-wp-fs-layout="hero"]
795
+ html[data-wallpaper-mode="fullscreen"]:not(
796
+ [data-wp-is-home]
797
+ )[data-wp-fs-layout="hero"]
794
798
  .wp-stage {
795
799
  position: fixed;
796
800
  inset: 0;
@@ -886,7 +890,9 @@ html[data-wallpaper-mode="fullscreen"][data-wp-is-home] .wp-img {
886
890
  /* overlay 形态的图片模糊/放大补偿(服务端算好 scale 防边缘露底),与 body 级旧口径一致;
887
891
  非首页 fullscreen-hero 降级为 overlay,复用同一套模糊参数 */
888
892
  html[data-wallpaper-mode="overlay"] .wp-img,
889
- html[data-wallpaper-mode="fullscreen"]:not([data-wp-is-home])[data-wp-fs-layout="hero"]
893
+ html[data-wallpaper-mode="fullscreen"]:not(
894
+ [data-wp-is-home]
895
+ )[data-wp-fs-layout="hero"]
890
896
  .wp-img {
891
897
  filter: blur(var(--wp-overlay-blur, 0px));
892
898
  transform: scale(var(--wp-overlay-scale, 1));
@@ -1119,7 +1125,8 @@ html[data-wallpaper-mode="fullscreen"][data-wp-is-home] .wp-scroll-hint {
1119
1125
  }
1120
1126
  /* hero 布局:stage fixed 且 pointer-events:none,箭头需单独恢复可点击;
1121
1127
  滚动后随 .wp-hero 同步淡出 */
1122
- html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"] .wp-scroll-hint {
1128
+ html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"]
1129
+ .wp-scroll-hint {
1123
1130
  pointer-events: auto;
1124
1131
  }
1125
1132
  html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"].wp-scrolled
@@ -1169,7 +1176,7 @@ html[data-wallpaper-mode="overlay"] .card {
1169
1176
  classic 滚下后正文区纯色背景(旧行为,无需额外规则)。
1170
1177
  首屏两种布局一致:壁纸占满一屏 + 中央 hero 文案。 */
1171
1178
  html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"][data-wp-is-home]
1172
- .wp-stage {
1179
+ .wp-stage {
1173
1180
  position: fixed;
1174
1181
  inset: 0;
1175
1182
  height: auto;
@@ -1180,15 +1187,14 @@ html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"][data-wp-is-home
1180
1187
  /* 首屏让给 hero:正文流向下推一屏,滚上后覆盖 fixed 壁纸;
1181
1188
  stage 隐藏(homeOnly 非首页 / JS 切走)时取消下推,避免顶部整屏空白 */
1182
1189
  html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"][data-wp-is-home]:not(
1183
- [data-wp-stage-hidden]
1184
- )
1185
- .site-main {
1190
+ [data-wp-stage-hidden]
1191
+ ) .site-main {
1186
1192
  margin-top: 100vh;
1187
1193
  }
1188
1194
  /* hero 交互:fixed 后 pointer-events 被关,hero 文案纯展示无需点击(打字机亦然) */
1189
1195
  /* hero 文字滚动淡出:滚离首屏后标题/副标题渐隐,不盖在正文区模糊壁纸上 */
1190
1196
  html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"][data-wp-is-home]
1191
- .wp-hero {
1197
+ .wp-hero {
1192
1198
  transition: opacity 0.5s ease;
1193
1199
  }
1194
1200
  html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"][data-wp-is-home].wp-scrolled
@@ -1198,7 +1204,7 @@ html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"][data-wp-is-home
1198
1204
  /* 滚动模糊过渡:滚过 40px(html.wp-scrolled,app.ts updateWallpaperScroll)后
1199
1205
  壁纸加模糊 + 放大补偿 + 底部渐隐蒙层加深,保证模糊渐变在 blur=0 时也可见 */
1200
1206
  html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"][data-wp-is-home]
1201
- .wp-img {
1207
+ .wp-img {
1202
1208
  transition:
1203
1209
  opacity 0.8s ease,
1204
1210
  transform 0.8s ease,
@@ -1228,16 +1234,22 @@ html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"][data-wp-is-home
1228
1234
  文章列表 .post-card 与分页 .page-item 不走 .card 类,需一并覆盖;
1229
1235
  非首页 fullscreen-hero 降级为 overlay,同样需要半透明卡片 */
1230
1236
  html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"][data-wp-is-home]
1231
- .card,
1237
+ .card,
1232
1238
  html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"][data-wp-is-home]
1233
1239
  .post-card,
1234
1240
  html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"][data-wp-is-home]
1235
1241
  .page-item,
1236
- html[data-wallpaper-mode="fullscreen"]:not([data-wp-is-home])[data-wp-fs-layout="hero"]
1242
+ html[data-wallpaper-mode="fullscreen"]:not(
1243
+ [data-wp-is-home]
1244
+ )[data-wp-fs-layout="hero"]
1237
1245
  .card,
1238
- html[data-wallpaper-mode="fullscreen"]:not([data-wp-is-home])[data-wp-fs-layout="hero"]
1246
+ html[data-wallpaper-mode="fullscreen"]:not(
1247
+ [data-wp-is-home]
1248
+ )[data-wp-fs-layout="hero"]
1239
1249
  .post-card,
1240
- html[data-wallpaper-mode="fullscreen"]:not([data-wp-is-home])[data-wp-fs-layout="hero"]
1250
+ html[data-wallpaper-mode="fullscreen"]:not(
1251
+ [data-wp-is-home]
1252
+ )[data-wp-fs-layout="hero"]
1241
1253
  .page-item {
1242
1254
  background: color-mix(
1243
1255
  in srgb,
@@ -1258,7 +1270,9 @@ html[data-wallpaper-mode="fullscreen"]:not([data-wp-is-home])[data-wp-fs-layout=
1258
1270
  文字职责交还内容区标题(.page-title/.post-title-lg 等,见下方规则) */
1259
1271
  html[data-wallpaper-mode="overlay"] .wp-hero,
1260
1272
  html[data-wallpaper-mode="none"] .wp-hero,
1261
- html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"]:not([data-wp-is-home])
1273
+ html[data-wallpaper-mode="fullscreen"][data-wp-fs-layout="hero"]:not(
1274
+ [data-wp-is-home]
1275
+ )
1262
1276
  .wp-hero {
1263
1277
  display: none;
1264
1278
  }
@@ -1272,10 +1286,22 @@ html[data-wp-hero="visible"][data-wallpaper-mode="banner"] .page-title,
1272
1286
  html[data-wp-hero="visible"][data-wallpaper-mode="banner"] .page-sub,
1273
1287
  html[data-wp-hero="visible"][data-wallpaper-mode="banner"] .post-title-lg,
1274
1288
  html[data-wp-hero="visible"][data-wallpaper-mode="banner"] .archive-head,
1275
- html[data-wp-hero="visible"][data-wallpaper-mode="fullscreen"]:not([data-wp-fs-layout="hero"]) .page-title,
1276
- html[data-wp-hero="visible"][data-wallpaper-mode="fullscreen"]:not([data-wp-fs-layout="hero"]) .page-sub,
1277
- html[data-wp-hero="visible"][data-wallpaper-mode="fullscreen"]:not([data-wp-fs-layout="hero"]) .post-title-lg,
1278
- html[data-wp-hero="visible"][data-wallpaper-mode="fullscreen"]:not([data-wp-fs-layout="hero"]) .archive-head {
1289
+ html[data-wp-hero="visible"][data-wallpaper-mode="fullscreen"]:not(
1290
+ [data-wp-fs-layout="hero"]
1291
+ )
1292
+ .page-title,
1293
+ html[data-wp-hero="visible"][data-wallpaper-mode="fullscreen"]:not(
1294
+ [data-wp-fs-layout="hero"]
1295
+ )
1296
+ .page-sub,
1297
+ html[data-wp-hero="visible"][data-wallpaper-mode="fullscreen"]:not(
1298
+ [data-wp-fs-layout="hero"]
1299
+ )
1300
+ .post-title-lg,
1301
+ html[data-wp-hero="visible"][data-wallpaper-mode="fullscreen"]:not(
1302
+ [data-wp-fs-layout="hero"]
1303
+ )
1304
+ .archive-head {
1279
1305
  display: none;
1280
1306
  }
1281
1307
  html[data-wp-waves="off"] .wp-waves {
@@ -6,19 +6,21 @@
6
6
  * 构建期解析,且经 sharp image service 重编码(默认 webp + 压缩,
7
7
  * §4.106 升级),返回优化产物 URL(唯一被优化的格式)。
8
8
  *
9
- * 解析机制:import.meta.glob("/src/assets/...") 的 `/` 前缀相对 Vite 项目根,
10
- * 主题包组件运行在站点构建管线中,因此命中站点项目的 src/assets;
11
- * 非 eager 按需 await,仅实际用到的图片进入构建产物。
9
+ * 解析机制:import.meta.glob 匹配站点根下 src 全目录的媒体扩展名,`/` 前缀
10
+ * 相对 Vite 项目根,主题包组件运行在站点构建管线中,因此命中站点项目的 src/
11
+ * (§4.108:不写死 assets/ 前缀,src 下任意目录的图片/视频均可解析;
12
+ * ⚠️ 本段注释不可出现 glob 的双星斜杠字面量——含块注释终止符子串会提前闭合);
13
+ * 非 eager 按需 await,仅实际用到的媒体进入构建产物。
12
14
  * svg(矢量)与 gif(动图)不做 sharp 编码——转码会破坏矢量/动画,原样输出。
13
15
  * ico 不参与 glob(favicon 走 public / 远程,无优化需求)。
14
16
  */
15
17
 
16
18
  import { getImage, type ImageMetadata } from "astro:assets";
17
19
 
18
- /** glob 命中的资源元数据 */
19
- type AssetMeta = ImageMetadata;
20
+ /** glob 命中的资源元数据:图片为 ImageMetadata,视频等为产物 URL 字符串 */
21
+ type AssetMeta = ImageMetadata | string;
20
22
 
21
- /** 三格式判定:true = 相对 src/ 的本地资源(需构建期解析) */
23
+ /** 三格式判定:true = 相对 src/ 的本地媒体资源(需构建期解析) */
22
24
  export function isLocalImagePath(src: string): boolean {
23
25
  if (!src) return false;
24
26
  return !(
@@ -30,21 +32,26 @@ export function isLocalImagePath(src: string): boolean {
30
32
  );
31
33
  }
32
34
 
33
- /** 相对路径 → glob key:"assets/avatar.png" → "/src/assets/avatar.png" */
35
+ /**
36
+ * 相对路径 → glob key:不写死 assets/ 前缀(§4.108),相对路径不加 `/` 即
37
+ * 相对站点 src/ 目录——"assets/images/avatar.png" → /src/assets/images/avatar.png,
38
+ * "styles/x.svg" → /src/styles/x.svg,src 下任意目录均可解析。
39
+ */
34
40
  export function toAssetKey(src: string): string {
35
- return `/src/assets/${src.replace(/^\.?\//, "")}`;
41
+ return `/src/${src.replace(/^\.?\//, "")}`;
36
42
  }
37
43
 
38
44
  type AssetModules = Record<string, () => Promise<AssetMeta>>;
39
45
 
40
46
  /**
41
- * 解析单张站点图片为可直接用于 <img src> 的 URL。
47
+ * 解析单张站点静态媒体(图片/视频)为可直接引用的 URL。
42
48
  * public / 外链原样返回;相对路径查 glob 表,命中则 await 产物元数据;
43
49
  * 未命中(文件不存在)时 console.warn 并原样返回,页面不断构建。
44
50
  *
45
51
  * §4.106:位图(png/jpg/webp/avif)额外经 getImage() 走 sharp image service
46
52
  * 构建期重编码(默认转 webp + 质量压缩,Astro 同图同参有编码缓存);
47
53
  * svg/gif 保持原始产物 URL 不转码。失败时降级原样返回,页面不断构建。
54
+ * §4.108:视频(mp4/webm/mov)glob 导出为产物 URL 字符串,直接返回。
48
55
  *
49
56
  * 注意:import.meta.glob 第一参数必须是字符串字面量(vite 编译期静态分析,
50
57
  * 变量/常量在 dev 下直接报 Invalid glob import syntax),模式不能提取为常量。
@@ -52,23 +59,27 @@ type AssetModules = Record<string, () => Promise<AssetMeta>>;
52
59
  export async function resolveSiteImage(src: string): Promise<string> {
53
60
  if (!isLocalImagePath(src)) return src;
54
61
  const modules = import.meta.glob(
55
- "/src/assets/**/*.{png,jpg,jpeg,webp,avif,gif,svg}",
62
+ "/src/**/*.{png,jpg,jpeg,webp,avif,gif,svg,mp4,webm,mov}",
56
63
  { import: "default" },
57
64
  ) as unknown as AssetModules;
58
65
  const loader = modules[toAssetKey(src)];
59
66
  if (!loader) {
60
67
  console.warn(
61
- `[orchid-theme] 图片未找到: ${toAssetKey(src)}(相对站点 src/ 目录解析)`,
68
+ `[orchid-theme] 媒体文件未找到: ${toAssetKey(src)}(相对路径不带 / 时相对站点 src/ 目录解析)`,
62
69
  );
63
70
  return src;
64
71
  }
65
72
  const meta = await loader();
73
+ if (typeof meta === "string") return meta;
66
74
  if (meta.format === "svg" || meta.format === "gif") return meta.src;
67
75
  try {
68
76
  const encoded = await getImage({ src: meta });
69
77
  return encoded.src;
70
78
  } catch (err) {
71
- console.warn(`[orchid-theme] 图片优化失败,回退原图: ${toAssetKey(src)}`, err);
79
+ console.warn(
80
+ `[orchid-theme] 图片优化失败,回退原图: ${toAssetKey(src)}`,
81
+ err,
82
+ );
72
83
  return meta.src;
73
84
  }
74
85
  }
@@ -20,7 +20,12 @@ export interface ThumbInfo {
20
20
  export function getThumbInfo(src: string): ThumbInfo {
21
21
  const hit = thumbs[`public:${src.replace(/^\//, "")}`];
22
22
  if (hit?.thumb && hit.width > 0 && hit.height > 0) {
23
- return { full: src, src: `/${hit.thumb}`, width: hit.width, height: hit.height };
23
+ return {
24
+ full: src,
25
+ src: `/${hit.thumb}`,
26
+ width: hit.width,
27
+ height: hit.height,
28
+ };
24
29
  }
25
30
  return { full: src, src };
26
31
  }