@amjadiqbal/start-seo-kit 0.0.0-stage → 0.1.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/CHANGELOG.md +22 -0
- package/LICENSE.md +21 -0
- package/README.md +318 -2
- package/dist/commerce.d.ts +151 -0
- package/dist/commerce.js +315 -0
- package/dist/head-D2pPoGNP.d.ts +218 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +420 -0
- package/dist/json-ld-BkYFVGHw.js +122 -0
- package/dist/og.d.ts +64 -0
- package/dist/og.js +142 -0
- package/dist/sitemap-DbEixSCi.d.ts +98 -0
- package/package.json +89 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { t as jsonLd } from "./json-ld-BkYFVGHw.js";
|
|
2
|
+
// src/url.ts
|
|
3
|
+
/** `https://example.com/` -> `https://example.com`. Throws on a value that is not an absolute http(s) URL. */
|
|
4
|
+
function normalizeSiteUrl(siteUrl) {
|
|
5
|
+
let parsed;
|
|
6
|
+
try {
|
|
7
|
+
parsed = new URL(siteUrl);
|
|
8
|
+
} catch {
|
|
9
|
+
throw new TypeError(`start-seo-kit: siteUrl must be an absolute URL, got "${siteUrl}"`);
|
|
10
|
+
}
|
|
11
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new TypeError(`start-seo-kit: siteUrl must use http or https, got "${siteUrl}"`);
|
|
12
|
+
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`;
|
|
13
|
+
}
|
|
14
|
+
/** Apply the router's trailing-slash convention to a path (the root `/` is left alone). */
|
|
15
|
+
function applyTrailingSlash(path, trailingSlash = "never") {
|
|
16
|
+
const [pathname = "", rest = ""] = splitPath(path);
|
|
17
|
+
if (pathname === "/" || pathname === "") return `/${rest}`;
|
|
18
|
+
const trimmed = pathname.replace(/\/+$/, "");
|
|
19
|
+
return `${trailingSlash === "always" ? `${trimmed}/` : trimmed}${rest}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a path or URL against the site. Absolute http(s) URLs are returned unchanged.
|
|
23
|
+
* Pass `trailingSlash` for page URLs only; asset URLs (images) keep their path as given.
|
|
24
|
+
*/
|
|
25
|
+
function absoluteUrl(siteUrl, pathOrUrl, trailingSlash) {
|
|
26
|
+
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
|
27
|
+
if (pathOrUrl.startsWith("//")) return `https:${pathOrUrl}`;
|
|
28
|
+
const path = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
|
|
29
|
+
return `${normalizeSiteUrl(siteUrl)}${trailingSlash ? applyTrailingSlash(path, trailingSlash) : path}`;
|
|
30
|
+
}
|
|
31
|
+
function splitPath(path) {
|
|
32
|
+
const i = path.search(/[?#]/);
|
|
33
|
+
return i === -1 ? [path, ""] : [path.slice(0, i), path.slice(i)];
|
|
34
|
+
}
|
|
35
|
+
// src/head.ts
|
|
36
|
+
/**
|
|
37
|
+
* Create a typed head builder bound to your site's defaults.
|
|
38
|
+
*
|
|
39
|
+
* ```ts
|
|
40
|
+
* export const seo = defineSeo({ url: 'https://acme.dev', name: 'Acme', titleTemplate: '%s | Acme' })
|
|
41
|
+
* // in a route
|
|
42
|
+
* head: ({ loaderData }) => seo({ title: loaderData.post.title, path: `/posts/${loaderData.post.slug}` })
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
function defineSeo(config) {
|
|
46
|
+
const site = {
|
|
47
|
+
...config,
|
|
48
|
+
url: normalizeSiteUrl(config.url)
|
|
49
|
+
};
|
|
50
|
+
const fn = ((input = {}) => buildHead(site, input));
|
|
51
|
+
Object.defineProperty(fn, "site", {
|
|
52
|
+
value: Object.freeze(site),
|
|
53
|
+
enumerable: true
|
|
54
|
+
});
|
|
55
|
+
fn.url = (path) => absoluteUrl(site.url, path, site.trailingSlash ?? "never");
|
|
56
|
+
return fn;
|
|
57
|
+
}
|
|
58
|
+
/** Build head tags without a site config. Relative URLs are not allowed here. */
|
|
59
|
+
function seo(input) {
|
|
60
|
+
const { siteUrl, ...rest } = input;
|
|
61
|
+
return buildHead({ url: siteUrl ? normalizeSiteUrl(siteUrl) : "" }, rest);
|
|
62
|
+
}
|
|
63
|
+
function buildHead(site, input) {
|
|
64
|
+
const meta = [];
|
|
65
|
+
const links = [];
|
|
66
|
+
const slash = site.trailingSlash ?? "never";
|
|
67
|
+
const page = (p) => resolve(site.url, p, slash);
|
|
68
|
+
const asset = (p) => resolve(site.url, p);
|
|
69
|
+
const template = input.titleTemplate === false ? void 0 : input.titleTemplate ?? site.titleTemplate;
|
|
70
|
+
const title = input.title !== void 0 ? template ? template.replace(/%s/g, input.title) : input.title : site.defaultTitle;
|
|
71
|
+
if (title) meta.push({ title });
|
|
72
|
+
const description = input.description ?? site.description;
|
|
73
|
+
if (description) meta.push({
|
|
74
|
+
name: "description",
|
|
75
|
+
content: description
|
|
76
|
+
});
|
|
77
|
+
const robots = input.noindex ? robotsContent({
|
|
78
|
+
...typeof input.robots === "object" ? input.robots : {},
|
|
79
|
+
index: false
|
|
80
|
+
}) : robotsContent(input.robots ?? site.robots);
|
|
81
|
+
if (robots) meta.push({
|
|
82
|
+
name: "robots",
|
|
83
|
+
content: robots
|
|
84
|
+
});
|
|
85
|
+
const canonical = input.canonical === false ? void 0 : input.canonical ?? input.path;
|
|
86
|
+
const canonicalUrl = canonical !== void 0 ? page(canonical) : void 0;
|
|
87
|
+
if (canonicalUrl) links.push({
|
|
88
|
+
rel: "canonical",
|
|
89
|
+
href: canonicalUrl
|
|
90
|
+
});
|
|
91
|
+
for (const [lang, href] of Object.entries(input.alternates ?? {})) links.push({
|
|
92
|
+
rel: "alternate",
|
|
93
|
+
hrefLang: lang,
|
|
94
|
+
href: page(href)
|
|
95
|
+
});
|
|
96
|
+
const socialTitle = input.socialTitle ?? input.title ?? site.defaultTitle;
|
|
97
|
+
const type = input.type ?? (input.article ? "article" : "website");
|
|
98
|
+
if (socialTitle) meta.push({
|
|
99
|
+
property: "og:title",
|
|
100
|
+
content: socialTitle
|
|
101
|
+
});
|
|
102
|
+
if (description) meta.push({
|
|
103
|
+
property: "og:description",
|
|
104
|
+
content: description
|
|
105
|
+
});
|
|
106
|
+
meta.push({
|
|
107
|
+
property: "og:type",
|
|
108
|
+
content: type
|
|
109
|
+
});
|
|
110
|
+
const ogUrl = canonicalUrl ?? (input.path !== void 0 ? page(input.path) : void 0);
|
|
111
|
+
if (ogUrl) meta.push({
|
|
112
|
+
property: "og:url",
|
|
113
|
+
content: ogUrl
|
|
114
|
+
});
|
|
115
|
+
if (site.name) meta.push({
|
|
116
|
+
property: "og:site_name",
|
|
117
|
+
content: site.name
|
|
118
|
+
});
|
|
119
|
+
const locale = input.locale ?? site.locale;
|
|
120
|
+
if (locale) meta.push({
|
|
121
|
+
property: "og:locale",
|
|
122
|
+
content: locale
|
|
123
|
+
});
|
|
124
|
+
const image = input.image === false ? void 0 : input.image ?? site.image;
|
|
125
|
+
const img = image === void 0 ? void 0 : typeof image === "string" ? { url: image } : image;
|
|
126
|
+
if (img) {
|
|
127
|
+
const url = asset(img.url);
|
|
128
|
+
meta.push({
|
|
129
|
+
property: "og:image",
|
|
130
|
+
content: url
|
|
131
|
+
});
|
|
132
|
+
if (url.startsWith("https://")) meta.push({
|
|
133
|
+
property: "og:image:secure_url",
|
|
134
|
+
content: url
|
|
135
|
+
});
|
|
136
|
+
if (img.type) meta.push({
|
|
137
|
+
property: "og:image:type",
|
|
138
|
+
content: img.type
|
|
139
|
+
});
|
|
140
|
+
if (img.width) meta.push({
|
|
141
|
+
property: "og:image:width",
|
|
142
|
+
content: String(img.width)
|
|
143
|
+
});
|
|
144
|
+
if (img.height) meta.push({
|
|
145
|
+
property: "og:image:height",
|
|
146
|
+
content: String(img.height)
|
|
147
|
+
});
|
|
148
|
+
if (img.alt) meta.push({
|
|
149
|
+
property: "og:image:alt",
|
|
150
|
+
content: img.alt
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (input.article) {
|
|
154
|
+
const a = input.article;
|
|
155
|
+
if (a.publishedTime) meta.push({
|
|
156
|
+
property: "article:published_time",
|
|
157
|
+
content: isoDate$1(a.publishedTime)
|
|
158
|
+
});
|
|
159
|
+
if (a.modifiedTime) meta.push({
|
|
160
|
+
property: "article:modified_time",
|
|
161
|
+
content: isoDate$1(a.modifiedTime)
|
|
162
|
+
});
|
|
163
|
+
if (a.expirationTime) meta.push({
|
|
164
|
+
property: "article:expiration_time",
|
|
165
|
+
content: isoDate$1(a.expirationTime)
|
|
166
|
+
});
|
|
167
|
+
if (a.author) meta.push({
|
|
168
|
+
property: "article:author",
|
|
169
|
+
content: a.author
|
|
170
|
+
});
|
|
171
|
+
if (a.section) meta.push({
|
|
172
|
+
property: "article:section",
|
|
173
|
+
content: a.section
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const twitter = {
|
|
177
|
+
...site.twitter,
|
|
178
|
+
...input.twitter
|
|
179
|
+
};
|
|
180
|
+
meta.push({
|
|
181
|
+
name: "twitter:card",
|
|
182
|
+
content: twitter.card ?? (img ? "summary_large_image" : "summary")
|
|
183
|
+
});
|
|
184
|
+
if (twitter.site) meta.push({
|
|
185
|
+
name: "twitter:site",
|
|
186
|
+
content: twitter.site
|
|
187
|
+
});
|
|
188
|
+
if (twitter.creator) meta.push({
|
|
189
|
+
name: "twitter:creator",
|
|
190
|
+
content: twitter.creator
|
|
191
|
+
});
|
|
192
|
+
if (socialTitle) meta.push({
|
|
193
|
+
name: "twitter:title",
|
|
194
|
+
content: socialTitle
|
|
195
|
+
});
|
|
196
|
+
if (description) meta.push({
|
|
197
|
+
name: "twitter:description",
|
|
198
|
+
content: description
|
|
199
|
+
});
|
|
200
|
+
if (img) {
|
|
201
|
+
meta.push({
|
|
202
|
+
name: "twitter:image",
|
|
203
|
+
content: asset(img.url)
|
|
204
|
+
});
|
|
205
|
+
if (img.alt) meta.push({
|
|
206
|
+
name: "twitter:image:alt",
|
|
207
|
+
content: img.alt
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const ld = input.jsonLd === void 0 ? [] : Array.isArray(input.jsonLd) ? input.jsonLd : [input.jsonLd];
|
|
211
|
+
for (const obj of ld) meta.push({ "script:ld+json": obj });
|
|
212
|
+
if (input.meta) meta.push(...input.meta);
|
|
213
|
+
if (input.links) links.push(...input.links);
|
|
214
|
+
return {
|
|
215
|
+
meta,
|
|
216
|
+
links
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function resolve(siteUrl, pathOrUrl, slash) {
|
|
220
|
+
if (!siteUrl && !/^(https?:)?\/\//i.test(pathOrUrl)) throw new TypeError(`start-seo-kit: "${pathOrUrl}" is relative; use defineSeo({ url }) or pass siteUrl to resolve it`);
|
|
221
|
+
return absoluteUrl(siteUrl || "https://invalid.invalid", pathOrUrl, slash);
|
|
222
|
+
}
|
|
223
|
+
/** Serialize robots directives, e.g. `{ index: false, maxImagePreview: 'large' }` -> `noindex, follow, max-image-preview:large`. */
|
|
224
|
+
function robotsContent(robots) {
|
|
225
|
+
if (robots === void 0) return void 0;
|
|
226
|
+
if (typeof robots === "string") return robots || void 0;
|
|
227
|
+
const parts = [];
|
|
228
|
+
if (robots.index !== void 0 || robots.follow !== void 0) parts.push(robots.index === false ? "noindex" : "index", robots.follow === false ? "nofollow" : "follow");
|
|
229
|
+
if (robots.noarchive) parts.push("noarchive");
|
|
230
|
+
if (robots.nosnippet) parts.push("nosnippet");
|
|
231
|
+
if (robots.noimageindex) parts.push("noimageindex");
|
|
232
|
+
if (robots.notranslate) parts.push("notranslate");
|
|
233
|
+
if (robots.maxSnippet !== void 0) parts.push(`max-snippet:${robots.maxSnippet}`);
|
|
234
|
+
if (robots.maxImagePreview) parts.push(`max-image-preview:${robots.maxImagePreview}`);
|
|
235
|
+
if (robots.maxVideoPreview !== void 0) parts.push(`max-video-preview:${robots.maxVideoPreview}`);
|
|
236
|
+
if (robots.unavailableAfter) parts.push(`unavailable_after:${isoDate$1(robots.unavailableAfter)}`);
|
|
237
|
+
return parts.length ? parts.join(", ") : void 0;
|
|
238
|
+
}
|
|
239
|
+
function isoDate$1(value) {
|
|
240
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
241
|
+
}
|
|
242
|
+
// src/sitemap.ts
|
|
243
|
+
/** Render a `<urlset>` sitemap. */
|
|
244
|
+
function sitemapXml(entries, options) {
|
|
245
|
+
const site = normalizeSiteUrl(options.siteUrl);
|
|
246
|
+
const slash = options.trailingSlash ?? "never";
|
|
247
|
+
const list = [...entries];
|
|
248
|
+
const hasAlternates = list.some((e) => e.alternates && Object.keys(e.alternates).length);
|
|
249
|
+
const hasImages = list.some((e) => e.images?.length);
|
|
250
|
+
const out = ["<?xml version=\"1.0\" encoding=\"UTF-8\"?>", `<urlset ${[
|
|
251
|
+
"xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"",
|
|
252
|
+
hasAlternates ? "xmlns:xhtml=\"http://www.w3.org/1999/xhtml\"" : "",
|
|
253
|
+
hasImages ? "xmlns:image=\"http://www.google.com/schemas/sitemap-image/1.1\"" : ""
|
|
254
|
+
].filter(Boolean).join(" ")}>`];
|
|
255
|
+
for (const e of list) {
|
|
256
|
+
out.push(" <url>", ` <loc>${xml(absoluteUrl(site, e.loc, slash))}</loc>`);
|
|
257
|
+
if (e.lastmod) out.push(` <lastmod>${xml(isoDate(e.lastmod))}</lastmod>`);
|
|
258
|
+
if (e.changefreq) out.push(` <changefreq>${e.changefreq}</changefreq>`);
|
|
259
|
+
if (e.priority !== void 0) out.push(` <priority>${clampPriority(e.priority)}</priority>`);
|
|
260
|
+
for (const [lang, href] of Object.entries(e.alternates ?? {})) out.push(` <xhtml:link rel="alternate" hreflang="${xml(lang)}" href="${xml(absoluteUrl(site, href, slash))}"/>`);
|
|
261
|
+
for (const image of e.images ?? []) {
|
|
262
|
+
const img = typeof image === "string" ? { url: image } : image;
|
|
263
|
+
out.push(" <image:image>", ` <image:loc>${xml(absoluteUrl(site, img.url))}</image:loc>`);
|
|
264
|
+
if (img.title) out.push(` <image:title>${xml(img.title)}</image:title>`);
|
|
265
|
+
if (img.caption) out.push(` <image:caption>${xml(img.caption)}</image:caption>`);
|
|
266
|
+
out.push(" </image:image>");
|
|
267
|
+
}
|
|
268
|
+
out.push(" </url>");
|
|
269
|
+
}
|
|
270
|
+
out.push("</urlset>", "");
|
|
271
|
+
return out.join("\n");
|
|
272
|
+
}
|
|
273
|
+
/** Render a `<sitemapindex>`. */
|
|
274
|
+
function sitemapIndexXml(sitemaps, options) {
|
|
275
|
+
const site = normalizeSiteUrl(options.siteUrl);
|
|
276
|
+
const out = ["<?xml version=\"1.0\" encoding=\"UTF-8\"?>", "<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"];
|
|
277
|
+
for (const s of sitemaps) {
|
|
278
|
+
out.push(" <sitemap>", ` <loc>${xml(absoluteUrl(site, s.loc))}</loc>`);
|
|
279
|
+
if (s.lastmod) out.push(` <lastmod>${xml(isoDate(s.lastmod))}</lastmod>`);
|
|
280
|
+
out.push(" </sitemap>");
|
|
281
|
+
}
|
|
282
|
+
out.push("</sitemapindex>", "");
|
|
283
|
+
return out.join("\n");
|
|
284
|
+
}
|
|
285
|
+
/** Dynamic sitemap (and sitemap index for large sites) served from TanStack Start server routes. */
|
|
286
|
+
function createSitemap(options) {
|
|
287
|
+
const max = options.maxUrlsPerSitemap ?? 5e4;
|
|
288
|
+
const basePath = (options.basePath ?? "/sitemaps").replace(/\/+$/, "");
|
|
289
|
+
const headers = {
|
|
290
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
291
|
+
"Cache-Control": options.cacheControl ?? "public, max-age=3600"
|
|
292
|
+
};
|
|
293
|
+
for (const name of Object.keys(options.sources)) if (!/^[\w-]+$/.test(name)) throw new TypeError(`start-seo-kit: invalid sitemap source name "${name}"`);
|
|
294
|
+
const count = async (src) => Array.isArray(src) ? src.length : typeof src === "function" ? (await src()).length : await src.count();
|
|
295
|
+
const slice = async (src, offset, limit) => Array.isArray(src) ? src.slice(offset, offset + limit) : typeof src === "function" ? (await src()).slice(offset, offset + limit) : await src.entries({
|
|
296
|
+
offset,
|
|
297
|
+
limit
|
|
298
|
+
});
|
|
299
|
+
const all = async () => {
|
|
300
|
+
const out = [];
|
|
301
|
+
for (const src of Object.values(options.sources)) out.push(...await slice(src, 0, Number.MAX_SAFE_INTEGER));
|
|
302
|
+
return out;
|
|
303
|
+
};
|
|
304
|
+
return {
|
|
305
|
+
entries: all,
|
|
306
|
+
async index() {
|
|
307
|
+
const counts = await Promise.all(Object.entries(options.sources).map(async ([n, s]) => [
|
|
308
|
+
n,
|
|
309
|
+
await count(s),
|
|
310
|
+
s
|
|
311
|
+
]));
|
|
312
|
+
const total = counts.reduce((sum, [, c]) => sum + c, 0);
|
|
313
|
+
if (!(options.index === true || options.index !== false && total > max)) {
|
|
314
|
+
if (total > max) throw new RangeError(`start-seo-kit: ${total} URLs exceed maxUrlsPerSitemap (${max}); enable index`);
|
|
315
|
+
return new Response(sitemapXml(await all(), options), { headers });
|
|
316
|
+
}
|
|
317
|
+
const children = [];
|
|
318
|
+
for (const [name, c, src] of counts) {
|
|
319
|
+
const lastmod = !Array.isArray(src) && typeof src === "object" && src.lastmod ? await src.lastmod() : void 0;
|
|
320
|
+
for (let i = 0; i < Math.max(1, Math.ceil(c / max)); i++) children.push({
|
|
321
|
+
loc: `${basePath}/${name}-${i + 1}.xml`,
|
|
322
|
+
lastmod
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return new Response(sitemapIndexXml(children, options), { headers });
|
|
326
|
+
},
|
|
327
|
+
async page({ request, params }) {
|
|
328
|
+
const name = params?.name ?? new URL(request.url).pathname.split("/").pop() ?? "";
|
|
329
|
+
const match = /^([\w-]+)-(\d+)\.xml$/.exec(name);
|
|
330
|
+
const src = match ? options.sources[match[1]] : void 0;
|
|
331
|
+
const n = match ? Number(match[2]) : 0;
|
|
332
|
+
if (!src || n < 1) return new Response("Not Found", { status: 404 });
|
|
333
|
+
const entries = await slice(src, (n - 1) * max, max);
|
|
334
|
+
if (!entries.length && n > 1) return new Response("Not Found", { status: 404 });
|
|
335
|
+
return new Response(sitemapXml(entries, options), { headers });
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Collect the static (parameter-free) page paths of your TanStack Router route tree.
|
|
341
|
+
* Dynamic routes (`$id`, `$`) are skipped: list them from your data in another source.
|
|
342
|
+
* Skipped too: server-only routes, paths with a file extension (`/sitemap.xml`), and routes with
|
|
343
|
+
* `staticData: { sitemap: false }`. Per-route `staticData.sitemap` values (priority, changefreq) are applied.
|
|
344
|
+
*/
|
|
345
|
+
function routesFromTree(routeTree, options = {}) {
|
|
346
|
+
if (!routeTree || typeof routeTree !== "object") throw new TypeError("start-seo-kit: routesFromTree() got no route tree. If your sitemap module imports routeTree.gen and is itself imported by a route, that is a circular import: make the source lazy, e.g. `pages: () => routesFromTree(routeTree)`.");
|
|
347
|
+
const seen = /* @__PURE__ */ new Map();
|
|
348
|
+
const visit = (route) => {
|
|
349
|
+
const kids = route.children ? Object.values(route.children) : [];
|
|
350
|
+
const path = route.fullPath;
|
|
351
|
+
const opts = route.options ?? {};
|
|
352
|
+
const sitemap = opts.staticData?.sitemap;
|
|
353
|
+
const serverOnly = opts.server !== void 0 && opts.component === void 0;
|
|
354
|
+
if (path !== void 0 && !route.isRoot && sitemap !== false && !serverOnly) {
|
|
355
|
+
const normalized = path === "/" ? "/" : path.replace(/\/+$/, "") || "/";
|
|
356
|
+
const isStatic = !/(^|\/)(\$|\{)/.test(normalized) && !/\.[a-z0-9]+$/i.test(normalized);
|
|
357
|
+
const excluded = options.exclude?.some((x) => typeof x === "string" ? x === normalized : x.test(normalized));
|
|
358
|
+
if (isStatic && !excluded && !seen.has(normalized)) seen.set(normalized, {
|
|
359
|
+
...options.defaults,
|
|
360
|
+
...sitemap,
|
|
361
|
+
loc: normalized
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
for (const kid of kids) visit(kid);
|
|
365
|
+
};
|
|
366
|
+
visit(routeTree);
|
|
367
|
+
return [...seen.values()];
|
|
368
|
+
}
|
|
369
|
+
function clampPriority(p) {
|
|
370
|
+
return String(Math.round(Math.min(1, Math.max(0, p)) * 100) / 100);
|
|
371
|
+
}
|
|
372
|
+
function isoDate(value) {
|
|
373
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
374
|
+
}
|
|
375
|
+
function xml(value) {
|
|
376
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
377
|
+
}
|
|
378
|
+
// src/robots.ts
|
|
379
|
+
/** Render a robots.txt body. */
|
|
380
|
+
function robotsTxt(config = {}) {
|
|
381
|
+
const rules = config.disallowAll ? [{
|
|
382
|
+
userAgent: "*",
|
|
383
|
+
disallow: "/"
|
|
384
|
+
}] : config.rules ?? [{
|
|
385
|
+
userAgent: "*",
|
|
386
|
+
allow: "/"
|
|
387
|
+
}];
|
|
388
|
+
const lines = [];
|
|
389
|
+
for (const rule of rules) {
|
|
390
|
+
if (lines.length) lines.push("");
|
|
391
|
+
for (const ua of list(rule.userAgent)) lines.push(`User-agent: ${ua}`);
|
|
392
|
+
for (const path of list(rule.allow)) lines.push(`Allow: ${path}`);
|
|
393
|
+
for (const path of list(rule.disallow)) lines.push(`Disallow: ${path}`);
|
|
394
|
+
if (rule.crawlDelay !== void 0) lines.push(`Crawl-delay: ${rule.crawlDelay}`);
|
|
395
|
+
}
|
|
396
|
+
const sitemaps = config.sitemaps ?? [];
|
|
397
|
+
if (sitemaps.length) lines.push("");
|
|
398
|
+
for (const s of sitemaps) {
|
|
399
|
+
if (!config.siteUrl && !/^https?:\/\//i.test(s)) throw new TypeError(`start-seo-kit: sitemap "${s}" is relative; pass siteUrl`);
|
|
400
|
+
lines.push(`Sitemap: ${config.siteUrl ? absoluteUrl(normalizeSiteUrl(config.siteUrl), s) : s}`);
|
|
401
|
+
}
|
|
402
|
+
return `${lines.join("\n")}\n`;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* `GET` handler for a `/robots.txt` server route. Pass a function to decide per request
|
|
406
|
+
* (for example, `disallowAll` when the host is a preview deployment).
|
|
407
|
+
*/
|
|
408
|
+
function createRobotsHandler(config, init = {}) {
|
|
409
|
+
return async (ctx) => {
|
|
410
|
+
const resolved = typeof config === "function" ? await config(ctx) : config;
|
|
411
|
+
return new Response(robotsTxt(resolved), { headers: {
|
|
412
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
413
|
+
"Cache-Control": init.cacheControl ?? "public, max-age=3600"
|
|
414
|
+
} });
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function list(value) {
|
|
418
|
+
return value === void 0 ? [] : Array.isArray(value) ? value : [value];
|
|
419
|
+
}
|
|
420
|
+
export { absoluteUrl, createRobotsHandler, createSitemap, defineSeo, jsonLd, robotsContent, robotsTxt, routesFromTree, seo, sitemapIndexXml, sitemapXml };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// src/json-ld.ts
|
|
2
|
+
const CONTEXT = "https://schema.org";
|
|
3
|
+
/**
|
|
4
|
+
* Typed builders for common schema.org types. Each returns a JSON-LD object with `@context`.
|
|
5
|
+
* Product, Offer, Review and return/shipping policies live in `@amjadiqbal/start-seo-kit/commerce`.
|
|
6
|
+
*/
|
|
7
|
+
const jsonLd = {
|
|
8
|
+
person(p) {
|
|
9
|
+
return clean({
|
|
10
|
+
"@context": CONTEXT,
|
|
11
|
+
"@type": "Person",
|
|
12
|
+
"@id": p.id,
|
|
13
|
+
name: p.name,
|
|
14
|
+
url: p.url,
|
|
15
|
+
image: p.image,
|
|
16
|
+
jobTitle: p.jobTitle,
|
|
17
|
+
sameAs: p.sameAs
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
organization(o) {
|
|
21
|
+
return clean({
|
|
22
|
+
"@context": CONTEXT,
|
|
23
|
+
"@type": o.type ?? "Organization",
|
|
24
|
+
"@id": o.id,
|
|
25
|
+
name: o.name,
|
|
26
|
+
url: o.url,
|
|
27
|
+
logo: o.logo,
|
|
28
|
+
sameAs: o.sameAs,
|
|
29
|
+
email: o.email,
|
|
30
|
+
telephone: o.telephone
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
website(w) {
|
|
34
|
+
return clean({
|
|
35
|
+
"@context": CONTEXT,
|
|
36
|
+
"@type": "WebSite",
|
|
37
|
+
"@id": w.id,
|
|
38
|
+
name: w.name,
|
|
39
|
+
url: w.url,
|
|
40
|
+
inLanguage: w.inLanguage,
|
|
41
|
+
publisher: w.publisher === void 0 ? void 0 : ref(w.publisher, "Organization"),
|
|
42
|
+
potentialAction: w.searchUrlTemplate ? {
|
|
43
|
+
"@type": "SearchAction",
|
|
44
|
+
target: {
|
|
45
|
+
"@type": "EntryPoint",
|
|
46
|
+
urlTemplate: w.searchUrlTemplate
|
|
47
|
+
},
|
|
48
|
+
"query-input": "required name=search_term_string"
|
|
49
|
+
} : void 0
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
article(a) {
|
|
53
|
+
const authors = a.author === void 0 ? void 0 : (Array.isArray(a.author) ? a.author : [a.author]).map((x) => ref(x, "Person"));
|
|
54
|
+
return clean({
|
|
55
|
+
"@context": CONTEXT,
|
|
56
|
+
"@type": a.type ?? "Article",
|
|
57
|
+
"@id": a.id,
|
|
58
|
+
headline: a.headline,
|
|
59
|
+
description: a.description,
|
|
60
|
+
url: a.url,
|
|
61
|
+
mainEntityOfPage: a.url,
|
|
62
|
+
image: a.image,
|
|
63
|
+
datePublished: date(a.datePublished),
|
|
64
|
+
dateModified: date(a.dateModified),
|
|
65
|
+
author: authors && authors.length === 1 ? authors[0] : authors,
|
|
66
|
+
publisher: a.publisher === void 0 ? void 0 : ref(a.publisher, "Organization"),
|
|
67
|
+
keywords: a.keywords?.join(", ")
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
breadcrumbs(items) {
|
|
71
|
+
return {
|
|
72
|
+
"@context": CONTEXT,
|
|
73
|
+
"@type": "BreadcrumbList",
|
|
74
|
+
itemListElement: items.map((item, i) => clean({
|
|
75
|
+
"@type": "ListItem",
|
|
76
|
+
position: i + 1,
|
|
77
|
+
name: item.name,
|
|
78
|
+
item: item.url
|
|
79
|
+
}))
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
faq(items) {
|
|
83
|
+
return {
|
|
84
|
+
"@context": CONTEXT,
|
|
85
|
+
"@type": "FAQPage",
|
|
86
|
+
mainEntity: items.map((q) => ({
|
|
87
|
+
"@type": "Question",
|
|
88
|
+
name: q.question,
|
|
89
|
+
acceptedAnswer: {
|
|
90
|
+
"@type": "Answer",
|
|
91
|
+
text: q.answer
|
|
92
|
+
}
|
|
93
|
+
}))
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
/** Combine several nodes into one `@graph` document (their own `@context` is dropped). */
|
|
97
|
+
graph(...nodes) {
|
|
98
|
+
return {
|
|
99
|
+
"@context": CONTEXT,
|
|
100
|
+
"@graph": nodes.map(({ "@context": _ctx, ...rest }) => rest)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
function ref(value, type) {
|
|
105
|
+
if (typeof value !== "string") {
|
|
106
|
+
const { "@context": _ctx, ...rest } = value;
|
|
107
|
+
return rest;
|
|
108
|
+
}
|
|
109
|
+
return /^https?:\/\//.test(value) ? { "@id": value } : {
|
|
110
|
+
"@type": type,
|
|
111
|
+
name: value
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function date(value) {
|
|
115
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
116
|
+
}
|
|
117
|
+
function clean(obj) {
|
|
118
|
+
const out = {};
|
|
119
|
+
for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
export { jsonLd as t };
|
package/dist/og.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { a as ServerHandler, r as HandlerContext } from "./sitemap-DbEixSCi.js";
|
|
2
|
+
type Awaitable<T> = T | Promise<T>;
|
|
3
|
+
/** A satori element: a React element (JSX) or a plain `{ type, props }` object tree. */
|
|
4
|
+
export type OgElement = {
|
|
5
|
+
type: unknown;
|
|
6
|
+
props: unknown;
|
|
7
|
+
} | string;
|
|
8
|
+
export interface OgFont {
|
|
9
|
+
name: string;
|
|
10
|
+
/** Font file contents (TTF, OTF or WOFF; not WOFF2). */
|
|
11
|
+
data: ArrayBuffer | Uint8Array;
|
|
12
|
+
weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
|
|
13
|
+
style?: 'normal' | 'italic';
|
|
14
|
+
}
|
|
15
|
+
/** What `@resvg/resvg-wasm`'s `initWasm` accepts: the `.wasm` bytes, a `Response`, a `WebAssembly.Module` or a URL. */
|
|
16
|
+
export type WasmSource = ArrayBuffer | Uint8Array | Response | WebAssembly.Module | URL | string;
|
|
17
|
+
export interface RenderOgOptions {
|
|
18
|
+
fonts: OgFont[];
|
|
19
|
+
/** Default 1200. */
|
|
20
|
+
width?: number;
|
|
21
|
+
/** Default 630. */
|
|
22
|
+
height?: number;
|
|
23
|
+
/** `png` (default, needs `@resvg/resvg-wasm`) or `svg` (satori only). */
|
|
24
|
+
format?: 'png' | 'svg';
|
|
25
|
+
/**
|
|
26
|
+
* The resvg `.wasm` for PNG output. On Node it is read from `node_modules` when left out.
|
|
27
|
+
* Where `node_modules` cannot be read, pass it yourself.
|
|
28
|
+
*/
|
|
29
|
+
wasm?: WasmSource | (() => Awaitable<WasmSource>);
|
|
30
|
+
}
|
|
31
|
+
/** Render an element to PNG bytes (or an SVG string with `format: 'svg'`). */
|
|
32
|
+
export declare function renderOgImage(element: OgElement, options: RenderOgOptions): Promise<Uint8Array | string>;
|
|
33
|
+
export interface CreateOgImageOptions extends Omit<RenderOgOptions, 'fonts'> {
|
|
34
|
+
/** Fonts, or a function that loads them once (cached after the first request). */
|
|
35
|
+
fonts: OgFont[] | (() => Awaitable<OgFont[]>);
|
|
36
|
+
/** Build the image for a request. Return `null` for a 404. */
|
|
37
|
+
render: (ctx: HandlerContext & {
|
|
38
|
+
url: URL;
|
|
39
|
+
}) => Awaitable<OgElement | null>;
|
|
40
|
+
/** `Cache-Control` header. Default `public, max-age=86400, immutable`. */
|
|
41
|
+
cacheControl?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `GET` handler for an Open Graph image server route, e.g. `src/routes/og/$slug.ts`.
|
|
45
|
+
* Point `seo({ image: '/og/my-post' })` at it.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createOgImageHandler(options: CreateOgImageOptions): ServerHandler;
|
|
48
|
+
export interface OgCardInput {
|
|
49
|
+
title: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
/** Small label above the title, e.g. the site name. */
|
|
52
|
+
eyebrow?: string;
|
|
53
|
+
/** Footer text, e.g. the domain. */
|
|
54
|
+
footer?: string;
|
|
55
|
+
/** Font family name as registered in `fonts`. */
|
|
56
|
+
fontFamily?: string;
|
|
57
|
+
background?: string;
|
|
58
|
+
foreground?: string;
|
|
59
|
+
muted?: string;
|
|
60
|
+
accent?: string;
|
|
61
|
+
}
|
|
62
|
+
/** A ready-made 1200x630 card (title, description, label, footer) as a plain element tree; no JSX needed. */
|
|
63
|
+
export declare function ogCard(input: OgCardInput): OgElement;
|
|
64
|
+
export {};
|