@xingwangzhe/stalux 1.56.0 → 1.57.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.56.0",
3
+ "version": "1.57.0",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -98,7 +98,7 @@ const archiveYears = [...archivesByYearMonth.entries()]
98
98
  )}
99
99
  </time>
100
100
  <a
101
- href={`/posts/${post.data.abbrlink}/`}
101
+ href={`/posts/${post.data.abbrlink}`}
102
102
  class="stalux-archive-post-link"
103
103
  data-archive-description={post.data.desc || ""}
104
104
  >
@@ -23,7 +23,7 @@ const { t } = createTranslator((siteConfig.lang as string) || "zh-CN");
23
23
  <!-- Modern Web Guidance (html): 分类卡本身即 <article>;外层曾经的 <div class="stalux-category-shell">
24
24
  已上移到列表的 <li>(见 categoriesList.astro),避免多一层无语义容器。 -->
25
25
  <article class="stalux-category-card" data-stalux-control="category-card">
26
- <a href={`/categories/${props.category.name}/`} class="stalux-categoryheader a-none">
26
+ <a href={`/categories/${props.category.name}`} class="stalux-categoryheader a-none">
27
27
  <span class="stalux-category-icon">
28
28
  <svg
29
29
  aria-hidden="true"
@@ -51,7 +51,7 @@ const cfg = {
51
51
  <ul class={cfg.listClass} data-stalux-reveal>
52
52
  {items.map((item) => (
53
53
  <li data-stalux-reveal-item>
54
- <a href={`${cfg.prefix}${item}/`} class={`${cfg.itemClass} a-none`}>
54
+ <a href={`${cfg.prefix}${item}`} class={`${cfg.itemClass} a-none`}>
55
55
  {item}
56
56
  </a>
57
57
  </li>
@@ -27,7 +27,7 @@ const description = post.data.desc || "";
27
27
  class="stalux-post-card-shell stalux-post-card h-full text-inherit no-underline stalux-u-flex stalux-u-flex-col stalux-u-w-full stalux-u-min-w-0"
28
28
  >
29
29
  {post.data.cover && (
30
- <a href={`/posts/${post.data.abbrlink}/`} class="a-none" aria-label={post.data.title}>
30
+ <a href={`/posts/${post.data.abbrlink}`} class="a-none" aria-label={post.data.title}>
31
31
  <span class="stalux-card-post-cover stalux-u-w-full stalux-u-min-w-0">
32
32
  <img
33
33
  src={post.data.cover}
@@ -43,7 +43,7 @@ const description = post.data.desc || "";
43
43
  </a>
44
44
  )}
45
45
  <h2 class="stalux-card-post-title">
46
- <a href={`/posts/${post.data.abbrlink}/`} class="a-none">
46
+ <a href={`/posts/${post.data.abbrlink}`} class="a-none">
47
47
  {post.data.title}
48
48
  </a>
49
49
  </h2>
@@ -90,5 +90,5 @@ const description = post.data.desc || "";
90
90
  ))}
91
91
  </ul>
92
92
  </footer>
93
- <a href={`/posts/${post.data.abbrlink}/`} class="stalux-card-read-more a-none"> Read More </a>
93
+ <a href={`/posts/${post.data.abbrlink}`} class="stalux-card-read-more a-none"> Read More </a>
94
94
  </article>
@@ -21,9 +21,9 @@ const { t } = createTranslator(siteConfig.lang || "zh-CN");
21
21
  const typeKey = props.typeKey || props.type;
22
22
  let typeUrl = "";
23
23
  if (typeKey === "category" || typeKey === "categories" || typeKey === "分类") {
24
- typeUrl = "/categories/";
24
+ typeUrl = "/categories";
25
25
  } else if (typeKey === "tag" || typeKey === "tags" || typeKey === "标签") {
26
- typeUrl = "/tags/";
26
+ typeUrl = "/tags";
27
27
  }
28
28
  ---
29
29
 
@@ -17,7 +17,7 @@ const cloudDensity = Math.sqrt(Math.max(tagList.length, 100) / 100);
17
17
  const tagLinks = tagList.map((t) => ({
18
18
  type: "link" as const,
19
19
  text: t.name,
20
- url: `/tags/${t.name}/`,
20
+ url: `/tags/${t.name}`,
21
21
  }));
22
22
  ---
23
23
 
@@ -36,7 +36,7 @@ const tagLinks = tagList.map((t) => ({
36
36
  {group.map(({ name, count }) => (
37
37
  <li data-stalux-reveal-item>
38
38
  <a
39
- href={`/tags/${name}/`}
39
+ href={`/tags/${name}`}
40
40
  class="stalux-cloud-tag-card"
41
41
  data-stalux-card
42
42
  data-stalux-tag-card
package/src/index.ts CHANGED
@@ -53,6 +53,7 @@ import { createPageOutputCache } from "./internal/page-output-cache";
53
53
  import { loadCoverEncoder, restoreCoverFiles, upgradeCoverImages } from "./internal/post-cover";
54
54
  import { isPluginProject } from "./internal/project-mode";
55
55
  import { syncBackgroundSvgs, syncFontAssets } from "./internal/public-assets";
56
+ import { normalizeRouteHrefs } from "./internal/route-hrefs";
56
57
  import { createRuntimeCacheKey } from "./internal/runtime-cache-key";
57
58
  import {
58
59
  appendUniquePlugin,
@@ -529,6 +530,8 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
529
530
  promoteCodeStyles(document);
530
531
  containMermaidSvgs(document);
531
532
  removePageFontStyles(document);
533
+ // 正文里手写的 /posts/x/ 也统一成路由器实际提供的形态
534
+ normalizeRouteHrefs(document);
532
535
  const images = findHtmlImages(before, document);
533
536
  await updateLocalImageDimensions(images, outDir, {
534
537
  cache: dimensionsCache,
@@ -740,6 +743,16 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
740
743
  ...userSitemap,
741
744
  customPages,
742
745
  filter,
746
+ // Page routes are served without a trailing slash; keep the root
747
+ // and real files (`.md`, `.json`, `.xml`, images) exactly as built.
748
+ async serialize(item) {
749
+ const next = userSitemap?.serialize ? await userSitemap.serialize(item) : item;
750
+ if (!next) return next;
751
+ const url = new URL(next.url);
752
+ if (url.pathname !== "/") url.pathname = url.pathname.replace(/\/+$/u, "");
753
+ next.url = url.href;
754
+ return next;
755
+ },
743
756
  }),
744
757
  {
745
758
  name: "stalux/sitemap-alias",
@@ -1,4 +1,5 @@
1
1
  import { type DefaultTreeAdapterMap, serialize, serializeOuter } from "parse5";
2
+ import { normalizeRouteHref } from "./route-hrefs";
2
3
 
3
4
  type Element = DefaultTreeAdapterMap["element"];
4
5
 
@@ -17,6 +18,9 @@ export function createHtmlMutationEmitter(
17
18
  attrs: Element["attrs"];
18
19
  text: string[];
19
20
  }> = [];
21
+ // Anchor hrefs are rewritten by value range only: reserializing the whole
22
+ // element would re-escape attribute text the emitter never touched.
23
+ const hrefSnapshots: Array<{ node: Element; start: number; end: number }> = [];
20
24
  let head: Element | undefined;
21
25
  function visit(node: DefaultTreeAdapterMap["node"]) {
22
26
  if ("tagName" in node) {
@@ -37,6 +41,10 @@ export function createHtmlMutationEmitter(
37
41
  attrs: node.attrs.map((attribute) => ({ ...attribute })),
38
42
  text: node.childNodes.map((child) => ("value" in child ? child.value : "")),
39
43
  });
44
+ } else if (node.tagName === "a") {
45
+ const href = location?.attrs?.href;
46
+ if (href)
47
+ hrefSnapshots.push({ node, start: href.startOffset, end: href.endOffset });
40
48
  }
41
49
  }
42
50
  if ("childNodes" in node) for (const child of node.childNodes) visit(child);
@@ -45,6 +53,16 @@ export function createHtmlMutationEmitter(
45
53
 
46
54
  return () => {
47
55
  const edits: Array<{ start: number; end: number; value: string }> = [];
56
+ for (const { node, start, end } of hrefSnapshots) {
57
+ const current = node.attrs.find((attribute) => attribute.name === "href")?.value;
58
+ if (current === undefined) continue;
59
+ const raw = source.slice(start, end);
60
+ const parsed = /^(href\s*=\s*["'])([^"']*)(["'])$/u.exec(raw);
61
+ if (!parsed) continue;
62
+ const next = normalizeRouteHref(parsed[2] ?? "");
63
+ if (next === parsed[2]) continue;
64
+ edits.push({ start, end, value: `${parsed[1]}${next}${parsed[3]}` });
65
+ }
48
66
  for (const snapshot of snapshots) {
49
67
  const { node, start, end, attrs, text } = snapshot;
50
68
  if (!node.parentNode) {
@@ -0,0 +1,47 @@
1
+ import type { DefaultTreeAdapterMap } from "parse5";
2
+
3
+ type Document = DefaultTreeAdapterMap["document"];
4
+
5
+ /** Endpoints that are real files: the extension identifies them, never a slash. */
6
+ const REAL_FILE =
7
+ /\.(?:md|json|xml|txt|html?|pdf|png|webp|avif|svg|jpe?g|gif|ico|css|js|mjs|map|zip|mp4|webm|woff2?|ttf|otf)$/iu;
8
+
9
+ /**
10
+ * Rewrite a site-absolute href to the form the router actually serves.
11
+ *
12
+ * Page routes carry no trailing slash (`/posts/x`, `/archives`), while real
13
+ * files keep theirs (`/posts/x.md`, `/api/posts.json`). Only rooted paths are
14
+ * touched: relative links, protocol-relative URLs and anchors pass through.
15
+ */
16
+ export function normalizeRouteHref(href: string): string {
17
+ if (!href.startsWith("/") || href.startsWith("//")) return href;
18
+ const match = /^([^?#]*)([?#].*)?$/u.exec(href);
19
+ const path = match?.[1] ?? href;
20
+ const suffix = match?.[2] ?? "";
21
+ if (path === "/" || !path.endsWith("/") || REAL_FILE.test(path)) return href;
22
+ return `${path.replace(/\/+$/u, "")}${suffix}`;
23
+ }
24
+
25
+ /**
26
+ * Content-authored links still use the old trailing-slash form. Normalising the
27
+ * parsed document here keeps hand-written Markdown from reintroducing a URL
28
+ * shape the router no longer serves, without touching the author's source.
29
+ */
30
+ export function normalizeRouteHrefs(document: Document): number {
31
+ let changed = 0;
32
+ const visit = (node: DefaultTreeAdapterMap["node"]): void => {
33
+ if ("tagName" in node && node.tagName === "a") {
34
+ for (const attribute of node.attrs) {
35
+ if (attribute.name !== "href") continue;
36
+ const next = normalizeRouteHref(attribute.value);
37
+ if (next !== attribute.value) {
38
+ attribute.value = next;
39
+ changed++;
40
+ }
41
+ }
42
+ }
43
+ if ("childNodes" in node) for (const child of node.childNodes) visit(child);
44
+ };
45
+ visit(document);
46
+ return changed;
47
+ }
@@ -22,7 +22,7 @@ export function createSocialCardData(
22
22
  siteTitle: site.title,
23
23
  author: author.name,
24
24
  host: siteUrl.host,
25
- postUrl: new URL(`/posts/${encodeURIComponent(post.abbrlink)}/`, siteUrl).href,
25
+ postUrl: new URL(`/posts/${encodeURIComponent(post.abbrlink)}`, siteUrl).href,
26
26
  tags: post.tags ?? [],
27
27
  date: formatInTimeZone(
28
28
  parseDate(post.date, site.timezone || "Asia/Shanghai"),
@@ -25,7 +25,7 @@ const { t } = createTranslator(siteConfig.lang || "zh-CN");
25
25
  <p data-stalux-motion-content>{t("page404.pageNotFound")}</p>
26
26
  <p class="agent-recovery" data-stalux-motion-content>
27
27
  This URL does not exist. Continue at the <a href="/">home page</a>, browse the
28
- <a href="/archives/">archive</a>, read <a href="/llms.txt">llms.txt</a>, or inspect
28
+ <a href="/archives">archive</a>, read <a href="/llms.txt">llms.txt</a>, or inspect
29
29
  the
30
30
  <a href="/sitemap-index.xml">sitemap</a>
31
31
  to find published content.
@@ -29,7 +29,7 @@ export const GET: APIRoute = async ({ logger }) => {
29
29
  categories: post.data.categories ?? [],
30
30
  desc: post.data.desc ?? "",
31
31
  wordCount: descriptions.get(String(post.data.abbrlink))?.wordCount ?? 0,
32
- url: sitePath(`posts/${encodeURIComponent(String(post.data.abbrlink))}/`),
32
+ url: sitePath(`posts/${encodeURIComponent(String(post.data.abbrlink))}`),
33
33
  }));
34
34
 
35
35
  return new Response(JSON.stringify(payload), {
@@ -19,6 +19,8 @@ export const GET: APIRoute = async ({ logger }) => {
19
19
  title: stalux?.title || "Stalux Blog",
20
20
  description: stalux?.description || "A blog powered by Stalux theme",
21
21
  site: stalux.url,
22
+ // 页面路由不带尾斜杠,feed 里的条目链接要与之保持一致
23
+ trailingSlash: false,
22
24
  items,
23
25
  customData: `<language>${langToFeedLanguage(lang)}</language>\n<copyright>${t("rss.copyright")}</copyright>`,
24
26
  xmlns: {
@@ -17,8 +17,8 @@ function generateCCFooter(post: CollectionEntry<"posts">, config: ConfigMap): st
17
17
  const baseUrl = (siteConfig.url as string) || "";
18
18
  const author = (config.get("author")?.name as string) || "";
19
19
  const postUrl = baseUrl
20
- ? `${baseUrl}/posts/${post.data.abbrlink}/`
21
- : `/posts/${post.data.abbrlink}/`;
20
+ ? `${baseUrl}/posts/${post.data.abbrlink}`
21
+ : `/posts/${post.data.abbrlink}`;
22
22
 
23
23
  const lines: string[] = [];
24
24
  lines.push(`**${t("post.author")}**${author}`);
@@ -19,6 +19,8 @@ export const GET: APIRoute = async ({ logger }) => {
19
19
  title: stalux?.title || "Stalux Blog",
20
20
  description: stalux?.description || "A blog powered by Stalux theme",
21
21
  site: stalux.url,
22
+ // 页面路由不带尾斜杠,feed 里的条目链接要与之保持一致
23
+ trailingSlash: false,
22
24
  items,
23
25
  customData: `<language>${langToFeedLanguage(lang)}</language>\n<copyright>${t("rss.copyright")}</copyright>`,
24
26
  xmlns: {
@@ -103,7 +103,7 @@ function pageUrl(site: string, path: string, exportMd: boolean): string {
103
103
 
104
104
  /** 生成文章链接,可切换 .md 源码版本 */
105
105
  export function postUrl(site: string, abbrlink: string | number, exportMd: boolean): string {
106
- return exportMd && abbrlink ? `${site}/posts/${abbrlink}.md` : `${site}/posts/${abbrlink}/`;
106
+ return exportMd && abbrlink ? `${site}/posts/${abbrlink}.md` : `${site}/posts/${abbrlink}`;
107
107
  }
108
108
 
109
109
  /** 获取已发布文章并按日期降序排列 */
package/src/utils/feed.ts CHANGED
@@ -70,7 +70,7 @@ export async function buildFeedItems(
70
70
  title: post.data.title,
71
71
  pubDate: new Date(toMachineDateTime(post.data.date, stalux.timezone)),
72
72
  description: cached?.desc || post.data.desc || "",
73
- link: `/posts/${post.data.abbrlink}/`,
73
+ link: `/posts/${post.data.abbrlink}`,
74
74
  categories: post.data.categories || [],
75
75
  customData,
76
76
  };
@@ -45,10 +45,20 @@ export function getMarkdownPath(pathname: string): string | undefined {
45
45
  return undefined;
46
46
  }
47
47
 
48
+ /**
49
+ * The public form of an HTML route. Every page route is emitted without a
50
+ * trailing slash (`/posts/x`, `/tags/x`, `/archives`); only the root keeps its
51
+ * single slash. Real files — Markdown exports, JSON/XML endpoints, images —
52
+ * are returned untouched so they keep the extension that identifies them.
53
+ */
54
+ export function toRoutePath(pathname: string): string {
55
+ const path = normalizePublicPath(pathname);
56
+ return path === "/" ? "/" : path;
57
+ }
58
+
48
59
  export function toPublicUrl(site: string, pathname: string, markdown = false): string {
49
60
  const base = site.replace(/\/+$/u, "");
50
- const normalized = normalizePublicPath(pathname);
51
- const path = markdown ? getMarkdownPath(normalized) : normalized;
52
- if (!path) return `${base}${normalized === "/" ? "/" : `${normalized}/`}`;
53
- return markdown ? `${base}${path}` : `${base}${path === "/" ? "/" : `${path}/`}`;
61
+ const path = markdown ? getMarkdownPath(pathname) : toRoutePath(pathname);
62
+ if (!path) return `${base}${toRoutePath(pathname)}`;
63
+ return `${base}${path}`;
54
64
  }
@@ -59,8 +59,9 @@ export function buildSeoDocument(input: SeoDocumentInput): SeoDocument {
59
59
  const routeKind = getRouteKind(pathname);
60
60
  const siteUrl = normalizeSiteUrl(site.canonical ?? site.url);
61
61
  const normalizedPath = normalizePublicPath(pathname);
62
- const canonicalPath = normalizedPath === "/" ? "/" : `${normalizedPath}/`;
63
- const canonical = new URL(canonicalPath, `${siteUrl}/`).href;
62
+ // Canonical mirrors the URL the router actually serves: page routes carry no
63
+ // trailing slash, so /posts/x and /archives are canonical, /posts/x/ is not.
64
+ const canonical = new URL(normalizedPath, `${siteUrl}/`).href;
64
65
  const title = routeKind === "home" && site.seoTitle ? site.seoTitle : page.title;
65
66
  const siteName = site.title;
66
67
  const siteTitleSuffix = ` | ${site.title}`;