@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/dist/escape.d.ts +6 -0
  4. package/dist/escape.d.ts.map +1 -0
  5. package/dist/escape.js +6 -0
  6. package/dist/escape.js.map +1 -0
  7. package/dist/head.d.ts +110 -0
  8. package/dist/head.d.ts.map +1 -0
  9. package/dist/head.js +183 -0
  10. package/dist/head.js.map +1 -0
  11. package/dist/index.d.ts +23 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +22 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/node.d.ts +57 -0
  16. package/dist/node.d.ts.map +1 -0
  17. package/dist/node.js +104 -0
  18. package/dist/node.js.map +1 -0
  19. package/dist/og.d.ts +70 -0
  20. package/dist/og.d.ts.map +1 -0
  21. package/dist/og.js +60 -0
  22. package/dist/og.js.map +1 -0
  23. package/dist/preset.d.ts +37 -0
  24. package/dist/preset.d.ts.map +1 -0
  25. package/dist/preset.js +43 -0
  26. package/dist/preset.js.map +1 -0
  27. package/dist/render.d.ts +36 -0
  28. package/dist/render.d.ts.map +1 -0
  29. package/dist/render.js +50 -0
  30. package/dist/render.js.map +1 -0
  31. package/dist/sitemap.d.ts +82 -0
  32. package/dist/sitemap.d.ts.map +1 -0
  33. package/dist/sitemap.js +91 -0
  34. package/dist/sitemap.js.map +1 -0
  35. package/package.json +105 -0
  36. package/src/escape.ts +8 -0
  37. package/src/head.test.ts +258 -0
  38. package/src/head.ts +288 -0
  39. package/src/index.ts +59 -0
  40. package/src/node.test.ts +102 -0
  41. package/src/node.ts +140 -0
  42. package/src/og.test.ts +53 -0
  43. package/src/og.ts +103 -0
  44. package/src/preset.ts +78 -0
  45. package/src/render.test.ts +67 -0
  46. package/src/render.ts +75 -0
  47. package/src/sitemap.test.ts +124 -0
  48. 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&amp;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
+ }