@xingwangzhe/stalux 1.35.1 → 1.35.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +23 -23
- package/src/components/stalux/archives/archivesList.astro +58 -50
- package/src/components/stalux/categories/categoriesList.astro +4 -4
- package/src/components/stalux/layout/footer.astro +8 -4
- package/src/components/stalux/layout/head.astro +3 -3
- package/src/components/stalux/layout/navs.astro +15 -15
- package/src/components/stalux/links/linkList.astro +4 -4
- package/src/components/stalux/links/socialMedia.astro +35 -35
- package/src/components/stalux/posts/PostTaxonomy.astro +14 -10
- package/src/components/stalux/posts/postCC.astro +7 -7
- package/src/components/stalux/posts/postCard.astro +47 -41
- package/src/components/stalux/posts/postContent.astro +49 -49
- package/src/components/stalux/posts/postList.astro +4 -4
- package/src/components/stalux/posts/postToc.astro +16 -16
- package/src/components/stalux/tags/tagsCloud.astro +20 -20
- package/src/components/stalux/words/wordCard.astro +28 -28
- package/src/components/stalux/words/wordList.astro +17 -17
- package/src/index.ts +98 -20
- package/src/internal/font-slices.ts +56 -6
- package/src/internal/html-images.ts +23 -2
- package/src/internal/image-dimensions.ts +60 -33
- package/src/internal/page-output-cache.ts +78 -0
- package/src/layouts/PostLayout.astro +9 -7
- package/src/layouts/Stalux.astro +4 -4
- package/src/pages/about.astro +6 -6
- package/src/pages/categories/[category].astro +4 -4
- package/src/pages/tags/[tag].astro +5 -1
- package/src/plugins/feature-flags.ts +5 -17
|
@@ -10,7 +10,10 @@ export interface HtmlImage {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/** Source ranges preserve scripts and code-copy attributes byte-for-byte. */
|
|
13
|
-
export function findHtmlImages(
|
|
13
|
+
export function findHtmlImages(
|
|
14
|
+
html: string,
|
|
15
|
+
document = parse(html, { sourceCodeLocationInfo: true }),
|
|
16
|
+
): HtmlImage[] {
|
|
14
17
|
const images: HtmlImage[] = [];
|
|
15
18
|
function visit(node: DefaultTreeAdapterMap["node"], body?: number) {
|
|
16
19
|
if ("tagName" in node) {
|
|
@@ -35,10 +38,28 @@ export function findHtmlImages(html: string): HtmlImage[] {
|
|
|
35
38
|
}
|
|
36
39
|
if ("childNodes" in node) for (const child of node.childNodes) visit(child, body);
|
|
37
40
|
}
|
|
38
|
-
visit(
|
|
41
|
+
visit(document);
|
|
39
42
|
return images.sort((left, right) => left.start - right.start);
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
export function imageLoadingUpdates(images: HtmlImage[]): Map<HtmlImage, Record<string, string>> {
|
|
46
|
+
const firstSeen = new Set<number>();
|
|
47
|
+
const changes = new Map<HtmlImage, Record<string, string>>();
|
|
48
|
+
for (const image of images) {
|
|
49
|
+
if (image.body === undefined) continue;
|
|
50
|
+
const first = !firstSeen.has(image.body);
|
|
51
|
+
firstSeen.add(image.body);
|
|
52
|
+
const updates: Record<string, string> = {
|
|
53
|
+
decoding: image.attributes.get("decoding") || "async",
|
|
54
|
+
loading: first ? "eager" : "lazy",
|
|
55
|
+
};
|
|
56
|
+
if (first) updates.fetchpriority = "high";
|
|
57
|
+
else if (image.attributes.get("fetchpriority") === "high") updates.fetchpriority = "auto";
|
|
58
|
+
changes.set(image, updates);
|
|
59
|
+
}
|
|
60
|
+
return changes;
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
export function updateImageTag(image: HtmlImage, updates: Record<string, string>): string {
|
|
43
64
|
let tag = image.tag;
|
|
44
65
|
const edits: Array<{ start: number; end: number; value: string }> = [];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { findHtmlImages, updateImageTag } from "./html-images";
|
|
3
|
+
import { findHtmlImages, type HtmlImage, imageLoadingUpdates, updateImageTag } from "./html-images";
|
|
4
4
|
|
|
5
5
|
export type ImageDimensions = { width: number; height: number };
|
|
6
6
|
|
|
@@ -72,41 +72,68 @@ export function readImageDimensions(data: Buffer): ImageDimensions | undefined {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/** Add width/height to same-origin emitted images so their space is reserved before download. */
|
|
75
|
-
export async function addLocalImageDimensions(
|
|
76
|
-
|
|
75
|
+
export async function addLocalImageDimensions(
|
|
76
|
+
html: string,
|
|
77
|
+
outputDir: string,
|
|
78
|
+
options: {
|
|
79
|
+
images?: HtmlImage[];
|
|
80
|
+
cache?: Map<string, Promise<ImageDimensions | undefined>>;
|
|
81
|
+
loadingPolicy?: boolean;
|
|
82
|
+
dependencies?: Set<string>;
|
|
83
|
+
} = {},
|
|
84
|
+
): Promise<string> {
|
|
85
|
+
const dimensionsCache =
|
|
86
|
+
options.cache ?? new Map<string, Promise<ImageDimensions | undefined>>();
|
|
77
87
|
const root = path.resolve(outputDir);
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
const images = options.images ?? findHtmlImages(html);
|
|
89
|
+
const loading = options.loadingPolicy ? imageLoadingUpdates(images) : new Map();
|
|
90
|
+
const replacements: Array<{ start: number; end: number; tag: string }> = [];
|
|
91
|
+
for (const image of images) {
|
|
92
|
+
const updates: Record<string, string> = { ...loading.get(image) };
|
|
82
93
|
const src = image.attributes.get("src");
|
|
83
|
-
if (!
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
if (src && !(image.attributes.has("width") && image.attributes.has("height"))) {
|
|
95
|
+
let filePath: string | undefined;
|
|
96
|
+
try {
|
|
97
|
+
const url = new URL(src, "https://stalux.invalid");
|
|
98
|
+
if (url.origin === "https://stalux.invalid") {
|
|
99
|
+
const candidate = path.resolve(root, `.${decodeURIComponent(url.pathname)}`);
|
|
100
|
+
if (candidate === root || candidate.startsWith(`${root}${path.sep}`))
|
|
101
|
+
filePath = candidate;
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// Invalid image URLs still receive the loading policy.
|
|
105
|
+
}
|
|
106
|
+
if (filePath) {
|
|
107
|
+
options.dependencies?.add(filePath);
|
|
108
|
+
let dimensions = dimensionsCache.get(filePath);
|
|
109
|
+
if (!dimensions) {
|
|
110
|
+
dimensions = readFile(filePath)
|
|
111
|
+
.then(readImageDimensions)
|
|
112
|
+
.catch(() => undefined);
|
|
113
|
+
dimensionsCache.set(filePath, dimensions);
|
|
114
|
+
}
|
|
115
|
+
const size = await dimensions;
|
|
116
|
+
if (size && size.width > 0 && size.height > 0) {
|
|
117
|
+
if (!image.attributes.has("width")) updates.width = String(size.width);
|
|
118
|
+
if (!image.attributes.has("height")) updates.height = String(size.height);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
92
121
|
}
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
.catch(() => undefined);
|
|
100
|
-
dimensionsCache.set(filePath, dimensions);
|
|
122
|
+
if (Object.keys(updates).length) {
|
|
123
|
+
replacements.push({
|
|
124
|
+
start: image.start,
|
|
125
|
+
end: image.end,
|
|
126
|
+
tag: updateImageTag(image, updates),
|
|
127
|
+
});
|
|
101
128
|
}
|
|
102
|
-
const size = await dimensions;
|
|
103
|
-
if (!size || size.width <= 0 || size.height <= 0) continue;
|
|
104
|
-
|
|
105
|
-
const updates: Record<string, string> = {};
|
|
106
|
-
if (!image.attributes.has("width")) updates.width = String(size.width);
|
|
107
|
-
if (!image.attributes.has("height")) updates.height = String(size.height);
|
|
108
|
-
const updated = updateImageTag(image, updates);
|
|
109
|
-
html = html.slice(0, image.start) + updated + html.slice(image.end);
|
|
110
129
|
}
|
|
111
|
-
return html;
|
|
130
|
+
if (!replacements.length) return html;
|
|
131
|
+
const parts: string[] = [];
|
|
132
|
+
let offset = 0;
|
|
133
|
+
for (const replacement of replacements) {
|
|
134
|
+
parts.push(html.slice(offset, replacement.start), replacement.tag);
|
|
135
|
+
offset = replacement.end;
|
|
136
|
+
}
|
|
137
|
+
parts.push(html.slice(offset));
|
|
138
|
+
return parts.join("");
|
|
112
139
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface CachedPageOutput {
|
|
6
|
+
html: string;
|
|
7
|
+
font?: { filename: string; sourcePath: string };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
|
|
11
|
+
|
|
12
|
+
/** Content hashes include dependencies even when their public URL has not changed. */
|
|
13
|
+
export async function createPageOutputCache(
|
|
14
|
+
directory: string,
|
|
15
|
+
dependencies: readonly string[],
|
|
16
|
+
font?: Buffer,
|
|
17
|
+
assets: readonly string[] = [],
|
|
18
|
+
) {
|
|
19
|
+
const hash = createHash("sha256").update("stalux-page-output-v1\0");
|
|
20
|
+
if (font) hash.update(font);
|
|
21
|
+
for (const dependency of [...dependencies].sort()) {
|
|
22
|
+
hash.update(dependency).update("\0");
|
|
23
|
+
hash.update(await fs.readFile(dependency)).update("\0");
|
|
24
|
+
}
|
|
25
|
+
const namespace = path.join(directory, hash.digest("hex"));
|
|
26
|
+
await fs.mkdir(namespace, { recursive: true });
|
|
27
|
+
const assetDigests = new Map<string, string>();
|
|
28
|
+
for (const asset of assets) assetDigests.set(asset, digest(await fs.readFile(asset)));
|
|
29
|
+
const key = (entry: string, html: string) => digest(`${entry}\0${html}`);
|
|
30
|
+
return {
|
|
31
|
+
async get(entry: string, html: string): Promise<CachedPageOutput | undefined> {
|
|
32
|
+
try {
|
|
33
|
+
const record = JSON.parse(
|
|
34
|
+
await fs.readFile(path.join(namespace, `${key(entry, html)}.json`), "utf8"),
|
|
35
|
+
);
|
|
36
|
+
if (typeof record.html !== "string" || digest(record.html) !== record.digest)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (!Array.isArray(record.assets)) return undefined;
|
|
39
|
+
for (const [asset, expected] of record.assets) {
|
|
40
|
+
if ((assetDigests.get(asset) ?? null) !== expected) return undefined;
|
|
41
|
+
}
|
|
42
|
+
if (record.font) {
|
|
43
|
+
if (
|
|
44
|
+
!/^page-[a-f\d]+\.woff2$/u.test(record.font.filename) ||
|
|
45
|
+
typeof record.font.sourcePath !== "string" ||
|
|
46
|
+
!(await fs.stat(record.font.sourcePath)).isFile()
|
|
47
|
+
)
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return { html: record.html, font: record.font };
|
|
51
|
+
} catch {
|
|
52
|
+
// Missing, truncated, or invalid cache entries are rebuilt from source.
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
async set(
|
|
57
|
+
entry: string,
|
|
58
|
+
html: string,
|
|
59
|
+
output: CachedPageOutput,
|
|
60
|
+
referencedAssets: readonly string[] = [],
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
const target = path.join(namespace, `${key(entry, html)}.json`);
|
|
63
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
64
|
+
await fs.writeFile(
|
|
65
|
+
temporary,
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
...output,
|
|
68
|
+
digest: digest(output.html),
|
|
69
|
+
assets: referencedAssets.map((asset) => [
|
|
70
|
+
asset,
|
|
71
|
+
assetDigests.get(asset) ?? null,
|
|
72
|
+
]),
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
await fs.rename(temporary, target);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -60,7 +60,9 @@ const walineConfig = walineSection?.serverURL
|
|
|
60
60
|
<PostContent
|
|
61
61
|
title={props.title}
|
|
62
62
|
description={props.description}
|
|
63
|
-
titleTransitionName={props.postId === undefined
|
|
63
|
+
titleTransitionName={props.postId === undefined
|
|
64
|
+
? undefined
|
|
65
|
+
: getPostTitleTransitionName(props.postId)}
|
|
64
66
|
date={props.date}
|
|
65
67
|
updated={props.updated}
|
|
66
68
|
readingMinutes={props.readingMinutes}
|
|
@@ -71,12 +73,12 @@ const walineConfig = walineSection?.serverURL
|
|
|
71
73
|
<PostCC title={props.title} author={author} url={Astro.url.href} cc={props.cc} />
|
|
72
74
|
<PostNavigation prev={props.prev} next={props.next} />
|
|
73
75
|
{commentEnabled && walineConfig?.serverURL && (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
)}
|
|
76
|
+
<WalineComment
|
|
77
|
+
{...buildWalineProps(walineConfig)}
|
|
78
|
+
path={Astro.url.pathname}
|
|
79
|
+
title={props.title}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
80
82
|
</PostContent>
|
|
81
83
|
|
|
82
84
|
<!-- Right Sidebar -->
|
package/src/layouts/Stalux.astro
CHANGED
|
@@ -83,10 +83,10 @@ const routeAnimation = getRouteAnimation(routeKind);
|
|
|
83
83
|
data-stalux-umami-url={headConfig?.umami?.url}
|
|
84
84
|
>
|
|
85
85
|
{llmPromote && (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
)}
|
|
86
|
+
<section class="stalux-ai-prompt" data-stalux-ai-prompt aria-label="AI site context">
|
|
87
|
+
<p>{llmPromote}</p>
|
|
88
|
+
</section>
|
|
89
|
+
)}
|
|
90
90
|
<div
|
|
91
91
|
class="bg-layer bg-a"
|
|
92
92
|
data-stalux-background="a"
|
package/src/pages/about.astro
CHANGED
|
@@ -47,12 +47,12 @@ const author = authorConfig.name;
|
|
|
47
47
|
cc="CC-BY-NC-SA-4.0"
|
|
48
48
|
/>
|
|
49
49
|
{commentEnabled && walineConfig?.serverURL && (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
)}
|
|
50
|
+
<WalineComment
|
|
51
|
+
{...buildWalineProps(walineConfig)}
|
|
52
|
+
path={Astro.url.pathname}
|
|
53
|
+
title={aboutPage.data.title}
|
|
54
|
+
/>
|
|
55
|
+
)}
|
|
56
56
|
</PostContent>
|
|
57
57
|
</div>
|
|
58
58
|
</Stalux>
|
|
@@ -28,10 +28,10 @@ const props = Astro.props as Props;
|
|
|
28
28
|
<Stalux
|
|
29
29
|
title={`${t("taxonomy.category")}: ${props.category} | ${siteConfig.title}`}
|
|
30
30
|
description={t("taxonomy.browseCategory", {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
})}
|
|
31
|
+
title: siteConfig.title,
|
|
32
|
+
category: props.category,
|
|
33
|
+
count: props.count,
|
|
34
|
+
})}
|
|
35
35
|
>
|
|
36
36
|
<div style="height: 3em;"></div>
|
|
37
37
|
<Navs />
|
|
@@ -28,7 +28,11 @@ const props = Astro.props as Props;
|
|
|
28
28
|
|
|
29
29
|
<Stalux
|
|
30
30
|
title={`${t("taxonomy.tag")}: ${props.tag} | ${siteConfig.title}`}
|
|
31
|
-
description={t("taxonomy.browseTag", {
|
|
31
|
+
description={t("taxonomy.browseTag", {
|
|
32
|
+
title: siteConfig.title,
|
|
33
|
+
tag: props.tag,
|
|
34
|
+
count: props.count,
|
|
35
|
+
})}
|
|
32
36
|
noindex={props.count < 3}
|
|
33
37
|
>
|
|
34
38
|
<div style="height: 3em;"></div>
|
|
@@ -16,7 +16,7 @@ import type { HastVisitorContext, MdastNode, MdastVisitorContext } from "satteri
|
|
|
16
16
|
* - 数学公式由 satteri-temml 插件直接输出 MathML,无需额外标记
|
|
17
17
|
*/
|
|
18
18
|
import { defineHastPlugin, defineMdastPlugin } from "satteri";
|
|
19
|
-
import { findHtmlImages, updateImageTag } from "../internal/html-images";
|
|
19
|
+
import { findHtmlImages, imageLoadingUpdates, updateImageTag } from "../internal/html-images";
|
|
20
20
|
import { logDetail } from "../utils/diagnostics";
|
|
21
21
|
|
|
22
22
|
declare module "satteri" {
|
|
@@ -247,22 +247,10 @@ export function applyImageLoadingPolicy(
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
export function applyHtmlImageLoadingPolicy(html: string): string {
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const body = image.body as number;
|
|
255
|
-
const first = !firstSeen.has(body);
|
|
256
|
-
firstSeen.add(body);
|
|
257
|
-
const updates: Record<string, string> = {
|
|
258
|
-
decoding: image.attributes.get("decoding") || "async",
|
|
259
|
-
loading: first ? "eager" : "lazy",
|
|
260
|
-
};
|
|
261
|
-
if (first) updates.fetchpriority = "high";
|
|
262
|
-
else if (image.attributes.get("fetchpriority") === "high")
|
|
263
|
-
updates.fetchpriority = "auto";
|
|
264
|
-
return { image, tag: updateImageTag(image, updates) };
|
|
265
|
-
});
|
|
250
|
+
const changes = [...imageLoadingUpdates(findHtmlImages(html))].map(([image, updates]) => ({
|
|
251
|
+
image,
|
|
252
|
+
tag: updateImageTag(image, updates),
|
|
253
|
+
}));
|
|
266
254
|
for (const { image, tag } of changes.reverse())
|
|
267
255
|
html = html.slice(0, image.start) + tag + html.slice(image.end);
|
|
268
256
|
return html;
|