@gusnips/vite 0.5.3 → 0.5.4
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/dist/head.d.ts.map +1 -1
- package/dist/head.js +63 -24
- package/dist/head.js.map +1 -1
- package/package.json +1 -1
- package/src/head.test.ts +45 -0
- package/src/head.ts +69 -26
package/dist/head.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"head.d.ts","sourceRoot":"","sources":["../src/head.ts"],"names":[],"mappings":"AAiBA,4FAA4F;AAC5F,eAAO,MAAM,UAAU,4BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"head.d.ts","sourceRoot":"","sources":["../src/head.ts"],"names":[],"mappings":"AAiBA,4FAA4F;AAC5F,eAAO,MAAM,UAAU,4BAA0B,CAAC;AAkFlD,uFAAuF;AACvF,MAAM,WAAW,SAAS;IACxB;yBACqB;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,QAAQ;IACvB,wDAAwD;IACxD,KAAK,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;2DACuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;gDAC4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC;IAClC;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;CACxC;AAED;;;;;;;;;GASG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAG5C;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,MAAM,CA+GjE;AAED,MAAM,WAAW,cAAc;IAC7B,2FAA2F;IAC3F,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;uDACmD;IACnD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAWD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,MAAM,GAAE,cAAmB,GAC1B,IAAI,CA2CN"}
|
package/dist/head.js
CHANGED
|
@@ -16,18 +16,49 @@ import { PRERENDERED_ROUTE_ATTR } from "@gusnips/react/contract";
|
|
|
16
16
|
import { escapeAttr, escapeRegex } from "./escape.js";
|
|
17
17
|
/** The empty root a template must carry — `bakeHead` fills it, and refuses a filled one. */
|
|
18
18
|
export const EMPTY_ROOT = '<div id="root"></div>';
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* One tag, in BOTH attribute orders.
|
|
21
|
+
*
|
|
22
|
+
* `[^>]*` on either side already tolerates the attributes being split across lines, as Vite
|
|
23
|
+
* emits them. What it cannot tolerate is `content` coming FIRST — `<meta content="…"
|
|
24
|
+
* name="description">` is equally valid HTML, hand-written heads are full of it, and a regex
|
|
25
|
+
* anchored on "selector, then content" simply does not match. That matters more here than
|
|
26
|
+
* anywhere else in this file, because {@link setMeta} THROWS on no match: a template spelling
|
|
27
|
+
* one required tag the other way round is a red build with a misleading message, on a head that
|
|
28
|
+
* is perfectly correct. The fifth adopter's rig carried the reversed form as a second pattern;
|
|
29
|
+
* this is that, kept.
|
|
30
|
+
*
|
|
31
|
+
* The capture groups differ between the two, which is why they are separate patterns and not
|
|
32
|
+
* one alternation: forward captures `(…content=")value(")`, reversed captures
|
|
33
|
+
* `(<meta…content=")value("…selector)`. `$1`/`$2` mean the right thing in both.
|
|
34
|
+
*/
|
|
35
|
+
const metaResFor = (pattern) => [
|
|
36
|
+
new RegExp(`(<meta[^>]*${pattern}[^>]*content=")[^"]*(")`),
|
|
37
|
+
new RegExp(`(<meta[^>]*content=")[^"]*("[^>]*${pattern})`),
|
|
38
|
+
];
|
|
39
|
+
/** …from a selector written literally, as every caller but {@link eitherRes} writes it. */
|
|
40
|
+
const metaRes = (selector) => metaResFor(escapeRegex(selector));
|
|
41
|
+
/**
|
|
42
|
+
* Rewrite the value the first matching pattern captures between `$1` and `$2`, or null when the
|
|
43
|
+
* template carries no such tag. Tag-agnostic on purpose — the canonical `<link>` uses it too.
|
|
44
|
+
*/
|
|
45
|
+
function rewriteAttr(html, patterns, value) {
|
|
46
|
+
for (const re of patterns) {
|
|
47
|
+
if (re.test(html))
|
|
48
|
+
return html.replace(re, `$1${escapeAttr(value)}$2`);
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
21
52
|
/**
|
|
22
53
|
* Replace the `content` of one `<meta …>`. Throws when the tag is missing, so an edit to
|
|
23
54
|
* `index.html` fails the build instead of silently shipping the front door's description to
|
|
24
55
|
* every crawler.
|
|
25
56
|
*/
|
|
26
57
|
function setMeta(html, selector, value) {
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
58
|
+
const out = rewriteAttr(html, metaRes(selector), value);
|
|
59
|
+
if (out === null)
|
|
29
60
|
throw new Error(`prerender: <meta ${selector}> not found in index.html`);
|
|
30
|
-
return
|
|
61
|
+
return out;
|
|
31
62
|
}
|
|
32
63
|
/**
|
|
33
64
|
* The same regex, for a key a template may spell with EITHER attribute.
|
|
@@ -44,7 +75,7 @@ function setMeta(html, selector, value) {
|
|
|
44
75
|
* That is the exact failure this file exists to prevent, arriving through the package rather
|
|
45
76
|
* than despite it.
|
|
46
77
|
*/
|
|
47
|
-
const
|
|
78
|
+
const eitherRes = (key) => metaResFor(`(?:name|property)="${escapeRegex(key)}"`);
|
|
48
79
|
/**
|
|
49
80
|
* Replace the `content` of a tag a template is allowed not to carry.
|
|
50
81
|
*
|
|
@@ -55,10 +86,10 @@ const eitherRe = (key) => new RegExp(`(<meta[^>]*(?:name|property)="${escapeRege
|
|
|
55
86
|
* is why they are written rather than ignored. Everything a crawler actually depends on goes
|
|
56
87
|
* through `setMeta`.
|
|
57
88
|
*
|
|
58
|
-
* Takes the
|
|
89
|
+
* Takes the patterns rather than the selector, so each caller picks how strict the spelling is.
|
|
59
90
|
*/
|
|
60
|
-
function setMetaIfPresent(html,
|
|
61
|
-
return
|
|
91
|
+
function setMetaIfPresent(html, patterns, value) {
|
|
92
|
+
return rewriteAttr(html, patterns, value) ?? html;
|
|
62
93
|
}
|
|
63
94
|
/**
|
|
64
95
|
* A BCP-47 tag in Open Graph's spelling: underscore, and a TERRITORY it will not infer.
|
|
@@ -88,35 +119,40 @@ export function bakeHead(template, tags) {
|
|
|
88
119
|
html = setMeta(html, 'name="description"', tags.description);
|
|
89
120
|
html = setMeta(html, 'property="og:title"', shareTitle);
|
|
90
121
|
html = setMeta(html, 'property="og:description"', shareDescription);
|
|
91
|
-
html = setMetaIfPresent(html,
|
|
92
|
-
html = setMetaIfPresent(html,
|
|
93
|
-
html = setMetaIfPresent(html,
|
|
122
|
+
html = setMetaIfPresent(html, metaRes('name="title"'), tags.title);
|
|
123
|
+
html = setMetaIfPresent(html, eitherRes("twitter:title"), shareTitle);
|
|
124
|
+
html = setMetaIfPresent(html, eitherRes("twitter:description"), shareDescription);
|
|
94
125
|
if (tags.image !== undefined) {
|
|
95
126
|
html = setMeta(html, 'property="og:image"', tags.image);
|
|
96
|
-
html = setMetaIfPresent(html,
|
|
127
|
+
html = setMetaIfPresent(html, eitherRes("twitter:image"), tags.image);
|
|
97
128
|
// A card's description goes with the card. Left alone, every page's own card was described
|
|
98
129
|
// with the front page's title — the stale value this function exists to prevent, on the one
|
|
99
130
|
// tag written for somebody who cannot see the picture.
|
|
100
|
-
html = setMetaIfPresent(html,
|
|
131
|
+
html = setMetaIfPresent(html, eitherRes("og:image:alt"), shareTitle);
|
|
101
132
|
}
|
|
102
133
|
if (tags.canonical === null) {
|
|
103
134
|
// Strip rather than blank: an empty canonical is a claim about "" and an empty og:url is a
|
|
104
135
|
// share card pointing at the origin root. The image is left alone — it is a picture, not a
|
|
105
136
|
// claim about this address, and a dead link that still unfurls the brand card is fine.
|
|
106
137
|
html = html
|
|
107
|
-
.replace(/\s*<link
|
|
138
|
+
.replace(/\s*<link[^>]*rel="canonical"[^>]*>/, "")
|
|
108
139
|
.replace(/\s*<meta[^>]*(?:name|property)="(?:og|twitter):url"[^>]*>/g, "");
|
|
109
140
|
}
|
|
110
141
|
else {
|
|
111
142
|
html = setMeta(html, 'property="og:url"', tags.canonical);
|
|
112
|
-
html = setMetaIfPresent(html,
|
|
143
|
+
html = setMetaIfPresent(html, eitherRes("twitter:url"), tags.canonical);
|
|
113
144
|
// Demanded, like every meta tag a crawler depends on. `String.replace` with no match is a
|
|
114
145
|
// silent no-op, so a template that never had a canonical would get one on no page at all
|
|
115
146
|
// and say nothing about it — the exact failure `setMeta` exists to turn into a red build.
|
|
116
|
-
|
|
117
|
-
|
|
147
|
+
// Both attribute orders, for the reason `metaResFor` gives — and this is the branch where
|
|
148
|
+
// getting it wrong REFUSES a correct head rather than skipping it.
|
|
149
|
+
const written = rewriteAttr(html, [
|
|
150
|
+
/(<link[^>]*rel="canonical"[^>]*href=")[^"]*(")/,
|
|
151
|
+
/(<link[^>]*href=")[^"]*("[^>]*rel="canonical")/,
|
|
152
|
+
], tags.canonical);
|
|
153
|
+
if (written === null)
|
|
118
154
|
throw new Error('prerender: <link rel="canonical"> not found in index.html');
|
|
119
|
-
html =
|
|
155
|
+
html = written;
|
|
120
156
|
}
|
|
121
157
|
if (tags.alternates?.length) {
|
|
122
158
|
const links = tags.alternates
|
|
@@ -129,7 +165,11 @@ export function bakeHead(template, tags) {
|
|
|
129
165
|
// APPENDED rather than set, because a Vite template carries no such tag — but one that
|
|
130
166
|
// does would end up with two, and a crawler reading the first would get the template's
|
|
131
167
|
// language on every page. So drop whatever is there before writing this page's own.
|
|
132
|
-
|
|
168
|
+
// `[^>]*` before the attribute as well as after it: a template that writes `content` first
|
|
169
|
+
// would survive the strip and keep its own tag beside the appended one, and a crawler
|
|
170
|
+
// reading the first of two gets the template's language on every page — which is the exact
|
|
171
|
+
// outcome the strip is here to prevent.
|
|
172
|
+
html = html.replace(/\s*<meta[^>]*property="og:locale(?::alternate)?"[^>]*>/g, "");
|
|
133
173
|
const alternates = (tags.alternates ?? [])
|
|
134
174
|
.filter((alt) => alt.hreflang !== "x-default" && alt.hreflang !== tags.lang)
|
|
135
175
|
.map((alt) => ` <meta property="og:locale:alternate" content="${escapeAttr(ogLocale(alt.hreflang))}" />`);
|
|
@@ -139,10 +179,9 @@ export function bakeHead(template, tags) {
|
|
|
139
179
|
// carrying both leaves a contradiction to each crawler's own tie-break rule, and one adopter's
|
|
140
180
|
// 404 shipped exactly that.
|
|
141
181
|
if (tags.noindex) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
: html.replace("</head>", ` <meta name="robots" content="noindex" />\n </head>`);
|
|
182
|
+
html =
|
|
183
|
+
rewriteAttr(html, metaRes('name="robots"'), "noindex") ??
|
|
184
|
+
html.replace("</head>", ` <meta name="robots" content="noindex" />\n </head>`);
|
|
146
185
|
}
|
|
147
186
|
if (tags.headExtra)
|
|
148
187
|
html = html.replace("</head>", ` ${tags.headExtra}\n </head>`);
|
package/dist/head.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"head.js","sourceRoot":"","sources":["../src/head.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,yFAAyF;AACzF,8FAA8F;AAC9F,yDAAyD;AACzD,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAEtD,4FAA4F;AAC5F,MAAM,CAAC,MAAM,UAAU,GAAG,uBAAuB,CAAC;AAElD
|
|
1
|
+
{"version":3,"file":"head.js","sourceRoot":"","sources":["../src/head.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/head.test.ts
CHANGED
|
@@ -56,6 +56,22 @@ const LEAN_TEMPLATE = `<!doctype html>
|
|
|
56
56
|
*/
|
|
57
57
|
const NAME_TWITTER_TEMPLATE = TEMPLATE.replace(/property="twitter:/g, 'name="twitter:');
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The same head with `content` written FIRST, on every tag including the canonical.
|
|
61
|
+
*
|
|
62
|
+
* Equally valid HTML, ordinary in a hand-written template, and the fifth adopter's own rig
|
|
63
|
+
* carried a second pattern for exactly this. Here it is worse than the spelling above: the
|
|
64
|
+
* narrow regex did not merely skip these tags, it made `setMeta` THROW — so a correct head
|
|
65
|
+
* failed the build saying the tag was "not found in index.html".
|
|
66
|
+
*/
|
|
67
|
+
const CONTENT_FIRST_TEMPLATE = TEMPLATE.replace(
|
|
68
|
+
/<meta ((?:name|property)="[^"]*") (content="[^"]*")/g,
|
|
69
|
+
"<meta $2 $1",
|
|
70
|
+
).replace(
|
|
71
|
+
'<link rel="canonical" href="https://acme.com/" />',
|
|
72
|
+
'<link href="https://acme.com/" rel="canonical" />',
|
|
73
|
+
);
|
|
74
|
+
|
|
59
75
|
const BASE = { title: "T", description: "D", canonical: "https://acme.com/pricing" };
|
|
60
76
|
|
|
61
77
|
describe("bakeHead", () => {
|
|
@@ -114,6 +130,35 @@ describe("bakeHead", () => {
|
|
|
114
130
|
expect(html).toContain('<meta name="twitter:description" content="D" />');
|
|
115
131
|
});
|
|
116
132
|
|
|
133
|
+
// The other half of the same class, and the louder one: a required tag written the other way
|
|
134
|
+
// round was a red build on a head that is perfectly correct.
|
|
135
|
+
it("writes a head that spells the content attribute first", () => {
|
|
136
|
+
const html = bakeHead(CONTENT_FIRST_TEMPLATE, { ...BASE, ogTitle: "share" });
|
|
137
|
+
expect(html).toContain('<meta content="D" name="description" />');
|
|
138
|
+
expect(html).toContain('<meta content="share" property="og:title" />');
|
|
139
|
+
expect(html).toContain('<meta content="share" property="twitter:title" />');
|
|
140
|
+
expect(html).toContain('<link href="https://acme.com/pricing" rel="canonical" />');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("strips a reversed canonical and share URL from a shell", () => {
|
|
144
|
+
const html = bakeHead(CONTENT_FIRST_TEMPLATE, { ...BASE, canonical: null });
|
|
145
|
+
expect(html).not.toContain('rel="canonical"');
|
|
146
|
+
expect(html).not.toContain("og:url");
|
|
147
|
+
expect(html).not.toContain('content=""');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Strip-then-append, so a strip that misses leaves two tags — and a crawler reads the first,
|
|
151
|
+
// which is the template's language rather than this page's.
|
|
152
|
+
it("replaces a reversed og:locale instead of writing a second one", () => {
|
|
153
|
+
const withLocale = CONTENT_FIRST_TEMPLATE.replace(
|
|
154
|
+
"</head>",
|
|
155
|
+
' <meta content="en_US" property="og:locale" />\n </head>',
|
|
156
|
+
);
|
|
157
|
+
const html = bakeHead(withLocale, { ...BASE, lang: "pt-BR" });
|
|
158
|
+
expect(html.match(/property="og:locale"/g)).toHaveLength(1);
|
|
159
|
+
expect(html).toContain('content="pt_BR"');
|
|
160
|
+
});
|
|
161
|
+
|
|
117
162
|
it("strips a share URL spelled with either attribute", () => {
|
|
118
163
|
const html = bakeHead(NAME_TWITTER_TEMPLATE, { ...BASE, canonical: null });
|
|
119
164
|
expect(html).not.toContain("twitter:url");
|
package/src/head.ts
CHANGED
|
@@ -18,9 +18,40 @@ import { escapeAttr, escapeRegex } from "./escape.ts";
|
|
|
18
18
|
/** The empty root a template must carry — `bakeHead` fills it, and refuses a filled one. */
|
|
19
19
|
export const EMPTY_ROOT = '<div id="root"></div>';
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
/**
|
|
22
|
+
* One tag, in BOTH attribute orders.
|
|
23
|
+
*
|
|
24
|
+
* `[^>]*` on either side already tolerates the attributes being split across lines, as Vite
|
|
25
|
+
* emits them. What it cannot tolerate is `content` coming FIRST — `<meta content="…"
|
|
26
|
+
* name="description">` is equally valid HTML, hand-written heads are full of it, and a regex
|
|
27
|
+
* anchored on "selector, then content" simply does not match. That matters more here than
|
|
28
|
+
* anywhere else in this file, because {@link setMeta} THROWS on no match: a template spelling
|
|
29
|
+
* one required tag the other way round is a red build with a misleading message, on a head that
|
|
30
|
+
* is perfectly correct. The fifth adopter's rig carried the reversed form as a second pattern;
|
|
31
|
+
* this is that, kept.
|
|
32
|
+
*
|
|
33
|
+
* The capture groups differ between the two, which is why they are separate patterns and not
|
|
34
|
+
* one alternation: forward captures `(…content=")value(")`, reversed captures
|
|
35
|
+
* `(<meta…content=")value("…selector)`. `$1`/`$2` mean the right thing in both.
|
|
36
|
+
*/
|
|
37
|
+
const metaResFor = (pattern: string): readonly RegExp[] => [
|
|
38
|
+
new RegExp(`(<meta[^>]*${pattern}[^>]*content=")[^"]*(")`),
|
|
39
|
+
new RegExp(`(<meta[^>]*content=")[^"]*("[^>]*${pattern})`),
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** …from a selector written literally, as every caller but {@link eitherRes} writes it. */
|
|
43
|
+
const metaRes = (selector: string): readonly RegExp[] => metaResFor(escapeRegex(selector));
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rewrite the value the first matching pattern captures between `$1` and `$2`, or null when the
|
|
47
|
+
* template carries no such tag. Tag-agnostic on purpose — the canonical `<link>` uses it too.
|
|
48
|
+
*/
|
|
49
|
+
function rewriteAttr(html: string, patterns: readonly RegExp[], value: string): string | null {
|
|
50
|
+
for (const re of patterns) {
|
|
51
|
+
if (re.test(html)) return html.replace(re, `$1${escapeAttr(value)}$2`);
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
24
55
|
|
|
25
56
|
/**
|
|
26
57
|
* Replace the `content` of one `<meta …>`. Throws when the tag is missing, so an edit to
|
|
@@ -28,9 +59,9 @@ const metaRe = (selector: string): RegExp =>
|
|
|
28
59
|
* every crawler.
|
|
29
60
|
*/
|
|
30
61
|
function setMeta(html: string, selector: string, value: string): string {
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
return
|
|
62
|
+
const out = rewriteAttr(html, metaRes(selector), value);
|
|
63
|
+
if (out === null) throw new Error(`prerender: <meta ${selector}> not found in index.html`);
|
|
64
|
+
return out;
|
|
34
65
|
}
|
|
35
66
|
|
|
36
67
|
/**
|
|
@@ -48,8 +79,8 @@ function setMeta(html: string, selector: string, value: string): string {
|
|
|
48
79
|
* That is the exact failure this file exists to prevent, arriving through the package rather
|
|
49
80
|
* than despite it.
|
|
50
81
|
*/
|
|
51
|
-
const
|
|
52
|
-
|
|
82
|
+
const eitherRes = (key: string): readonly RegExp[] =>
|
|
83
|
+
metaResFor(`(?:name|property)="${escapeRegex(key)}"`);
|
|
53
84
|
|
|
54
85
|
/**
|
|
55
86
|
* Replace the `content` of a tag a template is allowed not to carry.
|
|
@@ -61,10 +92,10 @@ const eitherRe = (key: string): RegExp =>
|
|
|
61
92
|
* is why they are written rather than ignored. Everything a crawler actually depends on goes
|
|
62
93
|
* through `setMeta`.
|
|
63
94
|
*
|
|
64
|
-
* Takes the
|
|
95
|
+
* Takes the patterns rather than the selector, so each caller picks how strict the spelling is.
|
|
65
96
|
*/
|
|
66
|
-
function setMetaIfPresent(html: string,
|
|
67
|
-
return
|
|
97
|
+
function setMetaIfPresent(html: string, patterns: readonly RegExp[], value: string): string {
|
|
98
|
+
return rewriteAttr(html, patterns, value) ?? html;
|
|
68
99
|
}
|
|
69
100
|
|
|
70
101
|
/** One `<link rel="alternate" hreflang>` — this page's address in another language. */
|
|
@@ -161,17 +192,17 @@ export function bakeHead(template: string, tags: HeadTags): string {
|
|
|
161
192
|
html = setMeta(html, 'name="description"', tags.description);
|
|
162
193
|
html = setMeta(html, 'property="og:title"', shareTitle);
|
|
163
194
|
html = setMeta(html, 'property="og:description"', shareDescription);
|
|
164
|
-
html = setMetaIfPresent(html,
|
|
165
|
-
html = setMetaIfPresent(html,
|
|
166
|
-
html = setMetaIfPresent(html,
|
|
195
|
+
html = setMetaIfPresent(html, metaRes('name="title"'), tags.title);
|
|
196
|
+
html = setMetaIfPresent(html, eitherRes("twitter:title"), shareTitle);
|
|
197
|
+
html = setMetaIfPresent(html, eitherRes("twitter:description"), shareDescription);
|
|
167
198
|
|
|
168
199
|
if (tags.image !== undefined) {
|
|
169
200
|
html = setMeta(html, 'property="og:image"', tags.image);
|
|
170
|
-
html = setMetaIfPresent(html,
|
|
201
|
+
html = setMetaIfPresent(html, eitherRes("twitter:image"), tags.image);
|
|
171
202
|
// A card's description goes with the card. Left alone, every page's own card was described
|
|
172
203
|
// with the front page's title — the stale value this function exists to prevent, on the one
|
|
173
204
|
// tag written for somebody who cannot see the picture.
|
|
174
|
-
html = setMetaIfPresent(html,
|
|
205
|
+
html = setMetaIfPresent(html, eitherRes("og:image:alt"), shareTitle);
|
|
175
206
|
}
|
|
176
207
|
|
|
177
208
|
if (tags.canonical === null) {
|
|
@@ -179,18 +210,27 @@ export function bakeHead(template: string, tags: HeadTags): string {
|
|
|
179
210
|
// share card pointing at the origin root. The image is left alone — it is a picture, not a
|
|
180
211
|
// claim about this address, and a dead link that still unfurls the brand card is fine.
|
|
181
212
|
html = html
|
|
182
|
-
.replace(/\s*<link
|
|
213
|
+
.replace(/\s*<link[^>]*rel="canonical"[^>]*>/, "")
|
|
183
214
|
.replace(/\s*<meta[^>]*(?:name|property)="(?:og|twitter):url"[^>]*>/g, "");
|
|
184
215
|
} else {
|
|
185
216
|
html = setMeta(html, 'property="og:url"', tags.canonical);
|
|
186
|
-
html = setMetaIfPresent(html,
|
|
217
|
+
html = setMetaIfPresent(html, eitherRes("twitter:url"), tags.canonical);
|
|
187
218
|
// Demanded, like every meta tag a crawler depends on. `String.replace` with no match is a
|
|
188
219
|
// silent no-op, so a template that never had a canonical would get one on no page at all
|
|
189
220
|
// and say nothing about it — the exact failure `setMeta` exists to turn into a red build.
|
|
190
|
-
|
|
191
|
-
|
|
221
|
+
// Both attribute orders, for the reason `metaResFor` gives — and this is the branch where
|
|
222
|
+
// getting it wrong REFUSES a correct head rather than skipping it.
|
|
223
|
+
const written = rewriteAttr(
|
|
224
|
+
html,
|
|
225
|
+
[
|
|
226
|
+
/(<link[^>]*rel="canonical"[^>]*href=")[^"]*(")/,
|
|
227
|
+
/(<link[^>]*href=")[^"]*("[^>]*rel="canonical")/,
|
|
228
|
+
],
|
|
229
|
+
tags.canonical,
|
|
230
|
+
);
|
|
231
|
+
if (written === null)
|
|
192
232
|
throw new Error('prerender: <link rel="canonical"> not found in index.html');
|
|
193
|
-
html =
|
|
233
|
+
html = written;
|
|
194
234
|
}
|
|
195
235
|
|
|
196
236
|
if (tags.alternates?.length) {
|
|
@@ -208,7 +248,11 @@ export function bakeHead(template: string, tags: HeadTags): string {
|
|
|
208
248
|
// APPENDED rather than set, because a Vite template carries no such tag — but one that
|
|
209
249
|
// does would end up with two, and a crawler reading the first would get the template's
|
|
210
250
|
// language on every page. So drop whatever is there before writing this page's own.
|
|
211
|
-
|
|
251
|
+
// `[^>]*` before the attribute as well as after it: a template that writes `content` first
|
|
252
|
+
// would survive the strip and keep its own tag beside the appended one, and a crawler
|
|
253
|
+
// reading the first of two gets the template's language on every page — which is the exact
|
|
254
|
+
// outcome the strip is here to prevent.
|
|
255
|
+
html = html.replace(/\s*<meta[^>]*property="og:locale(?::alternate)?"[^>]*>/g, "");
|
|
212
256
|
const alternates = (tags.alternates ?? [])
|
|
213
257
|
.filter((alt) => alt.hreflang !== "x-default" && alt.hreflang !== tags.lang)
|
|
214
258
|
.map(
|
|
@@ -227,10 +271,9 @@ export function bakeHead(template: string, tags: HeadTags): string {
|
|
|
227
271
|
// carrying both leaves a contradiction to each crawler's own tie-break rule, and one adopter's
|
|
228
272
|
// 404 shipped exactly that.
|
|
229
273
|
if (tags.noindex) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
: html.replace("</head>", ` <meta name="robots" content="noindex" />\n </head>`);
|
|
274
|
+
html =
|
|
275
|
+
rewriteAttr(html, metaRes('name="robots"'), "noindex") ??
|
|
276
|
+
html.replace("</head>", ` <meta name="robots" content="noindex" />\n </head>`);
|
|
234
277
|
}
|
|
235
278
|
if (tags.headExtra) html = html.replace("</head>", ` ${tags.headExtra}\n </head>`);
|
|
236
279
|
|