@gusnips/vite 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/LICENSE +21 -0
- package/README.md +123 -0
- package/dist/escape.d.ts +6 -0
- package/dist/escape.d.ts.map +1 -0
- package/dist/escape.js +6 -0
- package/dist/escape.js.map +1 -0
- package/dist/head.d.ts +110 -0
- package/dist/head.d.ts.map +1 -0
- package/dist/head.js +183 -0
- package/dist/head.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +57 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +104 -0
- package/dist/node.js.map +1 -0
- package/dist/og.d.ts +70 -0
- package/dist/og.d.ts.map +1 -0
- package/dist/og.js +60 -0
- package/dist/og.js.map +1 -0
- package/dist/preset.d.ts +37 -0
- package/dist/preset.d.ts.map +1 -0
- package/dist/preset.js +43 -0
- package/dist/preset.js.map +1 -0
- package/dist/render.d.ts +36 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +50 -0
- package/dist/render.js.map +1 -0
- package/dist/sitemap.d.ts +82 -0
- package/dist/sitemap.d.ts.map +1 -0
- package/dist/sitemap.js +91 -0
- package/dist/sitemap.js.map +1 -0
- package/package.json +105 -0
- package/src/escape.ts +8 -0
- package/src/head.test.ts +258 -0
- package/src/head.ts +288 -0
- package/src/index.ts +59 -0
- package/src/node.test.ts +102 -0
- package/src/node.ts +140 -0
- package/src/og.test.ts +53 -0
- package/src/og.ts +103 -0
- package/src/preset.ts +78 -0
- package/src/render.test.ts +67 -0
- package/src/render.ts +75 -0
- package/src/sitemap.test.ts +124 -0
- package/src/sitemap.ts +159 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ogImagePath,
|
|
4
|
+
pageFile,
|
|
5
|
+
pageSlug,
|
|
6
|
+
robotsTxt,
|
|
7
|
+
siteOrigin,
|
|
8
|
+
sitemapFor,
|
|
9
|
+
sitemapXml,
|
|
10
|
+
} from "./sitemap.ts";
|
|
11
|
+
|
|
12
|
+
describe("pageFile", () => {
|
|
13
|
+
// Invariant 5: Cloudflare Pages serves `pricing/index.html` at `/pricing/` and answers
|
|
14
|
+
// `/pricing` with a 308 — so every address the canonical and the sitemap advertise would be
|
|
15
|
+
// a redirect rather than a page.
|
|
16
|
+
it("is flat, never a directory index", () => {
|
|
17
|
+
expect(pageFile("/pricing")).toBe("pricing.html");
|
|
18
|
+
expect(pageFile("/pricing")).not.toBe("pricing/index.html");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("names the front page index.html", () => {
|
|
22
|
+
expect(pageFile("/")).toBe("index.html");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("keeps a nested route's folders", () => {
|
|
26
|
+
expect(pageFile("/guides/errors")).toBe("guides/errors.html");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("ignores a trailing slash rather than writing `pricing/.html`", () => {
|
|
30
|
+
expect(pageFile("/pricing/")).toBe("pricing.html");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("pageSlug", () => {
|
|
35
|
+
it("flattens a path into one name a card and a catalog can both use", () => {
|
|
36
|
+
expect(pageSlug("/")).toBe("home");
|
|
37
|
+
expect(pageSlug("/pricing")).toBe("pricing");
|
|
38
|
+
expect(pageSlug("/guides/errors")).toBe("guides-errors");
|
|
39
|
+
expect(ogImagePath("/pricing")).toBe("/og/pricing.png");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("siteOrigin", () => {
|
|
44
|
+
it("drops the trailing slash the caller's path would double", () => {
|
|
45
|
+
expect(siteOrigin("https://acme.com/")).toBe("https://acme.com");
|
|
46
|
+
expect(`${siteOrigin("https://acme.com/")}/pricing`).toBe("https://acme.com/pricing");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// `VITE_SITE_URL=acme.com` looks right in a .env and turns every canonical on the site into
|
|
50
|
+
// a relative URL. That is a bad build, not a bad page.
|
|
51
|
+
it("refuses a value with no scheme", () => {
|
|
52
|
+
expect(() => siteOrigin("acme.com")).toThrow(/absolute origin/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("sitemapXml", () => {
|
|
57
|
+
it("carries each entry's own alternates, so a crawler that never reads the head has them", () => {
|
|
58
|
+
const xml = sitemapXml([
|
|
59
|
+
{
|
|
60
|
+
loc: "https://acme.com/pricing",
|
|
61
|
+
changefreq: "weekly",
|
|
62
|
+
priority: "0.9",
|
|
63
|
+
lastmod: "2026-09-07",
|
|
64
|
+
alternates: [{ hreflang: "pt-BR", href: "https://acme.com/pt/pricing" }],
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
expect(xml).toContain('xmlns:xhtml="http://www.w3.org/1999/xhtml"');
|
|
68
|
+
expect(xml).toContain("<loc>https://acme.com/pricing</loc>");
|
|
69
|
+
expect(xml).toContain('<xhtml:link rel="alternate" hreflang="pt-BR"');
|
|
70
|
+
expect(xml).toContain("<lastmod>2026-09-07</lastmod>");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("leaves lastmod out when there is none to state", () => {
|
|
74
|
+
const xml = sitemapXml([{ loc: "https://acme.com/", changefreq: "weekly", priority: "1.0" }]);
|
|
75
|
+
expect(xml).not.toContain("lastmod");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// A bare `&` is not valid XML, and an invalid sitemap is rejected whole rather than per entry.
|
|
79
|
+
it("escapes an address that carries a query string", () => {
|
|
80
|
+
const xml = sitemapXml([
|
|
81
|
+
{ loc: "https://acme.com/s?a=1&b=2", changefreq: "daily", priority: "0.5" },
|
|
82
|
+
]);
|
|
83
|
+
expect(xml).toContain("<loc>https://acme.com/s?a=1&b=2</loc>");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("prints a computed priority to one decimal", () => {
|
|
87
|
+
const xml = sitemapFor("https://acme.com/", [
|
|
88
|
+
{ path: "/pricing", priority: 0.1 + 0.2, changeFrequency: "weekly" },
|
|
89
|
+
]);
|
|
90
|
+
expect(xml).toContain("<priority>0.3</priority>");
|
|
91
|
+
expect(xml).toContain("<loc>https://acme.com/pricing</loc>");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("robotsTxt", () => {
|
|
96
|
+
// A crawler reads only the robots.txt at the origin ROOT, so an app served from a
|
|
97
|
+
// subdirectory cannot ship its own — this one file has to name every sitemap on the origin,
|
|
98
|
+
// or nothing ever finds the second one.
|
|
99
|
+
it("lists every sitemap on the origin, on the origin", () => {
|
|
100
|
+
const txt = robotsTxt({
|
|
101
|
+
origin: "https://acme.com/",
|
|
102
|
+
sitemaps: ["/sitemap.xml", "/docs/sitemap.xml"],
|
|
103
|
+
});
|
|
104
|
+
expect(txt).toContain("Sitemap: https://acme.com/sitemap.xml");
|
|
105
|
+
expect(txt).toContain("Sitemap: https://acme.com/docs/sitemap.xml");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// The bug this shape prevents: a donor's static robots.txt and its build's fallback name
|
|
109
|
+
// different domains, and neither is checked against the other.
|
|
110
|
+
it("cannot name a host it is not served from", () => {
|
|
111
|
+
const txt = robotsTxt({ origin: "https://acme.com", sitemaps: ["/sitemap.xml"] });
|
|
112
|
+
expect(txt.match(/https:\/\//g)).toHaveLength(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("keeps a private path out of every index", () => {
|
|
116
|
+
const txt = robotsTxt({
|
|
117
|
+
origin: "https://acme.com",
|
|
118
|
+
sitemaps: ["/sitemap.xml"],
|
|
119
|
+
disallow: ["/opt-out/"],
|
|
120
|
+
});
|
|
121
|
+
expect(txt).toContain("Disallow: /opt-out/");
|
|
122
|
+
expect(txt.startsWith("User-agent: *\nAllow: /\n")).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/src/sitemap.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three build outputs that are addresses rather than pages: where a rendered page lands,
|
|
3
|
+
* `sitemap.xml`, and `robots.txt`.
|
|
4
|
+
*
|
|
5
|
+
* Pure string work, like `head.ts` — no `node:` import, so every rule here is unit-testable.
|
|
6
|
+
*/
|
|
7
|
+
import { escapeAttr } from "./escape.ts";
|
|
8
|
+
// Type-only, so this file carries no runtime dependency on the head baker: a sitemap alternate
|
|
9
|
+
// and an `hreflang` link are the same claim, stated in two places a crawler reads separately.
|
|
10
|
+
import type { Alternate } from "./head.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Where a rendered page lands.
|
|
14
|
+
*
|
|
15
|
+
* FLAT (`pricing.html`), not directory-style (`pricing/index.html`). Cloudflare Pages serves
|
|
16
|
+
* the directory form at `/pricing/` and answers `/pricing` with a 308 to it — so every address
|
|
17
|
+
* the app advertises in its canonical and its sitemap would be a redirect rather than a page.
|
|
18
|
+
* A flat file is served at `/pricing` AND `/pricing/`, 200 either way.
|
|
19
|
+
*
|
|
20
|
+
* A nested route keeps its folders: `/guides/errors` → `guides/errors.html`.
|
|
21
|
+
*/
|
|
22
|
+
export function pageFile(routePath: string): string {
|
|
23
|
+
const trimmed = routePath.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
24
|
+
return trimmed === "" ? "index.html" : `${trimmed}.html`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The origin every canonical, share URL and sitemap entry is built on.
|
|
29
|
+
*
|
|
30
|
+
* The trailing slash is dropped because the caller appends a path that starts with one, and
|
|
31
|
+
* `https://example.com//pricing` is a different address to every crawler that reads it. The
|
|
32
|
+
* scheme is demanded because the value normally comes from an env var: `VITE_SITE_URL=acme.com`
|
|
33
|
+
* looks right in a `.env`, and it silently turns every canonical on the site into a relative
|
|
34
|
+
* URL. That is a bad build, not a bad page, so it fails here.
|
|
35
|
+
*/
|
|
36
|
+
export function siteOrigin(url: string): string {
|
|
37
|
+
if (!/^https?:\/\/[^/]+/.test(url))
|
|
38
|
+
throw new Error(
|
|
39
|
+
`prerender: "${url}" is not an absolute origin — it needs a scheme, as in https://example.com`,
|
|
40
|
+
);
|
|
41
|
+
return url.replace(/\/+$/, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The three things about a public page that do not translate: where it is, how often it
|
|
46
|
+
* changes, and how it ranks against its siblings.
|
|
47
|
+
*
|
|
48
|
+
* A product's registry extends this with its own copy fields — one donor names an i18n key per
|
|
49
|
+
* page, another keys its locale catalogs by `pageSlug` and stores no copy here at all. Where
|
|
50
|
+
* the copy lives is the product's call. These three are what a sitemap needs from every one of
|
|
51
|
+
* them, and the registry is the single source the prerender, the sitemap and the share cards
|
|
52
|
+
* all walk, so a page can never be in one and missing from another.
|
|
53
|
+
*/
|
|
54
|
+
export interface PublicPage {
|
|
55
|
+
path: string;
|
|
56
|
+
/** Relative crawl priority, 0.0–1.0. */
|
|
57
|
+
priority: number;
|
|
58
|
+
changeFrequency: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** URL slug for a page path (`/pricing` → `pricing`, `/` → `home`), used to name its share
|
|
62
|
+
* card and to key a locale catalog's copy. */
|
|
63
|
+
export function pageSlug(path: string): string {
|
|
64
|
+
const trimmed = path.replace(/^\/+|\/+$/g, "");
|
|
65
|
+
return trimmed === "" ? "home" : trimmed.replaceAll("/", "-");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Public path of a page's generated share card (`/pricing` → `/og/pricing.png`). */
|
|
69
|
+
export function ogImagePath(path: string): string {
|
|
70
|
+
return `/og/${pageSlug(path)}.png`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SitemapEntry {
|
|
74
|
+
loc: string;
|
|
75
|
+
changefreq: string;
|
|
76
|
+
/** Already formatted — an app ranks its own pages, and that rule does not belong here. */
|
|
77
|
+
priority: string;
|
|
78
|
+
/** `YYYY-MM-DD`. The one hint in a sitemap Google actually reads. One date for the whole
|
|
79
|
+
* build is the honest answer: these pages ship together. */
|
|
80
|
+
lastmod?: string;
|
|
81
|
+
/** The same reciprocal set the page's `<head>` carries. Stated twice on purpose: a crawler
|
|
82
|
+
* that reaches an address through the sitemap has not read the head yet. */
|
|
83
|
+
alternates?: readonly Alternate[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `sitemap.xml`, from whichever registry the caller walks. */
|
|
87
|
+
export function sitemapXml(entries: readonly SitemapEntry[]): string {
|
|
88
|
+
const urls = entries
|
|
89
|
+
.map((entry) => {
|
|
90
|
+
const links = (entry.alternates ?? []).map(
|
|
91
|
+
(alt) =>
|
|
92
|
+
` <xhtml:link rel="alternate" hreflang="${escapeAttr(alt.hreflang)}" href="${escapeAttr(alt.href)}" />\n`,
|
|
93
|
+
);
|
|
94
|
+
return (
|
|
95
|
+
` <url>\n` +
|
|
96
|
+
// Escaped like every other URL here: a query string with an `&` in it is not valid XML,
|
|
97
|
+
// and an invalid sitemap is rejected whole rather than per entry.
|
|
98
|
+
` <loc>${escapeAttr(entry.loc)}</loc>\n` +
|
|
99
|
+
links.join("") +
|
|
100
|
+
(entry.lastmod ? ` <lastmod>${entry.lastmod}</lastmod>\n` : "") +
|
|
101
|
+
` <changefreq>${entry.changefreq}</changefreq>\n` +
|
|
102
|
+
` <priority>${entry.priority}</priority>\n` +
|
|
103
|
+
` </url>`
|
|
104
|
+
);
|
|
105
|
+
})
|
|
106
|
+
.join("\n");
|
|
107
|
+
return (
|
|
108
|
+
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
109
|
+
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"` +
|
|
110
|
+
` xmlns:xhtml="http://www.w3.org/1999/xhtml">\n${urls}\n</urlset>\n`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `sitemap.xml` for a single-language site, straight from the registry — the common case, and
|
|
116
|
+
* the reason a registry exists: the sitemap can never drift from the routes the app serves.
|
|
117
|
+
*
|
|
118
|
+
* A localized site walks its own locales and calls {@link sitemapXml}, because only it knows
|
|
119
|
+
* how an address carries a language.
|
|
120
|
+
*/
|
|
121
|
+
export function sitemapFor(origin: string, pages: readonly PublicPage[], lastmod?: string): string {
|
|
122
|
+
const root = siteOrigin(origin);
|
|
123
|
+
return sitemapXml(
|
|
124
|
+
pages.map((page) => ({
|
|
125
|
+
loc: `${root}${page.path}`,
|
|
126
|
+
changefreq: page.changeFrequency,
|
|
127
|
+
// One decimal, always. `<priority>` is a number a crawler compares, and floating-point
|
|
128
|
+
// noise from a computed rank would print as `0.7000000000000001`.
|
|
129
|
+
priority: page.priority.toFixed(1),
|
|
130
|
+
...(lastmod !== undefined && { lastmod }),
|
|
131
|
+
})),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface RobotsOptions {
|
|
136
|
+
/** The origin this file is served from. Every sitemap path is resolved against it, so the
|
|
137
|
+
* file can never advertise a host it is not on — a mistake one donor is still shipping,
|
|
138
|
+
* where a static `robots.txt` and the build's fallback name different domains. */
|
|
139
|
+
origin: string;
|
|
140
|
+
/** Every sitemap on this ORIGIN, as paths (`/sitemap.xml`, `/docs/sitemap.xml`).
|
|
141
|
+
*
|
|
142
|
+
* A crawler reads only the robots.txt at the origin root. An app served from a
|
|
143
|
+
* subdirectory therefore cannot ship its own — the one file at the root has to list its
|
|
144
|
+
* sitemap too, or nothing ever finds it. */
|
|
145
|
+
sitemaps: readonly string[];
|
|
146
|
+
/** Paths to keep out of every index. A page nobody should be able to find by searching —
|
|
147
|
+
* a per-request status page, an unsubscribe link — belongs here AND in `noindex`. */
|
|
148
|
+
disallow?: readonly string[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function robotsTxt({ origin, sitemaps, disallow = [] }: RobotsOptions): string {
|
|
152
|
+
const root = siteOrigin(origin);
|
|
153
|
+
return (
|
|
154
|
+
`User-agent: *\nAllow: /\n` +
|
|
155
|
+
disallow.map((path) => `Disallow: ${path}\n`).join("") +
|
|
156
|
+
`\n` +
|
|
157
|
+
sitemaps.map((path) => `Sitemap: ${root}${path}\n`).join("")
|
|
158
|
+
);
|
|
159
|
+
}
|