@websline/cms-view-utils 1.3.2 → 1.4.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": "@websline/cms-view-utils",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@eslint/compat": "^2.1.0",
|
|
23
23
|
"@eslint/js": "^10.0.1",
|
|
24
|
-
"eslint": "^10.
|
|
24
|
+
"eslint": "^10.6.0",
|
|
25
25
|
"eslint-config-prettier": "^10.1.8",
|
|
26
|
-
"eslint-plugin-svelte": "^3.
|
|
27
|
-
"globals": "^17.
|
|
28
|
-
"prettier": "^3.
|
|
29
|
-
"prettier-plugin-svelte": "^
|
|
26
|
+
"eslint-plugin-svelte": "^3.20.0",
|
|
27
|
+
"globals": "^17.7.0",
|
|
28
|
+
"prettier": "^3.9.4",
|
|
29
|
+
"prettier-plugin-svelte": "^4.1.1",
|
|
30
30
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
|
31
31
|
"tsup": "^8.5.1"
|
|
32
32
|
},
|
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
* from being indexed by search engines.
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
+
import { fetchSiteConfig } from "../cms/fetchSiteConfig.js";
|
|
35
|
+
import { buildAbsoluteUrl } from "../shared/url.js";
|
|
36
|
+
|
|
34
37
|
const isPreview = Astro.locals._preview ?? false;
|
|
35
38
|
|
|
36
39
|
const {
|
|
@@ -45,6 +48,8 @@ const {
|
|
|
45
48
|
} = Astro.props;
|
|
46
49
|
|
|
47
50
|
const {
|
|
51
|
+
alternates: seoAlternates = [],
|
|
52
|
+
mainLocale: seoMainLocale,
|
|
48
53
|
meta: {
|
|
49
54
|
canonical: seoCanonical,
|
|
50
55
|
description: seoDescription,
|
|
@@ -67,9 +72,13 @@ const finalCanonical = canonicalOverride ?? seoCanonical;
|
|
|
67
72
|
// For image, use explicit undefined check so null can mean "explicitly no image"
|
|
68
73
|
const finalImage = imageOverride !== undefined ? imageOverride : seoImage;
|
|
69
74
|
|
|
75
|
+
// The page's real noindex setting drives canonical/hreflang output, so the
|
|
76
|
+
// preview reflects what production emits (preview only forces the robots meta).
|
|
77
|
+
const pageNoIndex = noIndexOverride ?? seoNoIndex ?? false;
|
|
78
|
+
|
|
70
79
|
// In preview mode, force noindex/nofollow regardless of page settings
|
|
71
80
|
// to prevent unpublished drafts from being indexed
|
|
72
|
-
const finalNoIndex = isPreview ? true :
|
|
81
|
+
const finalNoIndex = isPreview ? true : pageNoIndex;
|
|
73
82
|
const finalNoFollow = isPreview ? true : (noFollowOverride ?? seoNoFollow ?? false);
|
|
74
83
|
|
|
75
84
|
// Build robots string only if at least one directive is active
|
|
@@ -81,6 +90,40 @@ const robotsContent =
|
|
|
81
90
|
|
|
82
91
|
const finalType = typeOverride ?? "website";
|
|
83
92
|
|
|
93
|
+
// hreflang: build absolute alternate URLs from the same source the sitemap uses
|
|
94
|
+
// (siteConfig.url + relative alternate path). Only indexable versions belong in
|
|
95
|
+
// the cluster, and a noindex page emits none. Meaningful only with >1 version.
|
|
96
|
+
let hreflangLinks = [];
|
|
97
|
+
const indexableAlternates = seoAlternates.filter((alternate) => !alternate.noIndex);
|
|
98
|
+
if (!pageNoIndex && indexableAlternates.length > 1) {
|
|
99
|
+
const siteConfig = await fetchSiteConfig(Astro.request).catch(() => null);
|
|
100
|
+
const siteUrl = siteConfig?.url;
|
|
101
|
+
|
|
102
|
+
if (siteUrl) {
|
|
103
|
+
hreflangLinks = indexableAlternates.map((alternate) => ({
|
|
104
|
+
hreflang: alternate.locale,
|
|
105
|
+
href: buildAbsoluteUrl(siteUrl, alternate.url),
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
// x-default: prefer EN when this page has an indexable EN version,
|
|
109
|
+
// otherwise fall back to the main locale.
|
|
110
|
+
const xDefaultLocale = indexableAlternates.some(
|
|
111
|
+
(alternate) => alternate.locale === "en",
|
|
112
|
+
)
|
|
113
|
+
? "en"
|
|
114
|
+
: seoMainLocale;
|
|
115
|
+
const xDefaultAlternate = indexableAlternates.find(
|
|
116
|
+
(alternate) => alternate.locale === xDefaultLocale,
|
|
117
|
+
);
|
|
118
|
+
if (xDefaultAlternate) {
|
|
119
|
+
hreflangLinks.push({
|
|
120
|
+
hreflang: "x-default",
|
|
121
|
+
href: buildAbsoluteUrl(siteUrl, xDefaultAlternate.url),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
84
127
|
// OG fallbacks (same as meta by default, can be extended via overrides later)
|
|
85
128
|
const ogTitle = finalTitle;
|
|
86
129
|
const ogDescription = finalDescription;
|
|
@@ -88,7 +131,12 @@ const ogDescription = finalDescription;
|
|
|
88
131
|
|
|
89
132
|
{finalTitle && <title>{finalTitle}</title>}
|
|
90
133
|
{finalDescription && <meta name="description" content={finalDescription} />}
|
|
91
|
-
{finalCanonical && <link rel="canonical" href={finalCanonical} />}
|
|
134
|
+
{finalCanonical && !pageNoIndex && <link rel="canonical" href={finalCanonical} />}
|
|
135
|
+
{
|
|
136
|
+
hreflangLinks.map((link) => (
|
|
137
|
+
<link rel="alternate" hreflang={link.hreflang} href={link.href} />
|
|
138
|
+
))
|
|
139
|
+
}
|
|
92
140
|
{robotsContent && <meta name="robots" content={robotsContent} />}
|
|
93
141
|
|
|
94
142
|
{/* Open Graph */}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { buildAbsoluteUrl } from "../shared/url.js";
|
|
2
|
+
|
|
1
3
|
const escapeXml = (value) =>
|
|
2
4
|
String(value).replace(/[<>&'"]/g, (char) => {
|
|
3
5
|
switch (char) {
|
|
@@ -24,7 +26,7 @@ const formatLastmod = (timestamp) => {
|
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
const buildUrlBlock = (entry, siteUrl) => {
|
|
27
|
-
const loc = encodeUrl(
|
|
29
|
+
const loc = encodeUrl(buildAbsoluteUrl(siteUrl, entry.path));
|
|
28
30
|
const lastmod = entry.updatedAt
|
|
29
31
|
? `\n <lastmod>${formatLastmod(entry.updatedAt)}</lastmod>`
|
|
30
32
|
: "";
|
|
@@ -32,12 +34,16 @@ const buildUrlBlock = (entry, siteUrl) => {
|
|
|
32
34
|
const alternates = (entry.alternates ?? [])
|
|
33
35
|
.map(
|
|
34
36
|
(alt) =>
|
|
35
|
-
`\n <xhtml:link rel="alternate" hreflang="${escapeXml(alt.locale)}" href="${encodeUrl(
|
|
37
|
+
`\n <xhtml:link rel="alternate" hreflang="${escapeXml(alt.locale)}" href="${encodeUrl(buildAbsoluteUrl(siteUrl, alt.path))}" />`,
|
|
36
38
|
)
|
|
37
39
|
.join("");
|
|
38
40
|
|
|
41
|
+
const xDefault = entry.xDefaultPath
|
|
42
|
+
? `\n <xhtml:link rel="alternate" hreflang="x-default" href="${encodeUrl(buildAbsoluteUrl(siteUrl, entry.xDefaultPath))}" />`
|
|
43
|
+
: "";
|
|
44
|
+
|
|
39
45
|
return ` <url>
|
|
40
|
-
<loc>${loc}</loc>${lastmod}${alternates}
|
|
46
|
+
<loc>${loc}</loc>${lastmod}${alternates}${xDefault}
|
|
41
47
|
</url>`;
|
|
42
48
|
};
|
|
43
49
|
|
|
@@ -19,6 +19,7 @@ const withRequestFilter = (options = {}) => {
|
|
|
19
19
|
const isExcludedPath = additionalExcludePaths.some((p) =>
|
|
20
20
|
url.pathname.startsWith(p),
|
|
21
21
|
);
|
|
22
|
+
const isAstroApiCall = url.pathname.startsWith("/api/");
|
|
22
23
|
|
|
23
24
|
return (
|
|
24
25
|
request.method !== "GET" ||
|
|
@@ -26,6 +27,7 @@ const withRequestFilter = (options = {}) => {
|
|
|
26
27
|
isErrorRoute ||
|
|
27
28
|
hasFileExtension ||
|
|
28
29
|
isExcludedPath ||
|
|
30
|
+
isAstroApiCall ||
|
|
29
31
|
customFilter?.(context) === true
|
|
30
32
|
);
|
|
31
33
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Joins the public site URL with a relative path into an absolute URL.
|
|
3
|
+
*
|
|
4
|
+
* Keeps URLs free of a trailing slash: the root path ("/") resolves to the
|
|
5
|
+
* bare site URL, so canonical, sitemap and hreflang all use the same form.
|
|
6
|
+
*/
|
|
7
|
+
const buildAbsoluteUrl = (siteUrl, path) => {
|
|
8
|
+
const base = siteUrl.replace(/\/+$/, "");
|
|
9
|
+
return path === "/" ? base : `${base}${path}`;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export { buildAbsoluteUrl };
|