@xingwangzhe/stalux 1.45.0 → 1.46.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.
- package/package.json +1 -1
- package/src/components/stalux/categories/categoriesList.astro +2 -2
- package/src/components/stalux/links/linkList.astro +2 -2
- package/src/components/stalux/posts/postCard.astro +1 -0
- package/src/components/stalux/posts/postList.astro +2 -2
- package/src/components/stalux/tags/tagsCloud.astro +2 -1
- package/src/components/stalux/words/wordList.astro +2 -2
- package/src/index.ts +32 -0
- package/src/internal/post-cover.ts +147 -0
- package/src/internal/social-card-renderer.ts +21 -2
- package/src/layouts/Stalux.astro +7 -0
- package/src/pages/og/[image].[format].ts +5 -2
- package/src/scripts/list-reveal.ts +41 -14
- package/src/scripts/route-motion.ts +153 -23
- package/src/scripts/route-out.ts +42 -17
- package/src/scripts/swup-router.ts +27 -11
- package/src/styles/generated-words.css +1 -1
- package/src/utils/social-image.ts +6 -3
package/package.json
CHANGED
|
@@ -7,9 +7,9 @@ import { getCategoryCountList } from "@utils/collections-stats";
|
|
|
7
7
|
const categoryList = await getCategoryCountList(Astro.logger);
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
<ul class="stalux-categories" data-stalux-motion-content>
|
|
10
|
+
<ul class="stalux-categories" data-stalux-motion-content data-stalux-reveal>
|
|
11
11
|
{categoryList.map((category) => (
|
|
12
|
-
<li>
|
|
12
|
+
<li data-stalux-reveal-item>
|
|
13
13
|
<CategoriesCard category={category} />
|
|
14
14
|
</li>
|
|
15
15
|
))}
|
|
@@ -10,9 +10,9 @@ const linksConfig = getLinksData(configEntries);
|
|
|
10
10
|
const links = linksConfig?.sites ?? [];
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
<ul class="stalux-links" data-stalux-motion-content>
|
|
13
|
+
<ul class="stalux-links" data-stalux-motion-content data-stalux-reveal>
|
|
14
14
|
{links.map((link) => (
|
|
15
|
-
<li class="stalux-link-list-item">
|
|
15
|
+
<li class="stalux-link-list-item" data-stalux-reveal-item>
|
|
16
16
|
<LinkCard link={link} />
|
|
17
17
|
</li>
|
|
18
18
|
))}
|
|
@@ -12,9 +12,9 @@ const posts = props.posts ?? (await getCollection("posts", ({ data }) => !data.d
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
<div data-stalux="postlist" class="stalux-post-list-container" data-stalux-motion-content>
|
|
15
|
-
<ul class="post-list">
|
|
15
|
+
<ul class="post-list" data-stalux-reveal>
|
|
16
16
|
{posts.map((post, index) => (
|
|
17
|
-
<li>
|
|
17
|
+
<li data-stalux-reveal-item>
|
|
18
18
|
<PostCard post={post} priority={index === 0} />
|
|
19
19
|
</li>
|
|
20
20
|
))}
|
|
@@ -25,7 +25,7 @@ const tagLinks = tagList.map((t) => ({
|
|
|
25
25
|
<div class="stalux-cloud-cards" id="tags-cards" data-stalux-tag-cards data-stalux-motion-content>
|
|
26
26
|
{tagList.length > 0 ? (
|
|
27
27
|
tagGroups.map((group) => (
|
|
28
|
-
<div class="stalux-cloud-tag-group">
|
|
28
|
+
<div class="stalux-cloud-tag-group" data-stalux-reveal>
|
|
29
29
|
{group.map(({ name, count }) => (
|
|
30
30
|
<a
|
|
31
31
|
href={`/tags/${name}/`}
|
|
@@ -33,6 +33,7 @@ const tagLinks = tagList.map((t) => ({
|
|
|
33
33
|
data-stalux-card
|
|
34
34
|
data-stalux-tag-card
|
|
35
35
|
data-stalux-control="tag-card"
|
|
36
|
+
data-stalux-reveal-item
|
|
36
37
|
data-count={count}
|
|
37
38
|
data-astro-prefetch
|
|
38
39
|
>
|
|
@@ -19,10 +19,10 @@ const sorted = [...entries].sort((a, b) => {
|
|
|
19
19
|
});
|
|
20
20
|
---
|
|
21
21
|
|
|
22
|
-
<ul class="stalux-word-list-list" data-stalux-motion-content>
|
|
22
|
+
<ul class="stalux-word-list-list" data-stalux-motion-content data-stalux-reveal>
|
|
23
23
|
{sorted.map((entry) => {
|
|
24
24
|
return (
|
|
25
|
-
<li class="stalux-word-list-item">
|
|
25
|
+
<li class="stalux-word-list-item" data-stalux-reveal-item>
|
|
26
26
|
<WordCard entry={entry} timezone={timezone} />
|
|
27
27
|
</li>
|
|
28
28
|
);
|
package/src/index.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { createInjectedRoutes } from "./internal/injected-routes";
|
|
|
49
49
|
import { markdownPropagationPlugin } from "./internal/markdown-propagation";
|
|
50
50
|
import { containMermaidSvgs } from "./internal/offscreen-layout";
|
|
51
51
|
import { createPageOutputCache } from "./internal/page-output-cache";
|
|
52
|
+
import { loadCoverEncoder, restoreCoverFiles, upgradeCoverImages } from "./internal/post-cover";
|
|
52
53
|
import { isPluginProject } from "./internal/project-mode";
|
|
53
54
|
import { syncBackgroundSvgs, syncFontAssets } from "./internal/public-assets";
|
|
54
55
|
import { createRuntimeCacheKey } from "./internal/runtime-cache-key";
|
|
@@ -121,6 +122,9 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
|
|
|
121
122
|
};
|
|
122
123
|
|
|
123
124
|
const socialCardPaths = { publicDir: "", cacheDir: "", base: "/", fontPath: "" };
|
|
125
|
+
// Content-addressed card covers, kept outside the build output so a page that
|
|
126
|
+
// is served from the page-output cache still finds its file on disk.
|
|
127
|
+
let coverCacheDir = "";
|
|
124
128
|
|
|
125
129
|
let pageFontData: { fontBuffer: Buffer; outputDir: string; cacheDir: string } | undefined;
|
|
126
130
|
|
|
@@ -378,6 +382,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
|
|
|
378
382
|
base: config.base,
|
|
379
383
|
fontPath: path.join(srcDir, "assets/fonts/LXGWWenKai-Regular.ttf"),
|
|
380
384
|
});
|
|
385
|
+
coverCacheDir = fileURLToPath(new URL("stalux-post-covers/", config.cacheDir));
|
|
381
386
|
const assetsLogger = logger.fork("stalux/assets");
|
|
382
387
|
if (pageFontData) {
|
|
383
388
|
pageFontData.outputDir = path.join(
|
|
@@ -411,6 +416,10 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
|
|
|
411
416
|
const htmlFiles = await fs.readdir(outDir, { recursive: true });
|
|
412
417
|
const sourceDir = fileURLToPath(new URL(".", import.meta.url));
|
|
413
418
|
const resolveModule = createRequire(import.meta.url).resolve;
|
|
419
|
+
const requireModule = createRequire(import.meta.url);
|
|
420
|
+
const coverEncoder = coverCacheDir
|
|
421
|
+
? loadCoverEncoder(requireModule, logger)
|
|
422
|
+
: undefined;
|
|
414
423
|
const parserEntry = resolveModule("parse5");
|
|
415
424
|
const imageSizeEntry = resolveModule("image-size");
|
|
416
425
|
const nativeEntry = resolveModule("@xingwangzhe/cjk-font-split-native");
|
|
@@ -510,6 +519,22 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
|
|
|
510
519
|
loadingPolicy: true,
|
|
511
520
|
dependencies,
|
|
512
521
|
});
|
|
522
|
+
// Card covers become content-addressed WebP. The encoded files
|
|
523
|
+
// are restored into the output at the end of every build, so a
|
|
524
|
+
// page served from the page-output cache still resolves them.
|
|
525
|
+
if (coverEncoder) {
|
|
526
|
+
try {
|
|
527
|
+
await upgradeCoverImages(images, {
|
|
528
|
+
outDir,
|
|
529
|
+
cacheDir: coverCacheDir,
|
|
530
|
+
sharp: coverEncoder,
|
|
531
|
+
dependencies,
|
|
532
|
+
logger,
|
|
533
|
+
});
|
|
534
|
+
} catch (error) {
|
|
535
|
+
logger.warn(`封面转 WebP 失败:${describeError(error)}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
513
538
|
if (pageFontData) {
|
|
514
539
|
const linkedStylesheetText = readLinkedStylesheetText(
|
|
515
540
|
"",
|
|
@@ -579,6 +604,13 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
|
|
|
579
604
|
logger.info(
|
|
580
605
|
`HTML postprocessing ${(performance.now() - started).toFixed(1)}ms; cache ${cacheHits}/${htmlFiles.filter((entry) => entry.endsWith(".html")).length}`,
|
|
581
606
|
);
|
|
607
|
+
// Covers are emitted here rather than only while a page is
|
|
608
|
+
// post-processed: a cached page skips that pass entirely but its
|
|
609
|
+
// HTML already points at these files, so they must always exist.
|
|
610
|
+
if (coverCacheDir) {
|
|
611
|
+
const restored = restoreCoverFiles({ outDir, cacheDir: coverCacheDir });
|
|
612
|
+
if (restored > 0) logger.debug(`restored ${restored} card covers`);
|
|
613
|
+
}
|
|
582
614
|
|
|
583
615
|
// 后处理:Pagefind 搜索索引。启用时任何失败都必须让构建失败,
|
|
584
616
|
// 避免发布一个页面正常但搜索已损坏的产物。
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import type { HtmlImage } from "./html-images";
|
|
12
|
+
|
|
13
|
+
/** Public directory holding the generated covers, relative to the build output. */
|
|
14
|
+
export const COVER_DIR = "stalux-covers";
|
|
15
|
+
|
|
16
|
+
// Post cards render at 640x360 and are shown on 2x screens, so 1280 is the
|
|
17
|
+
// widest useful source. Bigger originals only cost bytes.
|
|
18
|
+
const MAX_WIDTH = 1280;
|
|
19
|
+
const QUALITY = 82;
|
|
20
|
+
// Bump when any encoder input above changes, so cached files are not reused.
|
|
21
|
+
const RECIPE = "v1";
|
|
22
|
+
|
|
23
|
+
/** Formats worth re-encoding. Vector and icon sources stay untouched. */
|
|
24
|
+
const CONVERTIBLE = /\.(?:png|jpe?g|avif|tiff?|bmp)$/iu;
|
|
25
|
+
|
|
26
|
+
interface SharpPipeline {
|
|
27
|
+
resize(options: Record<string, unknown>): SharpPipeline;
|
|
28
|
+
webp(options: Record<string, unknown>): SharpPipeline;
|
|
29
|
+
toBuffer(): Promise<Buffer>;
|
|
30
|
+
}
|
|
31
|
+
type SharpFactory = (input: Buffer) => SharpPipeline;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `sharp` ships as a dependency of Astro itself and as a dev dependency of the
|
|
35
|
+
* theme, so it is present in practice — but it must never become a hard
|
|
36
|
+
* requirement, because a project using another image service would otherwise
|
|
37
|
+
* fail to build at all. Loaded through `require` because the Vite module runner
|
|
38
|
+
* is already closed by the time `astro:build:done` runs.
|
|
39
|
+
*/
|
|
40
|
+
export function loadCoverEncoder(
|
|
41
|
+
require: (id: string) => unknown,
|
|
42
|
+
logger?: { warn: (message: string) => void },
|
|
43
|
+
): SharpFactory | undefined {
|
|
44
|
+
try {
|
|
45
|
+
const loaded = require("sharp") as { default?: unknown };
|
|
46
|
+
const factory = (loaded?.default ?? loaded) as unknown;
|
|
47
|
+
return typeof factory === "function" ? (factory as SharpFactory) : undefined;
|
|
48
|
+
} catch {
|
|
49
|
+
logger?.warn("封面转 WebP 已跳过:未找到 sharp,卡片继续使用原图");
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CoverContext {
|
|
55
|
+
outDir: string;
|
|
56
|
+
cacheDir: string;
|
|
57
|
+
sharp: SharpFactory;
|
|
58
|
+
/** Source files this page depends on, so editing a cover invalidates it. */
|
|
59
|
+
dependencies?: Set<string>;
|
|
60
|
+
logger?: { warn: (message: string) => void };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const digest = (parts: readonly (string | Buffer)[]): string => {
|
|
64
|
+
const hash = createHash("sha256");
|
|
65
|
+
for (const part of parts) hash.update(part).update("\0");
|
|
66
|
+
return hash.digest("hex");
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Rewrites a marked card cover to a generated WebP.
|
|
71
|
+
*
|
|
72
|
+
* The name is content-addressed, so an unchanged cover keeps the same URL across
|
|
73
|
+
* builds and stays cacheable at the CDN, while an edited one gets a new name
|
|
74
|
+
* instead of a stale hit. The encoded file is kept in a cache directory outside
|
|
75
|
+
* the build output; `restoreCoverFiles` copies those back in at the end of every
|
|
76
|
+
* build, which is what keeps them present when a page was served from the
|
|
77
|
+
* page-output cache and this per-page pass never ran for it.
|
|
78
|
+
*/
|
|
79
|
+
export async function upgradeCoverImages(
|
|
80
|
+
images: readonly HtmlImage[],
|
|
81
|
+
context: CoverContext,
|
|
82
|
+
): Promise<number> {
|
|
83
|
+
const root = path.resolve(context.outDir);
|
|
84
|
+
const cacheDir = path.join(context.cacheDir, RECIPE);
|
|
85
|
+
let converted = 0;
|
|
86
|
+
for (const image of images) {
|
|
87
|
+
if (!image.attributes.has("data-stalux-cover")) continue;
|
|
88
|
+
const src = image.attributes.get("src");
|
|
89
|
+
if (!src?.startsWith("/")) continue;
|
|
90
|
+
let pathname: string;
|
|
91
|
+
try {
|
|
92
|
+
pathname = decodeURIComponent(new URL(src, "https://stalux.invalid").pathname);
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!CONVERTIBLE.test(pathname)) continue;
|
|
97
|
+
const source = path.resolve(root, `.${pathname}`);
|
|
98
|
+
if (!source.startsWith(`${root}${path.sep}`)) continue;
|
|
99
|
+
if (!existsSync(source)) continue;
|
|
100
|
+
context.dependencies?.add(source);
|
|
101
|
+
|
|
102
|
+
let bytes: Buffer;
|
|
103
|
+
try {
|
|
104
|
+
bytes = readFileSync(source);
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const hash = digest([RECIPE, "webp", String(MAX_WIDTH), String(QUALITY), bytes]);
|
|
109
|
+
const filename = `${hash.slice(0, 16)}.webp`;
|
|
110
|
+
const cached = path.join(cacheDir, filename);
|
|
111
|
+
if (!existsSync(cached)) {
|
|
112
|
+
try {
|
|
113
|
+
const encoded = await context
|
|
114
|
+
.sharp(bytes)
|
|
115
|
+
.resize({ width: MAX_WIDTH, withoutEnlargement: true })
|
|
116
|
+
.webp({ quality: QUALITY, effort: 5 })
|
|
117
|
+
.toBuffer();
|
|
118
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
119
|
+
writeFileSync(cached, encoded);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
// One malformed source must not fail the page it appears on.
|
|
122
|
+
context.logger?.warn(`封面转 WebP 失败(${pathname}):${String(error)}`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const attribute = image.node.attrs.find((entry) => entry.name === "src");
|
|
127
|
+
if (!attribute) continue;
|
|
128
|
+
attribute.value = `/${COVER_DIR}/${filename}`;
|
|
129
|
+
converted++;
|
|
130
|
+
}
|
|
131
|
+
return converted;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Copy every cached cover into the build output so cached pages still resolve. */
|
|
135
|
+
export function restoreCoverFiles(context: { outDir: string; cacheDir: string }): number {
|
|
136
|
+
const cacheDir = path.join(context.cacheDir, RECIPE);
|
|
137
|
+
if (!existsSync(cacheDir)) return 0;
|
|
138
|
+
const target = path.join(context.outDir, COVER_DIR);
|
|
139
|
+
mkdirSync(target, { recursive: true });
|
|
140
|
+
let copied = 0;
|
|
141
|
+
for (const name of readdirSync(cacheDir)) {
|
|
142
|
+
if (!name.endsWith(".webp")) continue;
|
|
143
|
+
copyFileSync(path.join(cacheDir, name), path.join(target, name));
|
|
144
|
+
copied++;
|
|
145
|
+
}
|
|
146
|
+
return copied;
|
|
147
|
+
}
|
|
@@ -23,6 +23,23 @@ function getRenderer(fontPath: string): Promise<Renderer> {
|
|
|
23
23
|
return rendererPromise;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Font identity for the render cache.
|
|
28
|
+
*
|
|
29
|
+
* The key used to carry the font's size and mtime, so re-installing the theme,
|
|
30
|
+
* restoring a checkout, or copying the package to a build machine changed every
|
|
31
|
+
* card's key at once and re-rendered all of them (about 8s each). Hashing the
|
|
32
|
+
* bytes keeps the key stable for identical font content and still changes when
|
|
33
|
+
* the font really is different.
|
|
34
|
+
*/
|
|
35
|
+
let fontDigestPromise: Promise<string> | undefined;
|
|
36
|
+
function getFontDigest(fontPath: string): Promise<string> {
|
|
37
|
+
fontDigestPromise ??= readFile(fontPath).then((bytes) =>
|
|
38
|
+
createHash("sha256").update(bytes).digest("hex"),
|
|
39
|
+
);
|
|
40
|
+
return fontDigestPromise;
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
/** Resolve only assets inside the final public directory, including encoded Chinese paths. */
|
|
27
44
|
export function localCoverPath(cover: string, paths: SocialCardPaths): string | undefined {
|
|
28
45
|
if (!cover.startsWith("/") || cover.startsWith("//")) return undefined;
|
|
@@ -248,9 +265,11 @@ export async function renderSocialCard(
|
|
|
248
265
|
warn(`分享封面不可用,已回退标题卡片:${data.title}`);
|
|
249
266
|
}
|
|
250
267
|
}
|
|
251
|
-
|
|
268
|
+
// Content-only key: identical card data + identical font + identical cover
|
|
269
|
+
// always reuses the cached render, whatever the filesystem timestamps say.
|
|
270
|
+
const fontDigest = await getFontDigest(paths.fontPath);
|
|
252
271
|
const key = createHash("sha256")
|
|
253
|
-
.update(JSON.stringify(["
|
|
272
|
+
.update(JSON.stringify(["v3", format, data, fontDigest]))
|
|
254
273
|
.update(cover ?? "")
|
|
255
274
|
.digest("hex");
|
|
256
275
|
const cacheFile = path.join(paths.cacheDir, `${key}.${format}`);
|
package/src/layouts/Stalux.astro
CHANGED
|
@@ -76,6 +76,13 @@ const routeKind = getRouteKind(Astro.url.pathname);
|
|
|
76
76
|
import "../scripts/background.ts";
|
|
77
77
|
import "../scripts/module-motion.ts";
|
|
78
78
|
import "../scripts/view-transitions.ts";
|
|
79
|
+
// Every page type has at least one list, so the unroll cue is registered
|
|
80
|
+
// once in the shared layout instead of per page. Page-runtime dedupes it
|
|
81
|
+
// against the article and archive components that also request it.
|
|
82
|
+
import { mountListReveal } from "../scripts/list-reveal";
|
|
83
|
+
import { registerPageLifecycle } from "../scripts/page-runtime";
|
|
84
|
+
|
|
85
|
+
registerPageLifecycle("list-reveal", mountListReveal);
|
|
79
86
|
</script>
|
|
80
87
|
{aiConfig?.conformance !== "disabled" && <script>import "../scripts/webmcp.ts";</script>}
|
|
81
88
|
<body
|
|
@@ -19,12 +19,15 @@ export async function getStaticPaths() {
|
|
|
19
19
|
)
|
|
20
20
|
.flatMap((post) => {
|
|
21
21
|
const card = createSocialCardData(post.data, site, author);
|
|
22
|
-
|
|
22
|
+
// PNG only. og:image must stay PNG because several platforms refuse
|
|
23
|
+
// WebP and AVIF in share previews, and a second format per post
|
|
24
|
+
// doubled the render work (about 8s each) for no visible gain.
|
|
25
|
+
return (["png"] as const).map((format) => ({
|
|
23
26
|
params: {
|
|
24
27
|
image: socialCardPath(card)
|
|
25
28
|
.split("/")
|
|
26
29
|
.at(-1)
|
|
27
|
-
?.replace(/\.
|
|
30
|
+
?.replace(/\.png$/u, ""),
|
|
28
31
|
format,
|
|
29
32
|
},
|
|
30
33
|
props: { card, format },
|
|
@@ -4,16 +4,32 @@ const ITEM = ":scope > [data-stalux-reveal-item]";
|
|
|
4
4
|
// never a queue long enough to keep the reader waiting for the last item.
|
|
5
5
|
const MAX_CONTAINERS = 48;
|
|
6
6
|
const MAX_ITEMS = 16;
|
|
7
|
-
const STAGGER_MS =
|
|
8
|
-
const MAX_DELAY_MS =
|
|
9
|
-
const DURATION_MS =
|
|
7
|
+
const STAGGER_MS = 45;
|
|
8
|
+
const MAX_DELAY_MS = 400;
|
|
9
|
+
const DURATION_MS = 380;
|
|
10
|
+
// When a route cue is already moving the page body, the sheet waits for it to
|
|
11
|
+
// mostly settle before unrolling. Starting both at once makes the rows wipe
|
|
12
|
+
// open while the whole page is still semi-transparent, which reads as a smudge.
|
|
13
|
+
const ROUTE_LEAD_IN_MS = 150;
|
|
14
|
+
// Strong ease-out: the row arrives fast and settles softly. The built-in
|
|
15
|
+
// ease-out is too weak to read at this size.
|
|
16
|
+
const EASING = "cubic-bezier(0.23, 1, 0.32, 1)";
|
|
17
|
+
// The clip is inset by a negative margin so a card's box-shadow is not sheared
|
|
18
|
+
// off by the wipe. A hard `inset(0)` would clip the shadow to the border box and
|
|
19
|
+
// make it pop back in when the effect is released.
|
|
20
|
+
const BLEED = "-10px";
|
|
21
|
+
const UNROLL_START = `inset(${BLEED} ${BLEED} 100% ${BLEED})`;
|
|
22
|
+
const UNROLL_END = `inset(${BLEED} ${BLEED} ${BLEED} ${BLEED})`;
|
|
10
23
|
|
|
11
24
|
/**
|
|
12
|
-
*
|
|
25
|
+
* Rows unroll into place like paper: each one wipes open from its top edge
|
|
26
|
+
* downward while settling a few pixels, and the next row starts before the
|
|
27
|
+
* previous one finishes so the group reads as one continuous sheet unrolling.
|
|
13
28
|
*
|
|
14
|
-
* Text is authored fully visible
|
|
15
|
-
* be read
|
|
16
|
-
*
|
|
29
|
+
* Text is authored fully visible. The effect only ever runs on a group that is
|
|
30
|
+
* about to be read, is skipped for restored history, reduced motion, hidden
|
|
31
|
+
* documents and persisted shells, and every clip is released afterwards so no
|
|
32
|
+
* clipped state can outlive the animation.
|
|
17
33
|
*/
|
|
18
34
|
export function mountListReveal(): (() => void) | undefined {
|
|
19
35
|
if (typeof IntersectionObserver !== "function") return;
|
|
@@ -25,6 +41,8 @@ export function mountListReveal(): (() => void) | undefined {
|
|
|
25
41
|
.filter((element) => !element.closest("[data-swup-persist]"))
|
|
26
42
|
.slice(0, MAX_CONTAINERS);
|
|
27
43
|
if (!containers.length) return;
|
|
44
|
+
// A route cue leaves its preset on the document element while it runs.
|
|
45
|
+
const leadIn = document.documentElement.dataset.staluxVtPreset ? ROUTE_LEAD_IN_MS : 0;
|
|
28
46
|
|
|
29
47
|
const active = new Set<Animation>();
|
|
30
48
|
const cancel = () => {
|
|
@@ -38,21 +56,30 @@ export function mountListReveal(): (() => void) | undefined {
|
|
|
38
56
|
if (typeof item.animate !== "function") continue;
|
|
39
57
|
try {
|
|
40
58
|
const animation = item.animate(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
[
|
|
60
|
+
{
|
|
61
|
+
clipPath: UNROLL_START,
|
|
62
|
+
opacity: 0.4,
|
|
63
|
+
transform: "translate3d(0, -10px, 0)",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
clipPath: UNROLL_END,
|
|
67
|
+
opacity: 1,
|
|
68
|
+
transform: "translate3d(0, 0, 0)",
|
|
69
|
+
},
|
|
70
|
+
],
|
|
45
71
|
{
|
|
46
72
|
duration: DURATION_MS,
|
|
47
|
-
delay: Math.min(index * STAGGER_MS, MAX_DELAY_MS),
|
|
48
|
-
easing:
|
|
49
|
-
//
|
|
73
|
+
delay: leadIn + Math.min(index * STAGGER_MS, MAX_DELAY_MS),
|
|
74
|
+
easing: EASING,
|
|
75
|
+
// Hidden during the stagger delay instead of popping in.
|
|
50
76
|
fill: "backwards",
|
|
51
77
|
},
|
|
52
78
|
);
|
|
53
79
|
active.add(animation);
|
|
54
80
|
const release = () => {
|
|
55
81
|
active.delete(animation);
|
|
82
|
+
// Drops the clip so nothing stays cut off after the wipe.
|
|
56
83
|
animation.cancel();
|
|
57
84
|
};
|
|
58
85
|
void animation.finished.then(release, release);
|
|
@@ -12,10 +12,12 @@ export interface RouteMotionEvent {
|
|
|
12
12
|
|
|
13
13
|
export interface RouteMotionOptions {
|
|
14
14
|
skip?: boolean;
|
|
15
|
-
/**
|
|
15
|
+
/** Animate the route's own content stage instead of a bounded heading area. */
|
|
16
16
|
content?: boolean;
|
|
17
17
|
preset?: RouteMotionPreset;
|
|
18
18
|
browserMotion?: boolean;
|
|
19
|
+
/** Releases the outgoing fill effect inside the entry cue's sync block. */
|
|
20
|
+
releaseOutgoing?: () => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export interface RouteMotion {
|
|
@@ -48,13 +50,102 @@ const headingMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion>
|
|
|
48
50
|
error: { opacity: 0.9, duration: 80 },
|
|
49
51
|
};
|
|
50
52
|
|
|
53
|
+
const MAIN = "main[data-stalux-route]";
|
|
54
|
+
const ARTICLE = "article.stalux-article";
|
|
55
|
+
// Authored per page type by the theme, not inferred from layout: the article
|
|
56
|
+
// body, archive list, tag cloud, post list, word list, link list, home block.
|
|
57
|
+
const CONTENT = "[data-stalux-motion-content]";
|
|
58
|
+
// A persist node marks the three-column article shell. Those sidebars keep
|
|
59
|
+
// their identity across visits and must never travel with a route cue.
|
|
60
|
+
const PERSIST = "[data-swup-persist]";
|
|
61
|
+
|
|
62
|
+
interface StageMotion {
|
|
63
|
+
opacity: number;
|
|
64
|
+
/** Enter offset in px. Positive y enters from below, positive x from the right. */
|
|
65
|
+
distance: number;
|
|
66
|
+
axis: "x" | "y";
|
|
67
|
+
duration: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One entry per route kind, because the components are not interchangeable: an
|
|
71
|
+
// article opens from below, the next article slides in from the side that the
|
|
72
|
+
// reader is heading toward, and going back up to an index pulls away upward.
|
|
73
|
+
const stageMotions: Record<Exclude<RouteMotionPreset, "none">, StageMotion> = {
|
|
74
|
+
// Article-to-article keeps a lateral direction: previous/next already says
|
|
75
|
+
// which way the reading order goes, so the cue repeats that, not a fade.
|
|
76
|
+
previous: { opacity: 0, distance: -36, axis: "x", duration: 300 },
|
|
77
|
+
next: { opacity: 0, distance: 36, axis: "x", duration: 300 },
|
|
78
|
+
// Entering a different article without a reading-direction trigger.
|
|
79
|
+
article: { opacity: 0, distance: 40, axis: "y", duration: 300 },
|
|
80
|
+
// Reaching an article from any list or the home block.
|
|
81
|
+
enter: { opacity: 0, distance: 56, axis: "y", duration: 340 },
|
|
82
|
+
home: { opacity: 0, distance: 48, axis: "y", duration: 320 },
|
|
83
|
+
// Leaving an article for the archive or a taxonomy index.
|
|
84
|
+
up: { opacity: 0, distance: -44, axis: "y", duration: 300 },
|
|
85
|
+
// Moving between two taxonomy detail pages.
|
|
86
|
+
taxonomy: { opacity: 0, distance: 36, axis: "y", duration: 280 },
|
|
87
|
+
// About / links / words: single-panel pages.
|
|
88
|
+
panel: { opacity: 0, distance: 44, axis: "y", duration: 300 },
|
|
89
|
+
glide: { opacity: 0, distance: 40, axis: "y", duration: 280 },
|
|
90
|
+
// History traversal restores what the reader already saw.
|
|
91
|
+
restore: { opacity: 0, distance: -36, axis: "y", duration: 280 },
|
|
92
|
+
error: { opacity: 0, distance: 28, axis: "y", duration: 260 },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const laidOut = (element: HTMLElement): boolean => {
|
|
96
|
+
const rect = element.getBoundingClientRect();
|
|
97
|
+
return rect.width > 0 && rect.height > 0;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The single node a route cue may animate for the current document.
|
|
102
|
+
*
|
|
103
|
+
* Picks the largest authored content stage, so an article moves its own column
|
|
104
|
+
* and an archive moves its list, while sidebars (persisted shells), the header
|
|
105
|
+
* and the footer stay untouched. Falls back to `main` only for pages without a
|
|
106
|
+
* persisted shell inside it.
|
|
107
|
+
*/
|
|
108
|
+
export function getRouteStage(): HTMLElement | null {
|
|
109
|
+
const main = document.querySelector<HTMLElement>(MAIN);
|
|
110
|
+
if (!main?.isConnected) return null;
|
|
111
|
+
const candidates: HTMLElement[] = [];
|
|
112
|
+
const article = main.querySelector<HTMLElement>(ARTICLE);
|
|
113
|
+
if (article) candidates.push(article);
|
|
114
|
+
for (const element of main.querySelectorAll<HTMLElement>(CONTENT)) candidates.push(element);
|
|
115
|
+
|
|
116
|
+
let stage: HTMLElement | null = null;
|
|
117
|
+
let area = 0;
|
|
118
|
+
for (const element of candidates) {
|
|
119
|
+
// A stage inside a persisted subtree is the shell itself.
|
|
120
|
+
if (!element.isConnected || element.closest(PERSIST)) continue;
|
|
121
|
+
const rect = element.getBoundingClientRect();
|
|
122
|
+
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
123
|
+
const current = rect.width * rect.height;
|
|
124
|
+
if (current > area) {
|
|
125
|
+
area = current;
|
|
126
|
+
stage = element;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (stage) return stage;
|
|
130
|
+
if (main.querySelector(PERSIST)) return null;
|
|
131
|
+
return laidOut(main) ? main : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Offset applied to the outgoing document, continuing the entry direction. */
|
|
135
|
+
export function getStageExit(preset: RouteMotionPreset): StageMotion | null {
|
|
136
|
+
if (preset === "none") return null;
|
|
137
|
+
const motion = stageMotions[preset];
|
|
138
|
+
// Entering from below means leaving upward, so both halves read as one
|
|
139
|
+
// continuous flow instead of an enter/leave bounce.
|
|
140
|
+
return { ...motion, distance: -Math.round(motion.distance * 0.72) };
|
|
141
|
+
}
|
|
142
|
+
|
|
51
143
|
const motions = new WeakMap<object, RouteMotion>();
|
|
52
144
|
const settle = (promise?: PromiseLike<unknown>): Promise<void> =>
|
|
53
145
|
Promise.resolve(promise).then(
|
|
54
146
|
() => undefined,
|
|
55
147
|
() => undefined,
|
|
56
148
|
);
|
|
57
|
-
|
|
58
149
|
function completion() {
|
|
59
150
|
let resolve = () => {};
|
|
60
151
|
const promise = new Promise<void>((done) => {
|
|
@@ -149,41 +240,80 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
|
149
240
|
start(heading, options) {
|
|
150
241
|
if (started) return;
|
|
151
242
|
started = true;
|
|
243
|
+
// Every exit path must release the outgoing fill, or the swapped
|
|
244
|
+
// document stays transparent behind a cancelled cue.
|
|
245
|
+
const release = options?.releaseOutgoing;
|
|
152
246
|
try {
|
|
153
247
|
const preset = options?.preset ?? "glide";
|
|
154
248
|
const mobile = window.matchMedia?.("(max-width: 768px)").matches;
|
|
155
|
-
const target = getRouteMotionTarget(heading);
|
|
156
249
|
if (
|
|
157
|
-
!target ||
|
|
158
|
-
preset === "none" ||
|
|
159
|
-
(options?.content && preset === "restore") ||
|
|
160
250
|
options?.skip ||
|
|
161
251
|
options?.browserMotion ||
|
|
252
|
+
preset === "none" ||
|
|
253
|
+
(options?.content && preset === "restore") ||
|
|
254
|
+
// History never replays an entry on a phone: the reader is
|
|
255
|
+
// returning to a position, not arriving somewhere new.
|
|
256
|
+
(mobile && preset === "restore") ||
|
|
162
257
|
document.hidden ||
|
|
163
|
-
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
164
|
-
typeof target.animate !== "function" ||
|
|
165
|
-
(mobile && preset === "restore")
|
|
258
|
+
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
166
259
|
) {
|
|
260
|
+
release?.();
|
|
261
|
+
animationDone.resolve();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const stage = options?.content ? getRouteStage() : null;
|
|
265
|
+
const target = stage ?? getRouteMotionTarget(heading);
|
|
266
|
+
if (!target || typeof target.animate !== "function") {
|
|
267
|
+
release?.();
|
|
167
268
|
animationDone.resolve();
|
|
168
269
|
return;
|
|
169
270
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
271
|
+
// Cancelling the outgoing fill and starting the entry cue must
|
|
272
|
+
// share one synchronous block. Split across frames, the reader
|
|
273
|
+
// sees the new document at full opacity for a frame first.
|
|
274
|
+
release?.();
|
|
275
|
+
if (stage) {
|
|
276
|
+
const spec = stageMotions[preset];
|
|
277
|
+
// Phones get a shorter throw; the same offset reads as a much
|
|
278
|
+
// larger move on a narrow viewport.
|
|
279
|
+
const distance = mobile ? Math.round(spec.distance * 0.6) : spec.distance;
|
|
280
|
+
const from =
|
|
281
|
+
spec.axis === "x"
|
|
282
|
+
? `translate3d(${distance}px, 0, 0)`
|
|
283
|
+
: `translate3d(0, ${distance}px, 0)`;
|
|
284
|
+
animation = stage.animate(
|
|
285
|
+
{
|
|
286
|
+
opacity: [spec.opacity, 1],
|
|
287
|
+
transform: [from, "translate3d(0, 0, 0)"],
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
duration: mobile ? Math.round(spec.duration * 0.9) : spec.duration,
|
|
291
|
+
// Strong ease-out: quick arrival, soft settle. The
|
|
292
|
+
// built-in ease-out is too weak to read at this size.
|
|
293
|
+
easing: "cubic-bezier(0.23, 1, 0.32, 1)",
|
|
294
|
+
fill: "both",
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
} else {
|
|
298
|
+
const spec = options?.content ? regionMotions[preset] : headingMotions[preset];
|
|
299
|
+
const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
|
|
300
|
+
// Phones use a pure fade. Explicit previous/next alone imply a
|
|
301
|
+
// lateral reading direction; history never replays the entry.
|
|
302
|
+
if (!mobile && (spec.x || spec.y)) {
|
|
303
|
+
keyframes.transform = [
|
|
304
|
+
`translate3d(${spec.x ?? 0}px, ${spec.y ?? 0}px, 0)`,
|
|
305
|
+
"translate3d(0px, 0px, 0)",
|
|
306
|
+
];
|
|
307
|
+
}
|
|
308
|
+
animation = target.animate(keyframes, {
|
|
309
|
+
duration: mobile ? (options?.content ? 120 : 80) : spec.duration,
|
|
310
|
+
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
311
|
+
fill: "both",
|
|
312
|
+
});
|
|
179
313
|
}
|
|
180
|
-
animation = target.animate(keyframes, {
|
|
181
|
-
duration: mobile ? (options?.content ? 120 : 80) : spec.duration,
|
|
182
|
-
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
183
|
-
fill: "both",
|
|
184
|
-
});
|
|
185
314
|
void animation.finished.then(clearAnimation, clearAnimation);
|
|
186
315
|
} catch {
|
|
316
|
+
release?.();
|
|
187
317
|
clearAnimation();
|
|
188
318
|
} finally {
|
|
189
319
|
installed.resolve();
|
package/src/scripts/route-out.ts
CHANGED
|
@@ -1,37 +1,62 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { RouteMotionPreset } from "../utils/route-motion-policy";
|
|
2
|
+
import { getRouteStage, getStageExit } from "./route-motion";
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Fade and shift the current content stage out, continuing the entry direction
|
|
6
|
+
* so the two halves read as one movement instead of a flash.
|
|
7
|
+
*
|
|
8
|
+
* The returned release keeps the fill effect until the entry cue takes over.
|
|
9
|
+
* Cancelling here would expose the freshly swapped document at full opacity for
|
|
10
|
+
* a frame before it animates in — the exact flash this replaces.
|
|
11
|
+
*/
|
|
4
12
|
export async function animateRouteOut(
|
|
5
|
-
|
|
13
|
+
preset: RouteMotionPreset,
|
|
6
14
|
signal: AbortSignal,
|
|
7
15
|
): Promise<() => void> {
|
|
16
|
+
const spec = getStageExit(preset);
|
|
17
|
+
const stage = spec ? getRouteStage() : null;
|
|
8
18
|
if (
|
|
9
|
-
!
|
|
19
|
+
!spec ||
|
|
20
|
+
!stage ||
|
|
10
21
|
signal.aborted ||
|
|
11
22
|
document.hidden ||
|
|
23
|
+
typeof stage.animate !== "function" ||
|
|
12
24
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
13
25
|
)
|
|
14
26
|
return () => {};
|
|
15
|
-
const target = getRouteMotionTarget(element);
|
|
16
|
-
if (!target || typeof target.animate !== "function") return () => {};
|
|
17
|
-
let animation: Animation | undefined;
|
|
18
|
-
const cancel = () => animation?.cancel();
|
|
19
27
|
const mobile = window.matchMedia("(max-width: 768px)").matches;
|
|
28
|
+
const distance = mobile ? Math.round(spec.distance * 0.6) : spec.distance;
|
|
29
|
+
const to =
|
|
30
|
+
spec.axis === "x" ? `translate3d(${distance}px, 0, 0)` : `translate3d(0, ${distance}px, 0)`;
|
|
31
|
+
let animation: Animation | undefined;
|
|
32
|
+
const cancel = () => {
|
|
33
|
+
const active = animation;
|
|
34
|
+
animation = undefined;
|
|
35
|
+
try {
|
|
36
|
+
active?.cancel();
|
|
37
|
+
} catch {
|
|
38
|
+
/* An already detached effect cannot be released further. */
|
|
39
|
+
}
|
|
40
|
+
};
|
|
20
41
|
try {
|
|
21
|
-
animation =
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
:
|
|
25
|
-
|
|
42
|
+
animation = stage.animate(
|
|
43
|
+
{ opacity: [1, 0], transform: ["translate3d(0, 0, 0)", to] },
|
|
44
|
+
{
|
|
45
|
+
duration: mobile
|
|
46
|
+
? Math.round(spec.duration * 0.62)
|
|
47
|
+
: Math.round(spec.duration * 0.58),
|
|
48
|
+
// Strong ease-in: the outgoing document accelerates away. An exit
|
|
49
|
+
// is the one place ease-in is correct; entrances use ease-out.
|
|
50
|
+
easing: "cubic-bezier(0.4, 0, 1, 1)",
|
|
51
|
+
fill: "forwards",
|
|
52
|
+
},
|
|
26
53
|
);
|
|
27
54
|
signal.addEventListener("abort", cancel, { once: true });
|
|
28
55
|
await animation.finished;
|
|
29
|
-
//
|
|
30
|
-
//
|
|
56
|
+
// The stage node survives the swap, so this filled effect is what holds
|
|
57
|
+
// the incoming document hidden until its own cue starts.
|
|
31
58
|
} catch {
|
|
32
59
|
/* Cancellation and unsupported animation must not block a visit. */
|
|
33
|
-
} finally {
|
|
34
|
-
if (signal.aborted) cancel();
|
|
35
60
|
}
|
|
36
61
|
return () => {
|
|
37
62
|
signal.removeEventListener("abort", cancel);
|
|
@@ -60,6 +60,16 @@ function clearMotionState(): void {
|
|
|
60
60
|
delete document.documentElement.dataset[key];
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** The exit fill is released by whichever path finishes first; never twice. */
|
|
64
|
+
function once(effect: () => void): () => void {
|
|
65
|
+
let done = false;
|
|
66
|
+
return () => {
|
|
67
|
+
if (done) return;
|
|
68
|
+
done = true;
|
|
69
|
+
effect();
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
/** Astro renders pages; Swup alone owns their client navigation and animation. */
|
|
64
74
|
export function installSwupRouter(): Swup {
|
|
65
75
|
if (window.swup) return window.swup;
|
|
@@ -133,6 +143,7 @@ export function installSwupRouter(): Swup {
|
|
|
133
143
|
content: true,
|
|
134
144
|
browserMotion: context.browserMotion,
|
|
135
145
|
skip: context.controller.signal.aborted,
|
|
146
|
+
releaseOutgoing: context.releaseOutgoing,
|
|
136
147
|
},
|
|
137
148
|
);
|
|
138
149
|
await context.motion.finished;
|
|
@@ -252,17 +263,14 @@ export function installSwupRouter(): Swup {
|
|
|
252
263
|
const next = visit.to.document;
|
|
253
264
|
if (!context || !next || !replace) throw new Error("Missing Swup document");
|
|
254
265
|
if (context.controller.signal.aborted) return;
|
|
255
|
-
// Two cues overlap on the outgoing document:
|
|
256
|
-
//
|
|
257
|
-
//
|
|
266
|
+
// Two cues overlap on the outgoing document: the content stage leaving,
|
|
267
|
+
// and the page walking back to the top. Persisted sidebars, the header
|
|
268
|
+
// and the frame are never part of either one.
|
|
258
269
|
if (context.navigationType !== "traverse" && context.preset !== "none") {
|
|
259
|
-
const outgoing = animateRouteOut(
|
|
260
|
-
document.querySelector<HTMLElement>("[data-stalux-motion-focus]"),
|
|
261
|
-
context.controller.signal,
|
|
262
|
-
);
|
|
270
|
+
const outgoing = animateRouteOut(context.preset, context.controller.signal);
|
|
263
271
|
if (!context.to.hash) context.glide = glideToTop();
|
|
264
272
|
await context.glide?.promise;
|
|
265
|
-
context.releaseOutgoing = await outgoing;
|
|
273
|
+
context.releaseOutgoing = once(await outgoing);
|
|
266
274
|
if (context.controller.signal.aborted || visit !== swup.visit) {
|
|
267
275
|
context.releaseOutgoing();
|
|
268
276
|
return;
|
|
@@ -276,8 +284,10 @@ export function installSwupRouter(): Swup {
|
|
|
276
284
|
context.motion = prepareRouteMotion(dispatchNavigation("astro:before-swap", context, next));
|
|
277
285
|
if (context.controller.signal.aborted) return;
|
|
278
286
|
await replace(visit, args);
|
|
279
|
-
// The
|
|
280
|
-
|
|
287
|
+
// The stage node survives the swap, so the filled exit effect still holds
|
|
288
|
+
// the incoming document at zero opacity. It is released inside
|
|
289
|
+
// motion.start, in the same synchronous block as the entry cue: releasing
|
|
290
|
+
// it here would paint the new document fully visible for one frame.
|
|
281
291
|
if (context.controller.signal.aborted) return;
|
|
282
292
|
for (const element of context.obsoleteStyles) element.remove();
|
|
283
293
|
const incomingStyles = [...next.head.querySelectorAll("style,link[rel=stylesheet]")];
|
|
@@ -340,6 +350,7 @@ export function installSwupRouter(): Swup {
|
|
|
340
350
|
content: true,
|
|
341
351
|
skip: !visit.animation.animate || context.controller.signal.aborted,
|
|
342
352
|
browserMotion: context.browserMotion,
|
|
353
|
+
releaseOutgoing: context.releaseOutgoing,
|
|
343
354
|
});
|
|
344
355
|
}
|
|
345
356
|
|
|
@@ -349,7 +360,8 @@ export function installSwupRouter(): Swup {
|
|
|
349
360
|
await context.scriptsReady;
|
|
350
361
|
if (context.controller.signal.aborted) return;
|
|
351
362
|
dispatchNavigation("astro:page-load", context);
|
|
352
|
-
if (!visit.animation.animate)
|
|
363
|
+
if (!visit.animation.animate)
|
|
364
|
+
context.motion?.start(null, { skip: true, releaseOutgoing: context.releaseOutgoing });
|
|
353
365
|
});
|
|
354
366
|
swup.hooks.replace("link:self", () => undefined);
|
|
355
367
|
|
|
@@ -357,6 +369,9 @@ export function installSwupRouter(): Swup {
|
|
|
357
369
|
const context = contexts.get(visit);
|
|
358
370
|
context?.controller.abort();
|
|
359
371
|
context?.glide?.cancel();
|
|
372
|
+
// An aborted visit keeps no exit fill: without this the stage would stay
|
|
373
|
+
// transparent until the next successful navigation.
|
|
374
|
+
context?.releaseOutgoing?.();
|
|
360
375
|
context?.motion?.cancel();
|
|
361
376
|
if (context === activeContext) {
|
|
362
377
|
clearMotionState();
|
|
@@ -369,6 +384,7 @@ export function installSwupRouter(): Swup {
|
|
|
369
384
|
swup.hooks.on("visit:end", (visit) => {
|
|
370
385
|
const context = contexts.get(visit);
|
|
371
386
|
context?.glide?.cancel();
|
|
387
|
+
context?.releaseOutgoing?.();
|
|
372
388
|
context?.motion?.start(null, { skip: true });
|
|
373
389
|
if (activeContext === context) {
|
|
374
390
|
clearMotionState();
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
@layer theme, base, components, utilities;
|
|
2
|
-
@layer components{.stalux-word-card{transition:border-color var(--transition-normal), box-shadow var(--transition-normal);box-sizing:border-box;border-radius:var(--radius-lg,12px);background:var(--surface-glass);width:100%;min-width:0;box-shadow:var(--card-box-shadow);flex-direction:column;gap:1.5rem;padding:2rem 2rem 1.25rem;display:flex;position:relative}.stalux-word-date{font-variant-numeric:tabular-nums;color:var(--white-50p)}.stalux-word-footer{border-top:1px solid var(--white-10p);color:var(--white-70p);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;margin-top:auto;padding-top:1rem;font-size:.85rem;display:flex}.stalux-word-source-link{text-decoration-color:var(--accent-50p);text-underline-offset:.15em;transition:color var(--transition-fast);color:var(--accent-90p);text-decoration-line:underline}.stalux-word-body{min-width:0;color:var(--white-90p);font-size:1.1rem;line-height:1.9;position:relative}.stalux-word-list-list{flex-direction:column;align-items:center;gap:1.5rem;margin:2rem auto;padding:0;list-style-type:none;display:flex}.stalux-word-list-item{width:100%;
|
|
2
|
+
@layer components{.stalux-word-card{transition:border-color var(--transition-normal), box-shadow var(--transition-normal);box-sizing:border-box;border-radius:var(--radius-lg,12px);background:var(--surface-glass);width:100%;min-width:0;box-shadow:var(--card-box-shadow);flex-direction:column;gap:1.5rem;padding:2rem 2rem 1.25rem;display:flex;position:relative}.stalux-word-date{font-variant-numeric:tabular-nums;color:var(--white-50p)}.stalux-word-footer{border-top:1px solid var(--white-10p);color:var(--white-70p);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;margin-top:auto;padding-top:1rem;font-size:.85rem;display:flex}.stalux-word-source-link{text-decoration-color:var(--accent-50p);text-underline-offset:.15em;transition:color var(--transition-fast);color:var(--accent-90p);text-decoration-line:underline}.stalux-word-body{min-width:0;color:var(--white-90p);font-size:1.1rem;line-height:1.9;position:relative}.stalux-word-list-list{flex-direction:column;align-items:center;gap:1.5rem;width:100%;max-width:900px;margin:2rem auto;padding:0;list-style-type:none;display:flex}.stalux-word-list-item{width:100%;min-width:0;display:flex}.stalux-word-source-meta{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:.5rem;display:flex}.stalux-word-source-date{color:var(--white-70p)}.stalux-word-source-text{color:var(--white-90p)}.stalux-word-card{border-left:2px solid var(--accent-50p);backdrop-filter:blur(3px);overflow-wrap:anywhere;background:0 0;scroll-margin-top:6rem}.stalux-word-mark{color:var(--accent-60p);opacity:.45;pointer-events:none;position:absolute;top:1.5rem;right:1.5rem}.stalux-word-body{padding-right:2.5rem}.stalux-word-footer{backdrop-filter:none;text-align:left;background:0 0;padding:.75rem 0 0}.stalux-word-source-meta{text-align:right}.stalux-word-source-link{align-items:center;min-height:44px;display:inline-flex}.stalux-word-permalink{min-height:44px;color:var(--white-70p);font-variant-numeric:tabular-nums;align-items:center;display:inline-flex}.stalux-word-permalink:focus-visible,.stalux-word-source-link:focus-visible{outline:2px solid var(--stalux-accent-color);outline-offset:4px;border-radius:4px}.stalux-word-source-meta{min-width:0;margin-left:auto}.stalux-word-body>:first-child{margin-top:0}.stalux-word-body>:last-child{margin-bottom:0}.stalux-word-body .expressive-code{max-width:100%;overflow-x:auto}.stalux-word-card:target,.stalux-word-card[data-glow]{border-color:var(--accent-90p)}.stalux-word-body h1,.stalux-word-body h2,.stalux-word-body h3,.stalux-word-body h4,.stalux-word-body h5,.stalux-word-body h6{margin:.5rem 0;font-size:1.1em}.stalux-word-body p{margin:.5rem 0}.stalux-word-body blockquote{border-left:3px solid var(--accent-60p);background:var(--white-05p);margin:.5rem 0;padding:.6rem 1rem}.stalux-word-body pre{white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:visible}.stalux-word-body code{overflow-wrap:anywhere}@media (hover:hover) and (pointer:fine){.stalux-word-card:hover{border-color:var(--accent-80p);box-shadow:var(--card-box-shadow-hover)}.stalux-word-source-link:hover{color:var(--accent-90p)}}@media (width<=768px){.stalux-word-card{padding:1.25rem}.stalux-word-list-list{gap:1rem;margin:1.5rem auto}.stalux-word-list-item{max-width:100%}}}@layer utilities,theme,base;
|
|
3
3
|
/*$vite$:1*/
|
|
@@ -41,7 +41,7 @@ export function socialCardPath(data: SocialCardData, base = "/"): string {
|
|
|
41
41
|
.update(JSON.stringify(["stalux-takumi-v2", data]))
|
|
42
42
|
.digest("hex")
|
|
43
43
|
.slice(0, 20);
|
|
44
|
-
return `${base.replace(/\/$/u, "")}/og/${hash}.
|
|
44
|
+
return `${base.replace(/\/$/u, "")}/og/${hash}.png`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export function resolvePostSocialImage(
|
|
@@ -50,11 +50,14 @@ export function resolvePostSocialImage(
|
|
|
50
50
|
base = "/",
|
|
51
51
|
): SocialImage {
|
|
52
52
|
if (explicit?.trim() && isSocialImageUrl(explicit.trim())) return { url: explicit.trim() };
|
|
53
|
+
// PNG for the sharing image, always. Several platforms (and every legacy
|
|
54
|
+
// crawler) refuse WebP or AVIF in og:image, so this is the one place the
|
|
55
|
+
// modern format costs reach instead of saving bytes. In-page images are
|
|
56
|
+
// served as WebP instead, where the browser decides.
|
|
53
57
|
return {
|
|
54
58
|
url: socialCardPath(data, base),
|
|
55
59
|
width: 1200,
|
|
56
60
|
height: 630,
|
|
57
|
-
type: "image/
|
|
58
|
-
fallbackUrl: socialCardPath(data, base).replace(/\.webp$/u, ".png"),
|
|
61
|
+
type: "image/png",
|
|
59
62
|
};
|
|
60
63
|
}
|