@gusnips/vite 0.8.32 → 0.8.34
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/README.md +34 -0
- package/dist/contrast.d.ts +43 -0
- package/dist/contrast.d.ts.map +1 -0
- package/dist/contrast.js +429 -0
- package/dist/contrast.js.map +1 -0
- package/dist/head.d.ts +17 -0
- package/dist/head.d.ts.map +1 -1
- package/dist/head.js +22 -0
- package/dist/head.js.map +1 -1
- package/dist/icons.d.ts +61 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +72 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +9 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -7
- package/dist/index.js.map +1 -1
- package/dist/sitemap.d.ts +9 -0
- package/dist/sitemap.d.ts.map +1 -1
- package/dist/sitemap.js +17 -0
- package/dist/sitemap.js.map +1 -1
- package/package.json +16 -2
- package/src/contrast.test.ts +94 -0
- package/src/contrast.ts +508 -0
- package/src/fixtures/contrast/bad/page.html +4 -0
- package/src/fixtures/contrast/bad/theme.css +26 -0
- package/src/fixtures/contrast/ok/page.html +2 -0
- package/src/fixtures/contrast/ok/theme.css +29 -0
- package/src/head.test.ts +41 -1
- package/src/head.ts +31 -0
- package/src/icons.test.ts +135 -0
- package/src/icons.ts +140 -0
- package/src/index.ts +18 -5
- package/src/sitemap.test.ts +24 -0
- package/src/sitemap.ts +19 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { mkdtemp, readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { webManifest, writeIconSet } from "./icons.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The callbacks are the product's (resvg, sharp, an ICO packer), so the tests stub them with
|
|
9
|
+
* deterministic bytes and assert the driving: each size rendered once, files named and placed,
|
|
10
|
+
* the ICO packed from the right renders in order.
|
|
11
|
+
*/
|
|
12
|
+
const SVG = `<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>`;
|
|
13
|
+
const render = (svg: string, size: number): Uint8Array =>
|
|
14
|
+
new TextEncoder().encode(`${svg.length}:${size}`);
|
|
15
|
+
const packIco = (pngs: readonly Uint8Array[]): Uint8Array =>
|
|
16
|
+
new TextEncoder().encode(`ico[${pngs.map((png) => new TextDecoder().decode(png)).join(",")}]`);
|
|
17
|
+
|
|
18
|
+
async function dir(): Promise<string> {
|
|
19
|
+
return mkdtemp(join(tmpdir(), "frontkit-icons-"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("writeIconSet", () => {
|
|
23
|
+
it("writes one PNG per size, the touch icon, and a packed favicon.ico", async () => {
|
|
24
|
+
const outDir = await dir();
|
|
25
|
+
const files = await writeIconSet({
|
|
26
|
+
outDir,
|
|
27
|
+
svg: SVG,
|
|
28
|
+
pngSizes: [192, 512],
|
|
29
|
+
icoSizes: [16, 32],
|
|
30
|
+
render,
|
|
31
|
+
packIco,
|
|
32
|
+
});
|
|
33
|
+
expect(files).toEqual(["icon-192.png", "icon-512.png", "apple-touch-icon.png", "favicon.ico"]);
|
|
34
|
+
expect(await readdir(outDir)).toEqual([...files].sort());
|
|
35
|
+
expect(await readFile(join(outDir, "icon-192.png"), "utf8")).toBe(`${SVG.length}:192`);
|
|
36
|
+
expect(await readFile(join(outDir, "apple-touch-icon.png"), "utf8")).toBe(`${SVG.length}:180`);
|
|
37
|
+
expect(await readFile(join(outDir, "favicon.ico"), "utf8")).toBe(
|
|
38
|
+
`ico[${SVG.length}:16,${SVG.length}:32]`,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// The touch icon at a size the set already renders costs no extra raster: same bytes, one
|
|
43
|
+
// render. A renderer that bills per call makes the difference visible.
|
|
44
|
+
it("renders each size once even when the touch icon shares it", async () => {
|
|
45
|
+
const seen: number[] = [];
|
|
46
|
+
await writeIconSet({
|
|
47
|
+
outDir: await dir(),
|
|
48
|
+
svg: SVG,
|
|
49
|
+
pngSizes: [180, 512],
|
|
50
|
+
icoSizes: [180],
|
|
51
|
+
render: (svg, size) => {
|
|
52
|
+
seen.push(size);
|
|
53
|
+
return render(svg, size);
|
|
54
|
+
},
|
|
55
|
+
packIco,
|
|
56
|
+
});
|
|
57
|
+
expect(seen).toEqual([180, 512]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// A renderer that throws leaves no half-written set behind: every size renders first.
|
|
61
|
+
it("writes nothing when a render fails, and reports without writing on check", async () => {
|
|
62
|
+
const failing = async (_svg: string, size: number): Promise<Uint8Array> => {
|
|
63
|
+
if (size === 512) throw new Error("rasterizer down");
|
|
64
|
+
return render(_svg, size);
|
|
65
|
+
};
|
|
66
|
+
const outDir = await dir();
|
|
67
|
+
await expect(
|
|
68
|
+
writeIconSet({
|
|
69
|
+
outDir,
|
|
70
|
+
svg: SVG,
|
|
71
|
+
pngSizes: [192, 512],
|
|
72
|
+
icoSizes: [16],
|
|
73
|
+
render: failing,
|
|
74
|
+
packIco,
|
|
75
|
+
}),
|
|
76
|
+
).rejects.toThrow("rasterizer down");
|
|
77
|
+
expect(await readdir(outDir)).toEqual([]);
|
|
78
|
+
|
|
79
|
+
const checkDir = await dir();
|
|
80
|
+
const files = await writeIconSet({
|
|
81
|
+
outDir: checkDir,
|
|
82
|
+
svg: SVG,
|
|
83
|
+
pngSizes: [192],
|
|
84
|
+
icoSizes: [16],
|
|
85
|
+
check: true,
|
|
86
|
+
render,
|
|
87
|
+
packIco,
|
|
88
|
+
});
|
|
89
|
+
expect(files).toEqual(["icon-192.png", "apple-touch-icon.png", "favicon.ico"]);
|
|
90
|
+
expect(await readdir(checkDir)).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("webManifest", () => {
|
|
95
|
+
it("writes keys in install order with the travelled defaults", () => {
|
|
96
|
+
expect(
|
|
97
|
+
webManifest({
|
|
98
|
+
name: "Acme",
|
|
99
|
+
icons: [{ src: "/icon-192.png", sizes: "192x192", type: "image/png" }],
|
|
100
|
+
}),
|
|
101
|
+
).toBe(
|
|
102
|
+
`{\n "name": "Acme",\n "start_url": ".",\n "display": "standalone",\n` +
|
|
103
|
+
` "icons": [\n {\n "src": "/icon-192.png",\n "sizes": "192x192",\n` +
|
|
104
|
+
` "type": "image/png"\n }\n ]\n}\n`,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("keeps the optional fields and a maskable purpose", () => {
|
|
109
|
+
const txt = webManifest({
|
|
110
|
+
name: "Acme",
|
|
111
|
+
shortName: "Acme",
|
|
112
|
+
description: "Do things.",
|
|
113
|
+
scope: "/app/",
|
|
114
|
+
backgroundColor: "#ffffff",
|
|
115
|
+
themeColor: "#7c3aed",
|
|
116
|
+
icons: [{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }],
|
|
117
|
+
});
|
|
118
|
+
const parsed = JSON.parse(txt) as Record<string, unknown>;
|
|
119
|
+
expect(parsed["short_name"]).toBe("Acme");
|
|
120
|
+
expect(parsed["scope"]).toBe("/app/");
|
|
121
|
+
expect(parsed["background_color"]).toBe("#ffffff");
|
|
122
|
+
expect(parsed["theme_color"]).toBe("#7c3aed");
|
|
123
|
+
expect(parsed["icons"]).toEqual([
|
|
124
|
+
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// A manifest with no name installs as nothing, and one with no icons fails installability
|
|
129
|
+
// with no error anywhere. Both fail here instead.
|
|
130
|
+
it("refuses an empty name and an empty icon list", () => {
|
|
131
|
+
const icon = { src: "/icon-192.png", sizes: "192x192", type: "image/png" };
|
|
132
|
+
expect(() => webManifest({ name: " ", icons: [icon] })).toThrow(/name/);
|
|
133
|
+
expect(() => webManifest({ name: "Acme", icons: [] })).toThrow(/icons/);
|
|
134
|
+
});
|
|
135
|
+
});
|
package/src/icons.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PWA icons and the manifest that names them, driven the way `writeOgCards` is.
|
|
3
|
+
*
|
|
4
|
+
* The artwork is the product's — one SVG master — and so are the two operations this package
|
|
5
|
+
* cannot do without dragging image tooling into every adopter: rasterizing (resvg, sharp,
|
|
6
|
+
* satori, …) and ICO packing. Both arrive as callbacks, so this file stays dependency-free and
|
|
7
|
+
* a repo that only wants a sitemap installs neither. What ships is the part every copy got
|
|
8
|
+
* subtly wrong: which sizes exist, what they are named, and the manifest agreeing with the
|
|
9
|
+
* files on disk.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** One SVG master at one pixel size. The renderer is the caller's; this only drives it. */
|
|
15
|
+
export type RenderIcon = (svg: string, size: number) => Uint8Array | Promise<Uint8Array>;
|
|
16
|
+
|
|
17
|
+
/** Several same-size PNGs into one multi-image `favicon.ico`. */
|
|
18
|
+
export type PackIco = (pngs: readonly Uint8Array[]) => Uint8Array | Promise<Uint8Array>;
|
|
19
|
+
|
|
20
|
+
export interface WriteIconSetOptions {
|
|
21
|
+
/** Where the files land — `public/` in every donor, so they are served as static files. */
|
|
22
|
+
outDir: string;
|
|
23
|
+
/** The one master artwork. A single path with no variants is what keeps every size in step. */
|
|
24
|
+
svg: string;
|
|
25
|
+
/** PNG sizes to write, e.g. `[180, 192, 512]`. Each is rendered once even when named twice. */
|
|
26
|
+
pngSizes: readonly number[];
|
|
27
|
+
/** The file name for one PNG size. Default `` `icon-${size}.png` ``. */
|
|
28
|
+
file?: (size: number) => string;
|
|
29
|
+
/**
|
|
30
|
+
* The size written as `apple-touch-icon.png`. Default 180 — what the reader's home screen
|
|
31
|
+
* asks for. Reuses the PNG render when it is already in `pngSizes`, so the common case costs
|
|
32
|
+
* no extra raster.
|
|
33
|
+
*/
|
|
34
|
+
appleTouchSize?: number;
|
|
35
|
+
/** Sizes re-rendered and packed into `favicon.ico`, e.g. `[16, 32, 48]`. */
|
|
36
|
+
icoSizes: readonly number[];
|
|
37
|
+
/** Lay every icon out and report, without writing anything. */
|
|
38
|
+
check?: boolean;
|
|
39
|
+
render: RenderIcon;
|
|
40
|
+
packIco: PackIco;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Render one icon set and write it, plus the multi-size `favicon.ico`.
|
|
45
|
+
*
|
|
46
|
+
* Every size is rendered BEFORE anything is written, so a renderer that throws leaves no
|
|
47
|
+
* half-written set behind — the same order `writeOgCards` keeps for the same reason.
|
|
48
|
+
*/
|
|
49
|
+
export async function writeIconSet({
|
|
50
|
+
outDir,
|
|
51
|
+
svg,
|
|
52
|
+
pngSizes,
|
|
53
|
+
file = (size) => `icon-${size}.png`,
|
|
54
|
+
appleTouchSize = 180,
|
|
55
|
+
icoSizes,
|
|
56
|
+
check = false,
|
|
57
|
+
render,
|
|
58
|
+
packIco,
|
|
59
|
+
}: WriteIconSetOptions): Promise<string[]> {
|
|
60
|
+
const sizes = [...new Set([...pngSizes, appleTouchSize, ...icoSizes])].sort((a, b) => a - b);
|
|
61
|
+
const rendered = new Map<number, Uint8Array>();
|
|
62
|
+
for (const size of sizes) rendered.set(size, await render(svg, size));
|
|
63
|
+
|
|
64
|
+
const written: Array<{ file: string; png: Uint8Array }> = [];
|
|
65
|
+
for (const size of pngSizes)
|
|
66
|
+
written.push({ file: file(size), png: rendered.get(size) ?? new Uint8Array() });
|
|
67
|
+
written.push({
|
|
68
|
+
file: "apple-touch-icon.png",
|
|
69
|
+
png: rendered.get(appleTouchSize) ?? new Uint8Array(),
|
|
70
|
+
});
|
|
71
|
+
written.push({
|
|
72
|
+
file: "favicon.ico",
|
|
73
|
+
png: await packIco(icoSizes.map((size) => rendered.get(size) ?? new Uint8Array())),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (check) return written.map((entry) => entry.file);
|
|
77
|
+
|
|
78
|
+
await mkdir(outDir, { recursive: true });
|
|
79
|
+
for (const entry of written) await writeFile(join(outDir, entry.file), entry.png);
|
|
80
|
+
return written.map((entry) => entry.file);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One icon as the manifest names it. `src` is the path `writeIconSet` wrote, from the root. */
|
|
84
|
+
export interface ManifestIcon {
|
|
85
|
+
src: string;
|
|
86
|
+
/** `"192x192"`, `"48x48"`, or several space-separated when one file serves several. */
|
|
87
|
+
sizes: string;
|
|
88
|
+
type: string;
|
|
89
|
+
purpose?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface WebManifestOptions {
|
|
93
|
+
name: string;
|
|
94
|
+
shortName?: string;
|
|
95
|
+
description?: string;
|
|
96
|
+
/** Default `"."`: the manifest travels with the app, so a relative start stays correct. */
|
|
97
|
+
startUrl?: string;
|
|
98
|
+
scope?: string;
|
|
99
|
+
/** Default `"standalone"`: a PWA that opens in a tab is a bookmark, not an app. */
|
|
100
|
+
display?: string;
|
|
101
|
+
backgroundColor?: string;
|
|
102
|
+
themeColor?: string;
|
|
103
|
+
/** At least one — a manifest with no icons fails installability with no error anywhere. */
|
|
104
|
+
icons: readonly ManifestIcon[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `site.webmanifest`, as text with a trailing newline. Keys in install order, so two runs over
|
|
109
|
+
* the same options write the same bytes.
|
|
110
|
+
*/
|
|
111
|
+
export function webManifest({
|
|
112
|
+
name,
|
|
113
|
+
shortName,
|
|
114
|
+
description,
|
|
115
|
+
startUrl = ".",
|
|
116
|
+
scope,
|
|
117
|
+
display = "standalone",
|
|
118
|
+
backgroundColor,
|
|
119
|
+
themeColor,
|
|
120
|
+
icons,
|
|
121
|
+
}: WebManifestOptions): string {
|
|
122
|
+
if (name.trim() === "") throw new Error("manifest: `name` is empty");
|
|
123
|
+
if (icons.length === 0)
|
|
124
|
+
throw new Error("manifest: `icons` is empty — name what writeIconSet wrote");
|
|
125
|
+
const manifest: Record<string, unknown> = { name };
|
|
126
|
+
if (shortName !== undefined) manifest["short_name"] = shortName;
|
|
127
|
+
if (description !== undefined) manifest["description"] = description;
|
|
128
|
+
manifest["start_url"] = startUrl;
|
|
129
|
+
if (scope !== undefined) manifest["scope"] = scope;
|
|
130
|
+
manifest["display"] = display;
|
|
131
|
+
if (backgroundColor !== undefined) manifest["background_color"] = backgroundColor;
|
|
132
|
+
if (themeColor !== undefined) manifest["theme_color"] = themeColor;
|
|
133
|
+
manifest["icons"] = icons.map((icon) => ({
|
|
134
|
+
src: icon.src,
|
|
135
|
+
sizes: icon.sizes,
|
|
136
|
+
type: icon.type,
|
|
137
|
+
...(icon.purpose !== undefined && { purpose: icon.purpose }),
|
|
138
|
+
}));
|
|
139
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
140
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
* Four repos wrote it independently and each learned something the others had not. What is
|
|
10
10
|
* here is the merge; every non-obvious rule carries the reason it exists.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Five things live behind their own subpath, because each drags a dependency this barrel would
|
|
13
13
|
* otherwise force on everyone: the vite config preset at `@gusnips/vite/preset` (the React and
|
|
14
|
-
* Tailwind plugins), `renderTree` at `@gusnips/vite/render`,
|
|
15
|
-
* `@gusnips/vite/theme` (both React itself)
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* Tailwind plugins), `renderTree` at `@gusnips/vite/render`, the theme pre-paint plugin at
|
|
15
|
+
* `@gusnips/vite/theme` (both React itself), the catalog check at `@gusnips/vite/i18n`, and the
|
|
16
|
+
* contrast gate at `@gusnips/vite/contrast` (tailwindcss) — the last two run in CI and never in
|
|
17
|
+
* a build. Nothing here imports React, vite or tailwindcss, so a prerender script, an OG
|
|
18
|
+
* generator and a repo that only wants a sitemap all install exactly what they use.
|
|
18
19
|
*/
|
|
19
20
|
export { resolveKey } from "./catalog.ts";
|
|
20
21
|
// `headExtra` takes raw markup, so the moment an adopter builds a tag out of its own copy it
|
|
@@ -26,8 +27,10 @@ export {
|
|
|
26
27
|
assertRendered,
|
|
27
28
|
bakeHead,
|
|
28
29
|
EMPTY_ROOT,
|
|
30
|
+
faqJsonLd,
|
|
29
31
|
ogLocale,
|
|
30
32
|
type Alternate,
|
|
33
|
+
type FaqEntry,
|
|
31
34
|
type HeadTags,
|
|
32
35
|
type RenderedChecks,
|
|
33
36
|
} from "./head.ts";
|
|
@@ -40,6 +43,15 @@ export {
|
|
|
40
43
|
type OgCard,
|
|
41
44
|
type WriteOgCardsOptions,
|
|
42
45
|
} from "./node.ts";
|
|
46
|
+
export {
|
|
47
|
+
webManifest,
|
|
48
|
+
writeIconSet,
|
|
49
|
+
type ManifestIcon,
|
|
50
|
+
type PackIco,
|
|
51
|
+
type RenderIcon,
|
|
52
|
+
type WebManifestOptions,
|
|
53
|
+
type WriteIconSetOptions,
|
|
54
|
+
} from "./icons.ts";
|
|
43
55
|
export {
|
|
44
56
|
describeOverflow,
|
|
45
57
|
fitText,
|
|
@@ -63,6 +75,7 @@ export {
|
|
|
63
75
|
siteOrigin,
|
|
64
76
|
sitemapFor,
|
|
65
77
|
sitemapXml,
|
|
78
|
+
templateOrigin,
|
|
66
79
|
type PublicPage,
|
|
67
80
|
type RobotsOptions,
|
|
68
81
|
type SitemapEntry,
|
package/src/sitemap.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
siteOrigin,
|
|
8
8
|
sitemapFor,
|
|
9
9
|
sitemapXml,
|
|
10
|
+
templateOrigin,
|
|
10
11
|
} from "./sitemap.ts";
|
|
11
12
|
|
|
12
13
|
describe("pageFile", () => {
|
|
@@ -122,3 +123,26 @@ describe("robotsTxt", () => {
|
|
|
122
123
|
expect(txt.startsWith("User-agent: *\nAllow: /\n")).toBe(true);
|
|
123
124
|
});
|
|
124
125
|
});
|
|
126
|
+
|
|
127
|
+
describe("templateOrigin", () => {
|
|
128
|
+
// One file is the answer for the head Vite ships and for every file written after it: read
|
|
129
|
+
// the domain out of the template's own canonical instead of resolving a second source.
|
|
130
|
+
it("reads the origin out of the template's canonical", () => {
|
|
131
|
+
const template = `<html><head><link rel="canonical" href="https://acme.com/" /></head></html>`;
|
|
132
|
+
expect(templateOrigin(template)).toBe("https://acme.com");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("reads the reversed attribute order too", () => {
|
|
136
|
+
const template = `<html><head><link href="https://acme.com/docs" rel="canonical" /></head></html>`;
|
|
137
|
+
expect(templateOrigin(template)).toBe("https://acme.com");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Demanded, like the tag is in `bakeHead`: a template without one would hand every page
|
|
141
|
+
// somebody else's origin, quietly.
|
|
142
|
+
it("refuses a template with no canonical, and a relative one", () => {
|
|
143
|
+
expect(() => templateOrigin("<html><head></head></html>")).toThrow(/canonical/);
|
|
144
|
+
expect(() =>
|
|
145
|
+
templateOrigin(`<html><head><link rel="canonical" href="/pricing" /></head></html>`),
|
|
146
|
+
).toThrow(/absolute origin/);
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/sitemap.ts
CHANGED
|
@@ -54,6 +54,25 @@ export function siteOrigin(url: string): string {
|
|
|
54
54
|
return url.replace(/\/+$/, "");
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The origin the template already declares, read out of its canonical.
|
|
59
|
+
*
|
|
60
|
+
* One file is the answer for the head Vite ships and for every file written after it, so there
|
|
61
|
+
* is no second place for a domain to drift to — no env var the script has to resolve beside
|
|
62
|
+
* it. Demanded rather than defaulted, for the reason `bakeHead` demands the tag: a template
|
|
63
|
+
* without one would hand every page somebody else's origin, quietly.
|
|
64
|
+
*/
|
|
65
|
+
export function templateOrigin(template: string): string {
|
|
66
|
+
const href =
|
|
67
|
+
/<link[^>]*rel="canonical"[^>]*href="([^"]*)"/.exec(template)?.[1] ??
|
|
68
|
+
/<link[^>]*href="([^"]*)"[^>]*rel="canonical"/.exec(template)?.[1];
|
|
69
|
+
if (href === undefined)
|
|
70
|
+
throw new Error('prerender: <link rel="canonical"> not found in index.html');
|
|
71
|
+
// A canonical names a PAGE, not the origin — drop the path. `siteOrigin` first, for its
|
|
72
|
+
// scheme demand: `VITE_SITE_URL=acme.com` looks right in a `.env` and is a relative URL.
|
|
73
|
+
return new URL(siteOrigin(href)).origin;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
/**
|
|
58
77
|
* The three things about a public page that do not translate: where it is, how often it
|
|
59
78
|
* changes, and how it ranks against its siblings.
|