@xingwangzhe/stalux 1.37.1 → 1.39.0

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.
Files changed (42) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/src/components/stalux/archives/toArchive.astro +5 -1
  4. package/src/components/stalux/common/Typewriter.astro +7 -1
  5. package/src/components/stalux/common/author.astro +2 -0
  6. package/src/components/stalux/common/date.astro +8 -1
  7. package/src/components/stalux/layout/PageHeading.astro +27 -0
  8. package/src/components/stalux/layout/footer.astro +6 -1
  9. package/src/components/stalux/layout/listPage.astro +2 -6
  10. package/src/components/stalux/layout/navigationProgress.astro +55 -0
  11. package/src/components/stalux/links/socialMedia.astro +3 -0
  12. package/src/components/stalux/posts/ArticleSummary.astro +8 -2
  13. package/src/components/stalux/posts/PostTaxonomy.astro +10 -3
  14. package/src/components/stalux/posts/postCC.astro +3 -2
  15. package/src/components/stalux/posts/postContent.astro +46 -27
  16. package/src/components/stalux/posts/postLeft.astro +17 -3
  17. package/src/components/stalux/posts/postRight.astro +20 -4
  18. package/src/components/stalux/posts/postToc.astro +5 -2
  19. package/src/components/stalux/tags/tagHeader.astro +5 -1
  20. package/src/index.ts +47 -4
  21. package/src/internal/font-slices.ts +159 -24
  22. package/src/internal/page-output-cache.ts +3 -1
  23. package/src/internal/style-names.generated.ts +135 -132
  24. package/src/layouts/PostLayout.astro +12 -2
  25. package/src/layouts/Stalux.astro +12 -0
  26. package/src/pages/404.astro +0 -3
  27. package/src/pages/about.astro +3 -3
  28. package/src/pages/archives.astro +5 -7
  29. package/src/pages/categories/[category].astro +0 -3
  30. package/src/pages/categories/index.astro +2 -5
  31. package/src/pages/index.astro +6 -6
  32. package/src/pages/posts/[post].astro +0 -3
  33. package/src/pages/tags/[tag].astro +0 -3
  34. package/src/pages/tags/index.astro +2 -5
  35. package/src/pages/words.astro +5 -7
  36. package/src/scripts/analytics-runtime.ts +3 -2
  37. package/src/scripts/navigation-progress.ts +146 -0
  38. package/src/scripts/navigation.ts +16 -1
  39. package/src/scripts/view-transitions.ts +39 -6
  40. package/src/styles/base.css +11 -3
  41. package/src/styles/generated.css +1 -1
  42. package/src/styles/motion.css +87 -0
package/README.md CHANGED
@@ -18,6 +18,10 @@ Standalone images written as `![Image description](url)` receive a `figure` and
18
18
 
19
19
  Code blocks default to the single `dark-plus` theme, with system color-scheme switching and theme selectors disabled. To opt into another theme or switching behavior, explicitly override `themes`, `useDarkModeMediaQuery`, and `themeCssSelector` through `expressiveCode`. This only changes generated code styles and adds no telemetry.
20
20
 
21
+ Route changes automatically show a 2px accent-colored progress bar at the top of the viewport, with no configuration. Its length follows actual milestones, with slow visual growth toward a bounded ceiling while waiting: loading begins, document/font preparation finishes, the DOM swaps, and page scripts finish; completion waits for the visual transition as well. It only reaches the end when loading and the transition both complete, and does not represent download/time percentages. It handles superseded or canceled navigation, history traversal, and reduced motion without additional requests or telemetry.
22
+
23
+ The sticky header stays at the top while the body grows with long content. Route transitions use one content snapshot with a short crossfade and a stationary header. Nested titles, sidebars and paragraphs no longer create separate snapshots, reducing rasterization and compositing work on mobile devices. Content, lightboxes, search, comments and analytics keep their existing behavior.
24
+
21
25
  ---
22
26
 
23
27
  ## 🚀 Quick Start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.37.1",
3
+ "version": "1.39.0",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -9,6 +9,10 @@ const siteConfig = getSiteData(configEntries);
9
9
  const { t } = createTranslator(siteConfig.lang || "zh-CN");
10
10
  ---
11
11
 
12
- <div id={"stalux-to-archive-archs"}>
12
+ <div
13
+ id={"stalux-to-archive-archs"}
14
+ transition:name="stalux-home-archive-link"
15
+ transition:animate="slide"
16
+ >
13
17
  <a href="/archives" data-astro-prefetch>{t("archives.viewAll")}</a>
14
18
  </div>
@@ -10,7 +10,13 @@ const texts = normalizeMessages(messages);
10
10
  ---
11
11
 
12
12
  {texts.length > 0 && (
13
- <div class={className} data-stalux-typewriter data-messages={JSON.stringify(texts)}>
13
+ <div
14
+ class={className}
15
+ data-stalux-typewriter
16
+ data-messages={JSON.stringify(texts)}
17
+ transition:name="stalux-home-typewriter"
18
+ transition:animate="fade"
19
+ >
14
20
  <span class="stalux-typewriter-accessible">{texts.join(", ")}</span>
15
21
  <span class="stalux-typewriter-reserve" aria-hidden="true">
16
22
  {texts.map((text) => (
@@ -20,6 +20,8 @@ const navigationHref = isHomePage ? "/about" : "/";
20
20
  <article
21
21
  class="stalux-author stalux-u-flex stalux-u-justify-center stalux-u-flex-col"
22
22
  transition:persist-props
23
+ transition:name={isHomePage ? "stalux-home-author" : undefined}
24
+ transition:animate={isHomePage ? "fade" : undefined}
23
25
  >
24
26
  <figure
25
27
  class={`stalux-author-figure ${props.iscolum ? "stalux-author-figure-column stalux-u-flex-col" : ""}`}
@@ -1,7 +1,14 @@
1
1
  ---
2
+ const isHomePage = Astro.url.pathname === "/";
2
3
  ---
3
4
 
4
- <div id={"stalux-date-date-time"} class="date-time" transition:persist-props>
5
+ <div
6
+ id={"stalux-date-date-time"}
7
+ class="date-time"
8
+ transition:persist-props
9
+ transition:name={isHomePage ? "stalux-home-date-time" : undefined}
10
+ transition:animate={isHomePage ? "fade" : undefined}
11
+ >
5
12
  <time id={"stalux-date-date"} data-ref="date" datetime=""></time>
6
13
  <time id={"stalux-date-time"} data-ref="time" datetime=""></time>
7
14
  </div>
@@ -0,0 +1,27 @@
1
+ ---
2
+ interface Props {
3
+ heading: string;
4
+ description?: string;
5
+ }
6
+
7
+ const { heading, description } = Astro.props;
8
+ ---
9
+
10
+ <>
11
+ <h1
12
+ class="stalux-page-title stalux-u-text-center"
13
+ transition:name="stalux-page-title"
14
+ transition:animate="none"
15
+ >
16
+ {heading}
17
+ </h1>
18
+ {description && (
19
+ <div
20
+ class="stalux-page-description stalux-u-text-center"
21
+ transition:name="stalux-page-description"
22
+ transition:animate="none"
23
+ >
24
+ {description}
25
+ </div>
26
+ )}
27
+ </>
@@ -51,7 +51,12 @@ const totalWordCount = await getTotalWordCount(Astro.logger);
51
51
  const formattedWordCount = formatWordCount(totalWordCount, siteConfig.lang || "zh-CN");
52
52
  ---
53
53
 
54
- <footer class="stalux-footer stalux-u-w-full stalux-u-text-center" transition:persist-props>
54
+ <footer
55
+ class="stalux-footer stalux-u-w-full stalux-u-text-center"
56
+ data-stalux-font-group="footer"
57
+ transition:persist="stalux-site-footer"
58
+ transition:persist-props
59
+ >
55
60
  <div class="stalux-footer-content stalux-u-flex stalux-u-flex-col">
56
61
  <!-- 版权信息 -->
57
62
  {footerConfig?.copyright?.enabled && (
@@ -1,9 +1,8 @@
1
1
  ---
2
2
  import { getCollection } from "astro:content";
3
- import Navs from "@components/stalux/layout/navs.astro";
4
3
  import Stalux from "@layouts/Stalux.astro";
5
-
6
4
  import { getSiteData } from "@utils/config-utils";
5
+ import PageHeading from "./PageHeading.astro";
7
6
 
8
7
  interface Props {
9
8
  title: string;
@@ -18,9 +17,6 @@ const siteConfig = getSiteData(configEntries);
18
17
  ---
19
18
 
20
19
  <Stalux title={`${title} | ${siteConfig.title}`} description={description}>
21
- <div style="height: 3em;"></div>
22
- <Navs />
23
- <h1 class="stalux-page-title stalux-u-text-center">{heading}</h1>
24
- {subheading && <div class="stalux-page-description stalux-u-text-center">{subheading}</div>}
20
+ <PageHeading heading={heading} description={subheading} />
25
21
  <slot />
26
22
  </Stalux>
@@ -0,0 +1,55 @@
1
+ <div
2
+ data-stalux-navigation-progress
3
+ aria-hidden="true"
4
+ transition:persist="stalux-navigation-progress"
5
+ transition:name="stalux-navigation-progress"
6
+ transition:animate="none"
7
+ ></div>
8
+
9
+ <style is:global>
10
+ [data-stalux-navigation-progress] {
11
+ position: fixed;
12
+ inset: 0 0 auto;
13
+ height: 2px;
14
+ z-index: 2147483647;
15
+ pointer-events: none;
16
+ background: var(--stalux-accent-color, #eab308);
17
+ transform: scaleX(0);
18
+ transform-origin: left;
19
+ opacity: 0;
20
+ transition:
21
+ transform 450ms ease-out,
22
+ opacity 120ms ease-out;
23
+ }
24
+ [data-stalux-navigation-progress][data-stage] {
25
+ opacity: 1;
26
+ }
27
+ [data-stalux-navigation-progress][data-stage="complete"] {
28
+ transition:
29
+ transform 160ms ease-out,
30
+ opacity 120ms ease-out;
31
+ }
32
+ [data-stalux-navigation-progress]:not([data-stage]) {
33
+ transition: opacity 120ms ease-out;
34
+ }
35
+ ::view-transition-group(stalux-navigation-progress) {
36
+ z-index: 2147483647;
37
+ }
38
+ ::view-transition-old(stalux-navigation-progress) {
39
+ display: none;
40
+ }
41
+ ::view-transition-new(stalux-navigation-progress) {
42
+ animation: none;
43
+ }
44
+ @media (prefers-reduced-motion: reduce) {
45
+ [data-stalux-navigation-progress],
46
+ [data-stalux-navigation-progress][data-stage] {
47
+ transition: none;
48
+ }
49
+ }
50
+ @media print {
51
+ [data-stalux-navigation-progress] {
52
+ display: none;
53
+ }
54
+ }
55
+ </style>
@@ -18,6 +18,7 @@ const configEntries = await getCollection("config");
18
18
  const mediaLinksSection = getMediaLinksData(configEntries);
19
19
  const mediaLinks = mediaLinksSection?.items ?? [];
20
20
  const isPostPage = Astro.url.pathname.startsWith("/posts/");
21
+ const isHomePage = Astro.url.pathname === "/";
21
22
 
22
23
  // 静态图标映射表:新增图标时只需添加对应的 simple-icons 导入和映射
23
24
  const iconMap: Record<string, SimpleIcon> = {
@@ -37,6 +38,8 @@ function getIconByTitle(iconName: string): SimpleIcon | undefined {
37
38
  <nav
38
39
  class="stalux-social"
39
40
  transition:persist-props
41
+ transition:name={isHomePage ? "stalux-home-social" : undefined}
42
+ transition:animate={isHomePage ? "fade" : undefined}
40
43
  aria-label="Social media"
41
44
  data-stalux-social-animate={String(!isPostPage)}
42
45
  >
@@ -2,13 +2,19 @@
2
2
  interface Props {
3
3
  description?: string;
4
4
  label: string;
5
+ transitionName: string;
5
6
  }
6
- const { description, label } = Astro.props;
7
+ const { description, label, transitionName } = Astro.props;
7
8
  const summary = description?.trim();
8
9
  ---
9
10
 
10
11
  {summary && (
11
- <aside class="stalux-article-summary" aria-label={label}>
12
+ <aside
13
+ class="stalux-article-summary"
14
+ aria-label={label}
15
+ transition:name={transitionName}
16
+ transition:animate="none"
17
+ >
12
18
  <p class="stalux-article-summary-label">{label}</p>
13
19
  <p class="stalux-article-summary-text">{summary}</p>
14
20
  </aside>
@@ -46,11 +46,12 @@ const cfg = {
46
46
  <nav
47
47
  class="stalux-post-taxonomy-container"
48
48
  transition:name={cfg.containerName}
49
+ transition:animate="none"
49
50
  aria-label={title ?? cfg.heading}
50
51
  >
51
- {items.length > 0 && <h3 class="stalux-post-taxonomy-title">{title ?? cfg.heading}</h3>}
52
+ <h3 class="stalux-post-taxonomy-title">{title ?? cfg.heading}</h3>
52
53
  {items.length > 0 ? (
53
- <div class={cfg.listClass} transition:name={cfg.contentName}>
54
+ <div class={cfg.listClass} transition:name={cfg.contentName} transition:animate="fade">
54
55
  {items.map((item) => (
55
56
  <a
56
57
  href={`${cfg.prefix}${item}/`}
@@ -62,6 +63,12 @@ const cfg = {
62
63
  ))}
63
64
  </div>
64
65
  ) : (
65
- <div class="stalux-post-taxonomy-empty">{cfg.empty}</div>
66
+ <div
67
+ class="stalux-post-taxonomy-empty"
68
+ transition:name={cfg.contentName}
69
+ transition:animate="fade"
70
+ >
71
+ {cfg.empty}
72
+ </div>
66
73
  )}
67
74
  </nav>
@@ -11,6 +11,7 @@ interface Props {
11
11
  author: string;
12
12
  url: string;
13
13
  cc?: string;
14
+ transitionName?: string;
14
15
  }
15
16
 
16
17
  const configEntries = await getCollection("config");
@@ -19,13 +20,13 @@ const siteConfig = (configEntries.find((e) => e.id === "site")?.data ?? {}) as R
19
20
  unknown
20
21
  >;
21
22
  const { t } = createTranslator((siteConfig.lang as string) || "zh-CN");
22
- const { title, author, url, cc = "CC-BY-NC-SA-4.0" } = Astro.props;
23
+ const { title, author, url, cc = "CC-BY-NC-SA-4.0", transitionName } = Astro.props;
23
24
 
24
25
  const licenseLink = buildCCLink(cc);
25
26
  const licenseName = buildCCName(cc, t);
26
27
  ---
27
28
 
28
- <div class="stalux-license-box">
29
+ <div class="stalux-license-box" transition:name={transitionName} transition:animate="none">
29
30
  <div class="stalux-license-content">
30
31
  <p class="stalux-license-title">{title}</p>
31
32
  <p class="stalux-license-author">{t("post.author")}{author}</p>
@@ -15,6 +15,7 @@ interface Props {
15
15
  description?: string;
16
16
  transitionName?: string;
17
17
  titleTransitionName?: string;
18
+ transitionScope?: "article" | "about";
18
19
  date?: string;
19
20
  updated?: string;
20
21
  readingMinutes?: number;
@@ -23,6 +24,7 @@ interface Props {
23
24
  }
24
25
 
25
26
  const props = Astro.props as Props;
27
+ const transitionPrefix = props.transitionScope === "about" ? "stalux-about" : "stalux-article";
26
28
  const configEntries = await getCollection("config");
27
29
  const siteConfig = getSiteData(configEntries);
28
30
  const promoteConfig = getPromoteData(configEntries);
@@ -39,14 +41,23 @@ const mdSourceUrl = exportMd ? `${Astro.url.pathname.replace(/\/$/, "")}.md` : "
39
41
  <article
40
42
  class="stalux-article"
41
43
  data-stalux-gallery={props.hasImage ? "true" : undefined}
42
- transition:name={props.transitionName}
44
+ transition:name={props.transitionName ?? "stalux-article-container"}
45
+ transition:animate="none"
43
46
  >
44
47
  <!-- Article Header -->
45
- <header class="stalux-post-article-header">
48
+ <header
49
+ class="stalux-post-article-header"
50
+ transition:name={`${transitionPrefix}-header`}
51
+ transition:animate="fade"
52
+ >
46
53
  <h1 class="stalux-post-title" transition:name={props.titleTransitionName}>
47
54
  {props.title}
48
55
  </h1>
49
- <div class="stalux-post-metadata">
56
+ <div
57
+ class="stalux-post-metadata"
58
+ transition:name={`${transitionPrefix}-metadata`}
59
+ transition:animate="none"
60
+ >
50
61
  {props.date && (
51
62
  <time
52
63
  class="stalux-post-meta-item"
@@ -67,30 +78,32 @@ const mdSourceUrl = exportMd ? `${Astro.url.pathname.replace(/\/$/, "")}.md` : "
67
78
  </time>
68
79
  )}
69
80
  </div>
70
- {(
71
- <div class="stalux-post-page-stats">
72
- {(props.readingMinutes ?? 0) > 0 && (
73
- <span class="stalux-post-reading-time">
74
- {t("post.readingTime")}
75
- {props.readingMinutes && props.readingMinutes > 0
76
- ? props.readingMinutes < 1
77
- ? t("common.lessThanOneMinute")
78
- : t("common.minute", { n: props.readingMinutes })
79
- : ""}
80
- </span>
81
- )}
82
- {(props.wordCount ?? 0) > 0 && (
83
- <span class="wordCount">
84
- {t("post.wordCount")}
85
- {props.wordCount}
86
- </span>
87
- )}
88
- <span id="vercount_container_page_pv">
89
- {t("post.viewCount")}
90
- <span id="vercount_value_page_pv">Loading...</span>
81
+ <div
82
+ class="stalux-post-page-stats"
83
+ transition:name={`${transitionPrefix}-stats`}
84
+ transition:animate="none"
85
+ >
86
+ {(props.readingMinutes ?? 0) > 0 && (
87
+ <span class="stalux-post-reading-time">
88
+ {t("post.readingTime")}
89
+ {props.readingMinutes && props.readingMinutes > 0
90
+ ? props.readingMinutes < 1
91
+ ? t("common.lessThanOneMinute")
92
+ : t("common.minute", { n: props.readingMinutes })
93
+ : ""}
91
94
  </span>
92
- </div>
93
- )}
95
+ )}
96
+ {(props.wordCount ?? 0) > 0 && (
97
+ <span class="wordCount">
98
+ {t("post.wordCount")}
99
+ {props.wordCount}
100
+ </span>
101
+ )}
102
+ <span id="vercount_container_page_pv">
103
+ {t("post.viewCount")}
104
+ <span id="vercount_value_page_pv">Loading...</span>
105
+ </span>
106
+ </div>
94
107
  {exportMd && (
95
108
  <div class="stalux-post-source-link-row">
96
109
  <a
@@ -105,7 +118,11 @@ const mdSourceUrl = exportMd ? `${Astro.url.pathname.replace(/\/$/, "")}.md` : "
105
118
  )}
106
119
  </header>
107
120
 
108
- <ArticleSummary description={props.description} label={t("post.summary")} />
121
+ <ArticleSummary
122
+ description={props.description}
123
+ label={t("post.summary")}
124
+ transitionName={`${transitionPrefix}-summary`}
125
+ />
109
126
 
110
127
  <!-- Article Body -->
111
128
  <section
@@ -113,6 +130,8 @@ const mdSourceUrl = exportMd ? `${Astro.url.pathname.replace(/\/$/, "")}.md` : "
113
130
  data-pagefind-body
114
131
  data-stalux-post-id={props.postId === undefined ? undefined : String(props.postId)}
115
132
  data-pagefind-filter={props.postId === undefined ? undefined : "post[data-stalux-post-id]"}
133
+ transition:name={`${transitionPrefix}-body`}
134
+ transition:animate="none"
116
135
  >
117
136
  <slot />
118
137
  </section>
@@ -12,11 +12,25 @@ import { getSidebarAnimation } from "@utils/view-transitions";
12
12
  transition:name="left-sidebar"
13
13
  transition:animate={getSidebarAnimation("left")}
14
14
  >
15
- <PostAuthor />
16
- <div class="stalux-sidebar-container">
15
+ <div
16
+ class="stalux-sidebar-container"
17
+ transition:name="stalux-article-left-author"
18
+ transition:animate="fade"
19
+ >
20
+ <PostAuthor />
21
+ </div>
22
+ <div
23
+ class="stalux-sidebar-container"
24
+ transition:name="stalux-article-left-social"
25
+ transition:animate="fade"
26
+ >
17
27
  <SocialMedia />
18
28
  </div>
19
- <div class="stalux-sidebar-container">
29
+ <div
30
+ class="stalux-sidebar-container"
31
+ transition:name="stalux-article-left-random"
32
+ transition:animate="fade"
33
+ >
20
34
  <RandomPost />
21
35
  </div>
22
36
  </aside>
@@ -21,17 +21,33 @@ const { headings = [], tags = [], categories = [] } = Astro.props;
21
21
  transition:name="right-sidebar"
22
22
  transition:animate={getSidebarAnimation("right")}
23
23
  >
24
- <div class="stalux-sidebar-container">
24
+ <div
25
+ class="stalux-sidebar-container"
26
+ transition:name="stalux-article-right-date"
27
+ transition:animate="none"
28
+ >
25
29
  <PostDate />
26
30
  <Weather />
27
31
  </div>
28
- <div class="stalux-sidebar-container">
32
+ <div
33
+ class="stalux-sidebar-container"
34
+ transition:name="stalux-article-right-toc"
35
+ transition:animate="none"
36
+ >
29
37
  <PostToc headings={headings} />
30
38
  </div>
31
- <div class="stalux-sidebar-container">
39
+ <div
40
+ class="stalux-sidebar-container"
41
+ transition:name="stalux-article-right-tags"
42
+ transition:animate="none"
43
+ >
32
44
  <PostTaxonomy kind="tag" items={tags} />
33
45
  </div>
34
- <div class="stalux-sidebar-container">
46
+ <div
47
+ class="stalux-sidebar-container"
48
+ transition:name="stalux-article-right-categories"
49
+ transition:animate="none"
50
+ >
35
51
  <PostTaxonomy kind="category" items={categories} />
36
52
  </div>
37
53
  </aside>
@@ -31,12 +31,13 @@ const tocItems = headings
31
31
 
32
32
  <nav
33
33
  transition:name="toc-container"
34
+ transition:animate="none"
34
35
  data-toc-active-class="stalux-toc-active"
35
36
  aria-label={t("taxonomy.toc")}
36
37
  >
37
38
  <h3 class="stalux-toc-title">{title}</h3>
38
39
  {tocItems.length > 0 ? (
39
- <ul class="stalux-toc-list" transition:name="toc-content">
40
+ <ul class="stalux-toc-list" transition:name="toc-content" transition:animate="fade">
40
41
  {tocItems.map((heading) => (
41
42
  <li>
42
43
  <a
@@ -50,7 +51,9 @@ const tocItems = headings
50
51
  ))}
51
52
  </ul>
52
53
  ) : (
53
- <div class="stalux-toc-empty">{t("taxonomy.noToc")}</div>
54
+ <div class="stalux-toc-empty" transition:name="toc-content" transition:animate="fade">
55
+ {t("taxonomy.noToc")}
56
+ </div>
54
57
  )}
55
58
  </nav>
56
59
 
@@ -27,7 +27,11 @@ if (typeKey === "category" || typeKey === "categories" || typeKey === "分类")
27
27
  }
28
28
  ---
29
29
 
30
- <header class="stalux-tag-header">
30
+ <header
31
+ class="stalux-tag-header"
32
+ transition:name="stalux-taxonomy-page-header-shell"
33
+ transition:animate="none"
34
+ >
31
35
  <h1 class="stalux-tag-page-title">
32
36
  <span class="stalux-tag-category-icon">
33
37
  <svg
package/src/index.ts CHANGED
@@ -32,10 +32,14 @@ import { expressiveCode } from "./expressive-code";
32
32
  import { staluxComponentsAlias } from "./internal/components-plugin";
33
33
  import {
34
34
  clearPageFontSubsets,
35
+ collectSharedFontGlyphs,
36
+ createSharedFontGlyphs,
35
37
  readLinkedStylesheetText,
36
38
  removePageFontStyles,
37
39
  resolveFontInputs,
40
+ sharedFontCacheKey,
38
41
  writePageFontSubsetIntoDocumentAsync,
42
+ writeSharedFontSubsetsAsync,
39
43
  } from "./internal/font-slices";
40
44
  import { findHtmlImages } from "./internal/html-images";
41
45
  import { createHtmlMutationEmitter } from "./internal/html-output";
@@ -61,7 +65,7 @@ import { imageCaptions } from "./plugins/image-captions";
61
65
  import { temml } from "./plugins/satteri-temml";
62
66
  import { describeError } from "./utils/diagnostics";
63
67
 
64
- // 代码字体通过 Astro Fonts API 注入;正文字体由 native N-API 按页面生成子集。
68
+ // 代码字体通过 Astro Fonts API 注入;正文字体由 native N-API 生成共享壳层与页面子集。
65
69
  function buildFontsConfig(codeNormal: string, codeItalic?: string) {
66
70
  return [
67
71
  {
@@ -308,7 +312,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
308
312
  }
309
313
  }
310
314
 
311
- // 7. 字体:解析本地字体输入;正文字体由 native N-API 在 build:done 阶段逐页生成。
315
+ // 7. 字体:解析本地字体输入;壳层共享子集与页面正文字形在 build:done 阶段生成。
312
316
  try {
313
317
  const sliced = await resolveFontInputs(process.cwd(), fontsLogger);
314
318
  if (sliced) {
@@ -371,6 +375,26 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
371
375
  const parserEntry = resolveModule("parse5");
372
376
  const imageSizeEntry = resolveModule("image-size");
373
377
  const nativeEntry = resolveModule("@xingwangzhe/cjk-font-split-native");
378
+ const pageEntries = htmlFiles.filter((entry) => entry.endsWith(".html"));
379
+ let sharedFonts: Awaited<ReturnType<typeof writeSharedFontSubsetsAsync>> = [];
380
+ let sharedFontKey = "";
381
+ if (pageFontData) {
382
+ const sharedGlyphs = createSharedFontGlyphs();
383
+ await fs.mkdir(pageFontData.outputDir, { recursive: true });
384
+ await fs.mkdir(pageFontData.cacheDir, { recursive: true });
385
+ // The common shell is static across soft navigations; scan once, then
386
+ // reuse each component's content-addressed subset on every route.
387
+ await forEachBounded(pageEntries, 4, async (entry) => {
388
+ const html = await fs.readFile(path.join(outDir, entry), "utf8");
389
+ collectSharedFontGlyphs(parse(html), sharedGlyphs);
390
+ });
391
+ sharedFonts = await writeSharedFontSubsetsAsync(
392
+ pageFontData.fontBuffer,
393
+ sharedGlyphs,
394
+ pageFontData.cacheDir,
395
+ );
396
+ sharedFontKey = sharedFontCacheKey(sharedFonts);
397
+ }
374
398
  const outputCache = await createPageOutputCache(
375
399
  path.resolve("node_modules/.astro/stalux-page-output"),
376
400
  [
@@ -394,16 +418,24 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
394
418
  pageFontData?.fontBuffer,
395
419
  // Track referenced files regardless of extension, including SVG/AVIF.
396
420
  htmlFiles.map((entry) => path.join(outDir, entry)),
421
+ sharedFontKey,
397
422
  );
398
423
  let cacheHits = 0;
399
424
  let optimizedImagePages = 0;
400
425
  let pageFontPages = 0;
426
+ let pageFontSubsetCacheHits = 0;
401
427
  if (pageFontData) await fs.mkdir(pageFontData.outputDir, { recursive: true });
402
428
  if (pageFontData) await fs.mkdir(pageFontData.cacheDir, { recursive: true });
403
429
  const referencedPageFonts = new Set<string>();
430
+ for (const sharedFont of sharedFonts) {
431
+ copyFileSync(
432
+ sharedFont.sourcePath,
433
+ path.join(pageFontData?.outputDir ?? outDir, sharedFont.filename),
434
+ );
435
+ referencedPageFonts.add(sharedFont.filename);
436
+ }
404
437
  const linkedCssTextCache = new Map<string, string>();
405
438
  const dimensionsCache = new Map<string, Promise<ImageDimensions | undefined>>();
406
- const pageEntries = htmlFiles.filter((entry) => entry.endsWith(".html"));
407
439
  const processPage = async (entry: string) => {
408
440
  const output = path.join(outDir, entry);
409
441
  const before = await fs.readFile(output, "utf8");
@@ -454,9 +486,11 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
454
486
  pageFontData.cacheDir,
455
487
  "/_astro/fonts/",
456
488
  linkedStylesheetText,
489
+ sharedFonts,
457
490
  );
458
491
  if (pageFont) {
459
492
  pageFontPages++;
493
+ if (pageFont.cacheHit) pageFontSubsetCacheHits++;
460
494
  cachedFont = {
461
495
  filename: pageFont.filename,
462
496
  sourcePath: pageFont.sourcePath,
@@ -492,8 +526,17 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
492
526
  }
493
527
  logger.info(`applied image loading hints to ${optimizedImagePages} HTML pages`);
494
528
  logger.info(
495
- `generated page-specific body font subsets for ${pageFontPages} HTML pages`,
529
+ `generated page-specific body font subsets for ${pageFontPages} HTML pages (subset cache ${pageFontSubsetCacheHits}/${pageFontPages})`,
496
530
  );
531
+ if (sharedFonts.length > 0) {
532
+ const sharedSummary = sharedFonts
533
+ .map(
534
+ ({ group, chars, cacheHit }) =>
535
+ `${group} ${chars.length} glyphs${cacheHit ? " (cached)" : ""}`,
536
+ )
537
+ .join(", ");
538
+ logger.info(`generated shared shell font subsets (${sharedSummary})`);
539
+ }
497
540
  logger.info(
498
541
  `HTML postprocessing ${(performance.now() - started).toFixed(1)}ms; cache ${cacheHits}/${htmlFiles.filter((entry) => entry.endsWith(".html")).length}`,
499
542
  );