Skip to content

Commit 5d75d67

Browse files
committed
feat: enforce ascii slugs
1 parent f801cd0 commit 5d75d67

7 files changed

Lines changed: 133 additions & 17 deletions

File tree

scripts/build-redirect-targets.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ function parseTags(value) {
5656
.filter(Boolean);
5757
}
5858

59+
function isValidSlugSegment(segment) {
60+
return /^[A-Za-z0-9][A-Za-z0-9._~-]*$/.test(segment);
61+
}
62+
5963
function normalizeTarget(value) {
6064
const target = String(value || "").trim();
6165
if (target.startsWith("//")) return target;
@@ -355,6 +359,14 @@ function parseLine(line, lineNumber, blocklistPolicy, errors) {
355359
errors.push(`Line ${lineNumber}: invalid slug "${displaySlug}"`);
356360
}
357361

362+
for (const segment of slug.split("/")) {
363+
if (!isValidSlugSegment(segment)) {
364+
errors.push(
365+
`Line ${lineNumber}: invalid slug segment "${segment}" for "${displaySlug}"; use ASCII letters, digits, dot, underscore, tilde, or hyphen`
366+
);
367+
}
368+
}
369+
358370
if (match === "splat" && !target.includes(":splat")) {
359371
errors.push(`Line ${lineNumber}: splat target for "${displaySlug}" must include :splat`);
360372
}

scripts/build.mjs

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -473,23 +473,47 @@ function applyLegalBranding(html, siteConfig, language = "en") {
473473

474474
function applyPublicBranding(siteConfig) {
475475
const slogans = siteConfig?.branding?.slogan;
476-
if (!hasConfiguredSlogan(slogans)) return;
476+
const wordmark = siteConfig?.branding?.wordmark;
477+
const hasWordmark = Boolean(wordmark?.black || wordmark?.green);
478+
const hasSlogan = hasConfiguredSlogan(slogans);
479+
if (!hasWordmark && !hasSlogan) return;
477480

478481
rewriteHtmlFiles(BUILD_DIR, (html, filePath) => {
479-
if (!html.includes('class="instance-brand-subtitle"')) return html;
482+
let brandedHtml = html;
483+
const brandLabel = hasWordmark ? `${wordmark.black || ""}${wordmark.green || ""}` : "";
484+
485+
if (hasWordmark) {
486+
const renderedWordmark = renderConfiguredWordmark(siteConfig);
487+
brandedHtml = brandedHtml
488+
.replace(/<h1([^>]*)><span>Vanity<\/span><span>URLs<\/span><\/h1>/g, `<h1$1>${renderedWordmark}</h1>`)
489+
.replace(
490+
/(<h1 class="instance-brand-title">\s*<a href="[^"]+" aria-label=")[^"]*("[^>]*>)[\s\S]*?(<\/a>\s*<\/h1>)/g,
491+
`$1${escapeHtmlAttribute(brandLabel)}$2${renderedWordmark}$3`
492+
)
493+
.replace(/<title>([^<]*?)VanityURLs([^<]*?)<\/title>/gi, `<title>$1${escapeHtml(brandLabel)}$2</title>`)
494+
.replace(/aria-label="VanityURLs"/g, `aria-label="${escapeHtmlAttribute(brandLabel)}"`)
495+
.replace(
496+
/(<a class="wordmark" href=)"https:\/\/vanityurls\.link\/"/gi,
497+
`$1"https://${escapeHtmlAttribute(siteConfig?.operator?.short_domain || brandLabel)}/"`
498+
);
499+
}
500+
501+
if (!hasSlogan) return brandedHtml;
480502

481503
const language = languageForBuildHtmlFile(filePath);
482504
const slogan = renderBrandingSlogan(
483505
localizedSlogan(slogans, language),
484506
siteConfig?.operator,
485507
localizedSloganLinkText(siteConfig?.branding?.slogan_link_text, language)
486508
);
487-
if (!slogan) return html;
509+
if (!slogan) return brandedHtml;
510+
const subtitle = `<p class="instance-brand-subtitle">${slogan}</p>`;
488511

489-
return html.replace(
490-
/<p class="instance-brand-subtitle">[\s\S]*?<\/p>/,
491-
`<p class="instance-brand-subtitle">${slogan}</p>`
492-
);
512+
if (!brandedHtml.includes('class="instance-brand-subtitle"')) {
513+
return brandedHtml.replace(/(<a class="wordmark"[\s\S]*?<\/a>)/, `$1\n\n ${subtitle}`);
514+
}
515+
516+
return brandedHtml.replace(/<p class="instance-brand-subtitle">[\s\S]*?<\/p>/, subtitle);
493517
});
494518
}
495519

scripts/install.mjs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,8 @@ async function promptForMissing(args) {
254254
: {};
255255
args.customizePublic = await confirm(
256256
rl,
257-
"Copy default web pages to custom/public with a split-color domain wordmark?",
258-
siteConfig.branding?.custom_public !== false
257+
"Copy full default web pages to custom/public for manual template editing?",
258+
siteConfig.branding?.custom_public === true
259259
);
260260
args.wordmarkBlack = await question(
261261
rl,
@@ -302,7 +302,7 @@ function normalizeArgs(args) {
302302
if (!args.workerName) throw new Error("Worker name cannot be empty.");
303303
validateWorkerName(args.workerName);
304304
validateOperator(args.operator);
305-
if (args.customizePublic) {
305+
if (args.configureBranding) {
306306
const split = normalizeWordmarkSplit(args);
307307
args.wordmarkBlack = split.black;
308308
args.wordmarkGreen = split.green;
@@ -741,12 +741,10 @@ function updateSiteConfig(args) {
741741
slogan: args.brandingSlogans,
742742
slogan_link_text: existingSiteConfig.branding?.slogan_link_text || {},
743743
custom_public: args.customizePublic === true,
744-
wordmark: args.customizePublic
745-
? {
746-
black: args.wordmarkBlack,
747-
green: args.wordmarkGreen
748-
}
749-
: undefined
744+
wordmark: {
745+
black: args.wordmarkBlack,
746+
green: args.wordmarkGreen
747+
}
750748
}
751749
: {
752750
...(existingSiteConfig.branding || {}),

scripts/install.test.mjs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,51 @@ function runSetup(cwd, extraArgs) {
112112
);
113113
}
114114

115+
{
116+
const fixture = makeFixture();
117+
118+
runSetup(fixture, [
119+
"--domain",
120+
"v8s.link",
121+
"--worker-name",
122+
"v8s-link",
123+
"--owner",
124+
"team",
125+
"--languages",
126+
"en,fr",
127+
"--operator-timezone",
128+
"America/Toronto",
129+
"--operator-legal-name",
130+
"Example Inc.",
131+
"--operator-domain",
132+
"example.com",
133+
"--operator-abuse-contact",
134+
"abuse@example.com",
135+
"--operator-security-contact",
136+
"security@example.com",
137+
"--branding-slogan",
138+
"The official demo for Example Inc. projects",
139+
"--wordmark-black",
140+
"v8s.",
141+
"--wordmark-green",
142+
"link",
143+
"--no-customize-public"
144+
]);
145+
146+
const siteConfig = JSON.parse(fs.readFileSync(path.join(fixture, "custom", "v8s-site-config.json"), "utf8"));
147+
assert.equal(siteConfig.branding.custom_public, false);
148+
assert.equal(siteConfig.branding.wordmark.black, "v8s.");
149+
assert.equal(siteConfig.branding.wordmark.green, "link");
150+
assert.equal(fs.existsSync(path.join(fixture, "custom", "public", "en", "index.html")), false);
151+
152+
execFileSync(process.execPath, ["scripts/build.mjs"], {
153+
cwd: fixture,
154+
stdio: "pipe"
155+
});
156+
157+
const builtIndex = fs.readFileSync(path.join(fixture, "build", "index.html"), "utf8");
158+
assert.match(builtIndex, /<span>v8s\.<\/span><span>link<\/span>/);
159+
assert.match(builtIndex, /The official demo for <a href="https:\/\/example\.com">Example Inc\.<\/a> projects/);
160+
}
161+
115162
console.log("install tests ok");

scripts/registry.test.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ execFileSync(process.execPath, ["scripts/validate-runtime-registry.mjs", registr
3636
stdio: "pipe"
3737
});
3838

39+
fs.writeFileSync(
40+
linksPath,
41+
[
42+
"# slug|target|state|title|description|tags|owner|expires_at|notes",
43+
"déplier|https://example.com/docs|permanent|Docs|Docs home|docs|team||",
44+
""
45+
].join("\n")
46+
);
47+
48+
assert.throws(
49+
() =>
50+
execFileSync(process.execPath, ["scripts/build-redirect-targets.mjs", linksPath, registryPath], {
51+
encoding: "utf8",
52+
stdio: "pipe"
53+
}),
54+
(error) => String(error.stderr || "").includes("invalid slug segment")
55+
);
56+
3957
const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
4058
const baseSiteConfig = JSON.parse(fs.readFileSync("defaults/v8s-site-config.json", "utf8"));
4159
const customSiteConfigPath = "custom/v8s-site-config.json";

scripts/validate-lnk.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ if (!fs.existsSync(file)) {
103103

104104
const segments = path.split("/");
105105
for (const segment of segments) {
106-
if (!validSegment(segment)) fail(`${displayPath}: invalid segment '${segment}'`);
106+
if (!validSegment(segment)) {
107+
fail(
108+
`${displayPath}: invalid segment '${segment}'; use ASCII letters, digits, dot, underscore, tilde, or hyphen`
109+
);
110+
}
107111
}
108112

109113
if (reservedTopLevel.has(segments[0])) fail(`${displayPath}: top-level path '${segments[0]}' is reserved`);

scripts/validate-registry.mjs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ function isValidPathTarget(value) {
5454
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//");
5555
}
5656

57+
function isValidSlugSegment(segment) {
58+
return /^[A-Za-z0-9][A-Za-z0-9._~-]*$/.test(segment);
59+
}
60+
5761
function isObject(value) {
5862
return Boolean(value && typeof value === "object" && !Array.isArray(value));
5963
}
@@ -96,6 +100,15 @@ function validateLink(link, prefix, blocklistPolicy, errors, seen = null) {
96100
error(errors, `${prefix}.slug is invalid: ${link.slug}`);
97101
}
98102

103+
for (const segment of link.slug.split("/")) {
104+
if (!isValidSlugSegment(segment)) {
105+
error(
106+
errors,
107+
`${prefix}.slug contains invalid segment "${segment}"; use ASCII letters, digits, dot, underscore, tilde, or hyphen`
108+
);
109+
}
110+
}
111+
99112
const match = link.match || "exact";
100113
if (!["exact", "splat"].includes(match)) {
101114
error(errors, `${prefix}.match must be exact or splat`);

0 commit comments

Comments
 (0)