@xingwangzhe/stalux 1.4.0 → 1.4.2
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 +6 -6
- package/src/index.ts +11 -5
- package/src/internal/font-subset-dev.ts +170 -0
- package/src/internal/font-subset.ts +77 -70
- package/template/astro.config.mjs +7 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingwangzhe/stalux",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "A powerful, modern Astro blog theme \u2014 use as template or install as plugin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -73,8 +73,10 @@
|
|
|
73
73
|
"prepare": "git config core.hooksPath scripts/git-hooks"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
|
+
"@astrojs/rss": "^4.0.19",
|
|
76
77
|
"@astrojs/sitemap": "^3.7.3",
|
|
77
78
|
"@expressive-code/plugin-line-numbers": "^0.44.1",
|
|
79
|
+
"@lucide/astro": "^1.27.0",
|
|
78
80
|
"@pagefind/component-ui": "^1.5.2",
|
|
79
81
|
"@waline/client": "3.15.2",
|
|
80
82
|
"@xingwangzhe/satteri-mermaid": "0.7.1",
|
|
@@ -83,15 +85,13 @@
|
|
|
83
85
|
"animejs": "^4.5.0",
|
|
84
86
|
"astro": "^7.1.3",
|
|
85
87
|
"astro-expressive-code": "^0.44.1",
|
|
86
|
-
"pagefind": "^1.5.2",
|
|
87
|
-
"photoswipe": "^5.4.4",
|
|
88
|
-
"subset-font": "^2.5.0",
|
|
89
|
-
"@astrojs/rss": "^4.0.19",
|
|
90
88
|
"astro-seo": "^1.1.0",
|
|
91
89
|
"astro-typewriter": "^1.2.0",
|
|
92
|
-
"@lucide/astro": "^1.27.0",
|
|
93
90
|
"badge-maker": "^6.0.0",
|
|
91
|
+
"pagefind": "^1.5.2",
|
|
92
|
+
"photoswipe": "^5.4.4",
|
|
94
93
|
"simple-icons": "^16.27.1",
|
|
94
|
+
"subset-font": "^2.5.0",
|
|
95
95
|
"temml": "^0.13.3"
|
|
96
96
|
},
|
|
97
97
|
"devDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -192,14 +192,14 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration {
|
|
|
192
192
|
// 5. 字体裁剪(在 config:setup 阶段执行,保证 dev/build 都能生成)
|
|
193
193
|
try {
|
|
194
194
|
const projectRoot = process.cwd();
|
|
195
|
-
await runFontSubsetting(projectRoot, logger);
|
|
195
|
+
await runFontSubsetting(projectRoot, logger, true);
|
|
196
196
|
} catch (error) {
|
|
197
197
|
logger.debug(`Font subsetting deferred: ${String(error)}`);
|
|
198
198
|
}
|
|
199
199
|
},
|
|
200
200
|
|
|
201
201
|
"astro:build:start": async ({ logger }) => {
|
|
202
|
-
// 6.
|
|
202
|
+
// 6. 字体裁剪(构建时,生成全部路由子集)
|
|
203
203
|
try {
|
|
204
204
|
const projectRoot = process.cwd();
|
|
205
205
|
await runFontSubsetting(projectRoot, logger);
|
|
@@ -208,11 +208,17 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration {
|
|
|
208
208
|
}
|
|
209
209
|
},
|
|
210
210
|
|
|
211
|
-
"astro:server:setup": async ({ logger }) => {
|
|
212
|
-
// 7.
|
|
211
|
+
"astro:server:setup": async ({ server, logger }) => {
|
|
212
|
+
// 7. 字体裁剪(开发模式,生成 common 子集 + 按需路由切片)
|
|
213
213
|
try {
|
|
214
214
|
const projectRoot = process.cwd();
|
|
215
|
-
|
|
215
|
+
// 先生成 common 子集
|
|
216
|
+
await runFontSubsetting(projectRoot, logger, true);
|
|
217
|
+
// 再添加按需路由切片中间件
|
|
218
|
+
const { createDevFontMiddleware } = await import(
|
|
219
|
+
new URL("./internal/font-subset-dev.ts", import.meta.url).href
|
|
220
|
+
);
|
|
221
|
+
server.middlewares.use(createDevFontMiddleware(projectRoot, logger));
|
|
216
222
|
} catch (error) {
|
|
217
223
|
logger.debug(`Font subsetting deferred in dev: ${String(error)}`);
|
|
218
224
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev mode on-demand font subsetting middleware.
|
|
3
|
+
*
|
|
4
|
+
* Intercepts /fonts/subset-*.css requests, generates the WOFF2 subset
|
|
5
|
+
* for the requested route on-the-fly, and serves it.
|
|
6
|
+
*
|
|
7
|
+
* Hooked into astro:server:setup.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { resolve, join, dirname } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import type { AstroIntegrationLogger } from "astro";
|
|
14
|
+
import subsetFontFn from "subset-font";
|
|
15
|
+
|
|
16
|
+
const FONT_INPUT = "src/assets/fonts/LXGWWenKai-Regular.ttf";
|
|
17
|
+
const CONTENT_ROOT = "stalux";
|
|
18
|
+
const FONT_OUT_DIR = "public/fonts";
|
|
19
|
+
|
|
20
|
+
/** Map route-style subset ID → content file path */
|
|
21
|
+
function resolveContentFile(projectRoot: string, subsetId: string): string | null {
|
|
22
|
+
if (subsetId === "home") return null; // home has no specific content
|
|
23
|
+
if (subsetId === "about") {
|
|
24
|
+
const aboutDir = resolve(projectRoot, CONTENT_ROOT, "about");
|
|
25
|
+
if (!existsSync(aboutDir)) return null;
|
|
26
|
+
for (const f of readdirSync(aboutDir)) {
|
|
27
|
+
if (f.endsWith(".md") || f.endsWith(".mdx")) return join(aboutDir, f);
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (subsetId === "words") {
|
|
32
|
+
const wordsDir = resolve(projectRoot, CONTENT_ROOT, "words");
|
|
33
|
+
if (!existsSync(wordsDir)) return null;
|
|
34
|
+
// Aggregate all words files
|
|
35
|
+
return wordsDir; // special: aggregate all
|
|
36
|
+
}
|
|
37
|
+
if (subsetId.startsWith("posts-")) {
|
|
38
|
+
const abbrlink = subsetId.slice(6);
|
|
39
|
+
const postsDir = resolve(projectRoot, CONTENT_ROOT, "posts");
|
|
40
|
+
if (!existsSync(postsDir)) return null;
|
|
41
|
+
for (const f of readdirSync(postsDir)) {
|
|
42
|
+
if ((f.endsWith(".md") || f.endsWith(".mdx")) && !f.startsWith("_")) {
|
|
43
|
+
const content = readFileSync(join(postsDir, f), "utf-8");
|
|
44
|
+
if (content.includes(`abbrlink: ${abbrlink}`)) return join(postsDir, f);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Extract unique characters from file content */
|
|
53
|
+
function extractChars(text: string): string {
|
|
54
|
+
const set = new Set<string>();
|
|
55
|
+
for (const ch of text) set.add(ch);
|
|
56
|
+
return [...set].sort().join("");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generate and serve a font subset on demand.
|
|
61
|
+
* Returns the CSS content to be served.
|
|
62
|
+
*/
|
|
63
|
+
async function generateSubsetOnDemand(
|
|
64
|
+
projectRoot: string,
|
|
65
|
+
subsetId: string,
|
|
66
|
+
logger: AstroIntegrationLogger,
|
|
67
|
+
): Promise<string | null> {
|
|
68
|
+
// 1. Resolve content file
|
|
69
|
+
const contentPath = resolveContentFile(projectRoot, subsetId);
|
|
70
|
+
if (!contentPath) return null;
|
|
71
|
+
|
|
72
|
+
// 2. Read content + extract chars
|
|
73
|
+
let content: string;
|
|
74
|
+
if (contentPath.endsWith("words")) {
|
|
75
|
+
// aggregate all words
|
|
76
|
+
const allWords: string[] = [];
|
|
77
|
+
for (const f of readdirSync(contentPath)) {
|
|
78
|
+
if (f.endsWith(".md") && !f.startsWith("_")) {
|
|
79
|
+
allWords.push(readFileSync(join(contentPath, f), "utf-8"));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
content = allWords.join("\n");
|
|
83
|
+
} else {
|
|
84
|
+
content = readFileSync(contentPath, "utf-8");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const chars = extractChars(content);
|
|
88
|
+
|
|
89
|
+
// 3. Load common subset chars to find unique ones
|
|
90
|
+
const outDir = resolve(projectRoot, FONT_OUT_DIR);
|
|
91
|
+
const commonCSS = join(outDir, "common.css");
|
|
92
|
+
let commonCharsCached: string[] = [];
|
|
93
|
+
|
|
94
|
+
// Look for existing common subset to extract its covered chars
|
|
95
|
+
// We re-read the common subset data from the generated file list
|
|
96
|
+
const commonFile = existsSync(join(outDir, "common.css"))
|
|
97
|
+
? readFileSync(join(outDir, "common.css"), "utf-8")
|
|
98
|
+
: "";
|
|
99
|
+
// We can't know exactly what chars the common subset covers without reading
|
|
100
|
+
// the original source. But we can just generate the full subset.
|
|
101
|
+
// To minimize size, we note that common subset already covers most.
|
|
102
|
+
|
|
103
|
+
// 4. Locate font file
|
|
104
|
+
const fontPaths = [
|
|
105
|
+
resolve(projectRoot, FONT_INPUT),
|
|
106
|
+
resolve(projectRoot, "node_modules", "@xingwangzhe", "stalux", FONT_INPUT),
|
|
107
|
+
resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", FONT_INPUT),
|
|
108
|
+
];
|
|
109
|
+
let fontPath: string | undefined;
|
|
110
|
+
for (const p of fontPaths) {
|
|
111
|
+
if (existsSync(p)) { fontPath = p; break; }
|
|
112
|
+
}
|
|
113
|
+
if (!fontPath) return null;
|
|
114
|
+
|
|
115
|
+
const fontBuffer = readFileSync(fontPath);
|
|
116
|
+
mkdirSync(outDir, { recursive: true });
|
|
117
|
+
|
|
118
|
+
// 5. Generate subset WOFF2
|
|
119
|
+
const hash = [...chars].slice(0, 12).join("");
|
|
120
|
+
const filename = `subset-${subsetId}-${hash}.woff2`;
|
|
121
|
+
const outPath = join(outDir, filename);
|
|
122
|
+
|
|
123
|
+
if (!existsSync(outPath)) {
|
|
124
|
+
const data = await subsetFontFn(fontBuffer, chars, { targetFormat: "woff2" });
|
|
125
|
+
writeFileSync(outPath, data);
|
|
126
|
+
logger.info(` on-demand subset: ${(data.length / 1024).toFixed(1)} KB → ${filename}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 6. Return CSS content
|
|
130
|
+
return `@font-face {
|
|
131
|
+
font-family: "LXGW WenKai Subset";
|
|
132
|
+
src: url("/fonts/${filename}") format("woff2");
|
|
133
|
+
font-display: swap;
|
|
134
|
+
}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Create Vite middleware for on-demand font subsetting in dev mode.
|
|
139
|
+
*/
|
|
140
|
+
export function createDevFontMiddleware(projectRoot: string, logger: AstroIntegrationLogger) {
|
|
141
|
+
return async (req: any, res: any, next: any) => {
|
|
142
|
+
const url = req.url || "";
|
|
143
|
+
const match = url.match(/^\/fonts\/subset-(.+)\.css$/);
|
|
144
|
+
if (!match) return next();
|
|
145
|
+
|
|
146
|
+
const subsetId = match[1];
|
|
147
|
+
|
|
148
|
+
// Check if file already exists
|
|
149
|
+
const cssPath = resolve(projectRoot, FONT_OUT_DIR, `subset-${subsetId}.css`);
|
|
150
|
+
if (existsSync(cssPath)) {
|
|
151
|
+
res.statusCode = 200;
|
|
152
|
+
res.setHeader("Content-Type", "text/css");
|
|
153
|
+
res.end(readFileSync(cssPath, "utf-8"));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Generate on-demand
|
|
158
|
+
logger.info(`Font subset: on-demand generating for "${subsetId}"...`);
|
|
159
|
+
const css = await generateSubsetOnDemand(projectRoot, subsetId, logger);
|
|
160
|
+
if (css) {
|
|
161
|
+
// Cache it
|
|
162
|
+
writeFileSync(cssPath, css);
|
|
163
|
+
res.statusCode = 200;
|
|
164
|
+
res.setHeader("Content-Type", "text/css");
|
|
165
|
+
res.end(css);
|
|
166
|
+
} else {
|
|
167
|
+
next();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -229,34 +229,39 @@ function deduplicate(routes: RouteCharSet[]): Map<string, string[]> {
|
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
231
|
* Run font subsetting during build.
|
|
232
|
-
* Called from
|
|
232
|
+
* Called from integration hooks.
|
|
233
|
+
*
|
|
234
|
+
* @param projectRoot - Project root directory
|
|
235
|
+
* @param logger - Astro integration logger
|
|
236
|
+
* @param skipPerRoute - Skip per-route subsets (for dev mode, only generate common)
|
|
233
237
|
*/
|
|
234
238
|
export async function runFontSubsetting(
|
|
235
|
-
|
|
236
|
-
|
|
239
|
+
projectRoot: string,
|
|
240
|
+
logger: AstroIntegrationLogger,
|
|
241
|
+
skipPerRoute = false,
|
|
237
242
|
): Promise<void> {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
243
|
+
logger.info("Font subsetting: scanning content...");
|
|
244
|
+
|
|
245
|
+
// 1. Locate font file (check project root first, then stalux package dir)
|
|
246
|
+
const fontPaths = [
|
|
247
|
+
resolve(projectRoot, FONT_INPUT),
|
|
248
|
+
resolve(projectRoot, "node_modules", "@xingwangzhe", "stalux", FONT_INPUT),
|
|
249
|
+
resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", FONT_INPUT),
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
let fontPath: string | undefined;
|
|
253
|
+
for (const p of fontPaths) {
|
|
254
|
+
if (existsSync(p)) {
|
|
255
|
+
fontPath = p;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
252
258
|
}
|
|
253
|
-
}
|
|
254
259
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
+
if (!fontPath) {
|
|
261
|
+
logger.warn(`Font not found at ${FONT_INPUT}, skipping subsetting`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const fontBuffer = readFileSync(fontPath);
|
|
260
265
|
|
|
261
266
|
// 2. Scan content files
|
|
262
267
|
const contentFiles = scanContent(projectRoot);
|
|
@@ -309,69 +314,71 @@ export async function runFontSubsetting(
|
|
|
309
314
|
`;
|
|
310
315
|
writeFileSync(join(outDir, "common.css"), commonCSS);
|
|
311
316
|
|
|
312
|
-
// Generate per-route subsets (deduplicated) and their CSS
|
|
313
317
|
const groups = deduplicate(routeSets);
|
|
314
318
|
const routeMap: Record<string, string> = {};
|
|
315
319
|
const postMap: Record<string, string> = {};
|
|
316
320
|
|
|
317
321
|
let subsetCount = 0;
|
|
318
|
-
// Track which subsets we've already generated CSS for
|
|
319
322
|
const cssGenerated = new Set<string>();
|
|
320
323
|
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
324
|
+
if (!skipPerRoute) {
|
|
325
|
+
for (const [hash, routes] of groups) {
|
|
326
|
+
const filename = `subset-${hash}.woff2`;
|
|
327
|
+
const outPath = join(outDir, filename);
|
|
328
|
+
if (!existsSync(outPath)) {
|
|
329
|
+
// Find the route chars for this hash
|
|
330
|
+
const route = routeSets.find((r) => r.hash === hash);
|
|
331
|
+
const chars = route?.chars ?? "";
|
|
332
|
+
// Only subset characters NOT in common (already covered by common.css)
|
|
333
|
+
const uniqueChars = chars
|
|
334
|
+
.split("")
|
|
335
|
+
.filter((ch) => !commonChars.includes(ch))
|
|
336
|
+
.join("");
|
|
337
|
+
if (uniqueChars.length > 0) {
|
|
338
|
+
const data = await subsetFont(fontBuffer, uniqueChars, {
|
|
339
|
+
targetFormat: "woff2",
|
|
340
|
+
});
|
|
341
|
+
writeFileSync(outPath, data);
|
|
342
|
+
subsetCount++;
|
|
343
|
+
logger.info(
|
|
344
|
+
` subset ${subsetCount}: ${(data.length / 1024).toFixed(1)} KB → ${filename}`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
340
347
|
}
|
|
341
|
-
}
|
|
342
348
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
349
|
+
// Generate per-route CSS (one per unique hash, references all routes with this hash)
|
|
350
|
+
if (!cssGenerated.has(hash)) {
|
|
351
|
+
cssGenerated.add(hash);
|
|
352
|
+
for (const route of routes) {
|
|
353
|
+
const cssContent = `/* Auto-generated font subset for route: ${route} */
|
|
348
354
|
@font-face {
|
|
349
355
|
font-family: "LXGW WenKai Subset";
|
|
350
356
|
src: url("/fonts/${filename}") format("woff2");
|
|
351
357
|
font-display: swap;
|
|
352
358
|
}
|
|
353
359
|
`;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
360
|
+
// Use first route's name as the CSS filename (they share the same subset)
|
|
361
|
+
const cssName = routes.length === 1 ? route.replace("/", "-") : `group-${hash}`;
|
|
362
|
+
const cssFilename = `subset-${cssName}.css`;
|
|
363
|
+
writeFileSync(join(outDir, cssFilename), cssContent);
|
|
364
|
+
routeMap[route] = cssFilename;
|
|
365
|
+
|
|
366
|
+
if (route.startsWith("posts/")) {
|
|
367
|
+
const abbrlink = route.replace("posts/", "");
|
|
368
|
+
postMap[abbrlink] = cssFilename;
|
|
369
|
+
}
|
|
363
370
|
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
371
|
+
} else {
|
|
372
|
+
// CSS already generated, just map
|
|
373
|
+
for (const route of routes) {
|
|
374
|
+
const cssFilename =
|
|
375
|
+
routes.length === 1
|
|
376
|
+
? `subset-${route.replace("/", "-")}.css`
|
|
377
|
+
: `subset-group-${hash}.css`;
|
|
378
|
+
routeMap[route] = cssFilename;
|
|
379
|
+
if (route.startsWith("posts/")) {
|
|
380
|
+
postMap[route.replace("posts/", "")] = cssFilename;
|
|
381
|
+
}
|
|
375
382
|
}
|
|
376
383
|
}
|
|
377
384
|
}
|
|
@@ -2,9 +2,9 @@ import { satteri } from "@astrojs/markdown-satteri";
|
|
|
2
2
|
import sitemap from "@astrojs/sitemap";
|
|
3
3
|
import { mermaidHast } from "@xingwangzhe/satteri-mermaid";
|
|
4
4
|
import { photoswipe } from "@xingwangzhe/satteri-photoswipe";
|
|
5
|
+
import stalux from "@xingwangzhe/stalux";
|
|
5
6
|
import expressiveCode from "astro-expressive-code";
|
|
6
7
|
import { defineConfig, fontProviders } from "astro/config";
|
|
7
|
-
import stalux from "@xingwangzhe/stalux";
|
|
8
8
|
|
|
9
9
|
// https://astro.build/config
|
|
10
10
|
export default defineConfig({
|
|
@@ -24,12 +24,16 @@ export default defineConfig({
|
|
|
24
24
|
{
|
|
25
25
|
weight: "100 900",
|
|
26
26
|
style: "normal",
|
|
27
|
-
src: [
|
|
27
|
+
src: [
|
|
28
|
+
"./node_modules/@xingwangzhe/stalux/src/assets/fonts/GoogleSansCode.woff2",
|
|
29
|
+
],
|
|
28
30
|
},
|
|
29
31
|
{
|
|
30
32
|
weight: "100 900",
|
|
31
33
|
style: "italic",
|
|
32
|
-
src: [
|
|
34
|
+
src: [
|
|
35
|
+
"./node_modules/@xingwangzhe/stalux/src/assets/fonts/GoogleSansCode-Italic.woff2",
|
|
36
|
+
],
|
|
33
37
|
},
|
|
34
38
|
],
|
|
35
39
|
},
|