From 8c4813ac1d6939b09929c12eeea06880115f6ee5 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 1 Nov 2024 09:04:38 -0700 Subject: [PATCH] preserveIndex, preserveExtension (#1784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * preserveIndex, preserveExtension * add unit tests --------- Co-authored-by: Philippe Rivière --- docs/config.md | 8 +- docs/getting-started.md | 2 +- src/config.ts | 25 ++- src/preview.ts | 4 +- templates/default/observablehq.config.js.tmpl | 3 +- templates/empty/observablehq.config.js.tmpl | 3 +- test/config-test.ts | 154 +++++++++++++++++- 7 files changed, 183 insertions(+), 16 deletions(-) diff --git a/docs/config.md b/docs/config.md index afac78a27..960bad819 100644 --- a/docs/config.md +++ b/docs/config.md @@ -197,9 +197,13 @@ footer: ({path}) => ` +## preserveIndex -Whether page links should be “clean”, _i.e._, formatted without a `.html` extension. Defaults to true. If true, a link to `config.html` will be formatted as `config`. Regardless of this setting, a link to an index page will drop the implied `index.html`; for example `foo/index.html` will be formatted as `foo/`. +Whether page links should preserve `/index` for directories. Defaults to false. If true, a link to `/` will be formatted as `/index` if the **preserveExtension** option is false or `/index.html` if the **preserveExtension** option is true. + +## preserveExtension + +Whether page links should preserve the `.html` extension. Defaults to false. If true, a link to `/foo` will be formatted as `/foo.html`. ## toc diff --git a/docs/getting-started.md b/docs/getting-started.md index 9587bec7f..ede7d4390 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -535,7 +535,7 @@ The build command generates the `dist` directory; you can then copy
npx http-server dist
-
By default, Framework generates “clean” URLs by dropping the `.html` extension from page links. Not all webhosts support this; some need the cleanUrls config option set to false.
+
By default, Framework generates “clean” URLs by dropping the `.html` extension from page links. Not all webhosts support this; some need the preserveExtension config option set to true.
When deploying to GitHub Pages without using GitHub’s related actions (configure-pages, deploy-pages, and diff --git a/src/config.ts b/src/config.ts index 126445a90..323577587 100644 --- a/src/config.ts +++ b/src/config.ts @@ -124,6 +124,8 @@ export interface ConfigSpec { typographer?: unknown; quotes?: unknown; cleanUrls?: unknown; + preserveIndex?: unknown; + preserveExtension?: unknown; markdownIt?: unknown; } @@ -259,7 +261,7 @@ export function normalizeConfig(spec: ConfigSpec = {}, defaultRoot?: string, wat const footer = pageFragment(spec.footer === undefined ? defaultFooter() : spec.footer); const search = spec.search == null || spec.search === false ? null : normalizeSearch(spec.search as any); const interpreters = normalizeInterpreters(spec.interpreters as any); - const normalizePath = getPathNormalizer(spec.cleanUrls); + const normalizePath = getPathNormalizer(spec); // If this path ends with a slash, then add an implicit /index to the // end of the path. Otherwise, remove the .html extension (we use clean @@ -324,13 +326,22 @@ function normalizeDynamicPaths(spec: unknown): Config["paths"] { return async function* () { yield* paths; }; // prettier-ignore } -function getPathNormalizer(spec: unknown = true): (path: string) => string { - const cleanUrls = Boolean(spec); +function normalizeCleanUrls(spec: unknown): boolean { + console.warn(`${yellow("Warning:")} the ${bold("cleanUrls")} option is deprecated; use ${bold("preserveIndex")} and ${bold("preserveExtension")} instead.`); // prettier-ignore + return !spec; +} + +function getPathNormalizer(spec: ConfigSpec): (path: string) => string { + const preserveIndex = spec.preserveIndex !== undefined ? Boolean(spec.preserveIndex) : false; + const preserveExtension = spec.preserveExtension !== undefined ? Boolean(spec.preserveExtension) : spec.cleanUrls !== undefined ? normalizeCleanUrls(spec.cleanUrls) : false; // prettier-ignore return (path) => { - if (path && !path.endsWith("/") && !extname(path)) path += ".html"; - if (path === "index.html") path = "."; - else if (path.endsWith("/index.html")) path = path.slice(0, -"index.html".length); - else if (cleanUrls) path = path.replace(/\.html$/, ""); + const ext = extname(path); + if (path.endsWith(".")) path += "/"; + if (ext === ".html") path = path.slice(0, -".html".length); + if (path.endsWith("/index")) path = path.slice(0, -"index".length); + if (preserveIndex && path.endsWith("/")) path += "index"; + if (!preserveIndex && path === "index") path = "."; + if (preserveExtension && path && !path.endsWith(".") && !path.endsWith("/") && !extname(path)) path += ".html"; return path; }; } diff --git a/src/preview.ts b/src/preview.ts index 42f3c931a..21a90f7c7 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -176,8 +176,8 @@ export class PreviewServer { } else { if ((pathname = normalize(pathname)).startsWith("..")) throw new Error("Invalid path: " + pathname); - // Normalize the pathname (e.g., adding ".html" if cleanUrls is false, - // dropping ".html" if cleanUrls is true) and redirect if necessary. + // Normalize the pathname (e.g., adding ".html" or removing ".html" + // based on preserveExtension) and redirect if necessary. const normalizedPathname = encodeURI(config.normalizePath(pathname)); if (url.pathname !== normalizedPathname) { res.writeHead(302, {Location: normalizedPathname + url.search}); diff --git a/templates/default/observablehq.config.js.tmpl b/templates/default/observablehq.config.js.tmpl index bfa5ef704..21c860028 100644 --- a/templates/default/observablehq.config.js.tmpl +++ b/templates/default/observablehq.config.js.tmpl @@ -33,5 +33,6 @@ export default { // search: true, // activate search // linkify: true, // convert URLs in Markdown to links // typographer: false, // smart quotes and other typographic improvements - // cleanUrls: true, // drop .html from URLs + // preserveExtension: false, // drop .html from URLs + // preserveIndex: false, // drop /index from URLs }; diff --git a/templates/empty/observablehq.config.js.tmpl b/templates/empty/observablehq.config.js.tmpl index bfa5ef704..21c860028 100644 --- a/templates/empty/observablehq.config.js.tmpl +++ b/templates/empty/observablehq.config.js.tmpl @@ -33,5 +33,6 @@ export default { // search: true, // activate search // linkify: true, // convert URLs in Markdown to links // typographer: false, // smart quotes and other typographic improvements - // cleanUrls: true, // drop .html from URLs + // preserveExtension: false, // drop .html from URLs + // preserveIndex: false, // drop /index from URLs }; diff --git a/test/config-test.ts b/test/config-test.ts index 2203372bb..575c0100c 100644 --- a/test/config-test.ts +++ b/test/config-test.ts @@ -187,7 +187,7 @@ describe("normalizeConfig(spec, root)", () => { }); }); -describe("normalizePath(path) with {cleanUrls: false}", () => { +describe("normalizePath(path) with {cleanUrls: false} (deprecated)", () => { const root = "test/input"; const normalize = config({cleanUrls: false}, root).normalizePath; it("appends .html to extension-less links", () => { @@ -234,7 +234,7 @@ describe("normalizePath(path) with {cleanUrls: false}", () => { }); }); -describe("normalizePath(path) with {cleanUrls: true}", () => { +describe("normalizePath(path) with {cleanUrls: true} (deprecated)", () => { const root = "test/input"; const normalize = config({cleanUrls: true}, root).normalizePath; it("does not append .html to extension-less links", () => { @@ -283,6 +283,156 @@ describe("normalizePath(path) with {cleanUrls: true}", () => { }); }); +describe("normalizePath(path) with {preserveExtension: true}", () => { + const root = "test/input"; + const normalize = config({preserveExtension: true}, root).normalizePath; + it("appends .html to extension-less links", () => { + assert.strictEqual(normalize("foo"), "foo.html"); + }); + it("does not append .html to extensioned links", () => { + assert.strictEqual(normalize("foo.png"), "foo.png"); + assert.strictEqual(normalize("foo.html"), "foo.html"); + assert.strictEqual(normalize("foo.md"), "foo.md"); + }); + it("preserves absolute paths", () => { + assert.strictEqual(normalize("/foo"), "/foo.html"); + assert.strictEqual(normalize("/foo.html"), "/foo.html"); + assert.strictEqual(normalize("/foo.png"), "/foo.png"); + }); + it("converts index links to directories", () => { + assert.strictEqual(normalize("foo/index"), "foo/"); + assert.strictEqual(normalize("foo/index.html"), "foo/"); + assert.strictEqual(normalize("../index"), "../"); + assert.strictEqual(normalize("../index.html"), "../"); + assert.strictEqual(normalize("./index"), "./"); + assert.strictEqual(normalize("./index.html"), "./"); + assert.strictEqual(normalize("/index"), "/"); + assert.strictEqual(normalize("/index.html"), "/"); + assert.strictEqual(normalize("index"), "."); + assert.strictEqual(normalize("index.html"), "."); + }); + it("preserves links to directories", () => { + assert.strictEqual(normalize(""), ""); + assert.strictEqual(normalize("/"), "/"); + assert.strictEqual(normalize("./"), "./"); + assert.strictEqual(normalize("../"), "../"); + assert.strictEqual(normalize("foo/"), "foo/"); + assert.strictEqual(normalize("./foo/"), "./foo/"); + assert.strictEqual(normalize("../foo/"), "../foo/"); + assert.strictEqual(normalize("../sub/"), "../sub/"); + }); + it("preserves a relative path", () => { + assert.strictEqual(normalize("foo"), "foo.html"); + assert.strictEqual(normalize("./foo"), "./foo.html"); + assert.strictEqual(normalize("../foo"), "../foo.html"); + assert.strictEqual(normalize("./foo.png"), "./foo.png"); + assert.strictEqual(normalize("../foo.png"), "../foo.png"); + }); +}); + +describe("normalizePath(path) with {preserveExtension: false}", () => { + const root = "test/input"; + const normalize = config({preserveExtension: false}, root).normalizePath; + it("does not append .html to extension-less links", () => { + assert.strictEqual(normalize("foo"), "foo"); + }); + it("does not append .html to extensioned links", () => { + assert.strictEqual(normalize("foo.png"), "foo.png"); + assert.strictEqual(normalize("foo.md"), "foo.md"); + }); + it("removes .html from extensioned links", () => { + assert.strictEqual(normalize("foo.html"), "foo"); + }); + it("preserves absolute paths", () => { + assert.strictEqual(normalize("/foo"), "/foo"); + assert.strictEqual(normalize("/foo.html"), "/foo"); + assert.strictEqual(normalize("/foo.png"), "/foo.png"); + }); + it("converts index links to directories", () => { + assert.strictEqual(normalize("foo/index"), "foo/"); + assert.strictEqual(normalize("foo/index.html"), "foo/"); + assert.strictEqual(normalize("../index"), "../"); + assert.strictEqual(normalize("../index.html"), "../"); + assert.strictEqual(normalize("./index"), "./"); + assert.strictEqual(normalize("./index.html"), "./"); + assert.strictEqual(normalize("/index"), "/"); + assert.strictEqual(normalize("/index.html"), "/"); + assert.strictEqual(normalize("index"), "."); + assert.strictEqual(normalize("index.html"), "."); + }); + it("preserves links to directories", () => { + assert.strictEqual(normalize(""), ""); + assert.strictEqual(normalize("/"), "/"); + assert.strictEqual(normalize("./"), "./"); + assert.strictEqual(normalize("../"), "../"); + assert.strictEqual(normalize("foo/"), "foo/"); + assert.strictEqual(normalize("./foo/"), "./foo/"); + assert.strictEqual(normalize("../foo/"), "../foo/"); + assert.strictEqual(normalize("../sub/"), "../sub/"); + }); + it("preserves a relative path", () => { + assert.strictEqual(normalize("foo"), "foo"); + assert.strictEqual(normalize("./foo"), "./foo"); + assert.strictEqual(normalize("../foo"), "../foo"); + assert.strictEqual(normalize("./foo.png"), "./foo.png"); + assert.strictEqual(normalize("../foo.png"), "../foo.png"); + }); +}); + +describe("normalizePath(path) with {preserveIndex: true}", () => { + const root = "test/input"; + const normalize = config({preserveIndex: true}, root).normalizePath; + it("preserves index links", () => { + assert.strictEqual(normalize("foo/index"), "foo/index"); + assert.strictEqual(normalize("foo/index.html"), "foo/index"); + assert.strictEqual(normalize("../index"), "../index"); + assert.strictEqual(normalize("../index.html"), "../index"); + assert.strictEqual(normalize("./index"), "./index"); + assert.strictEqual(normalize("./index.html"), "./index"); + assert.strictEqual(normalize("/index"), "/index"); + assert.strictEqual(normalize("/index.html"), "/index"); + assert.strictEqual(normalize("index"), "index"); + assert.strictEqual(normalize("index.html"), "index"); + }); + it("converts links to directories", () => { + assert.strictEqual(normalize(""), ""); + assert.strictEqual(normalize("/"), "/index"); + assert.strictEqual(normalize("./"), "./index"); + assert.strictEqual(normalize("../"), "../index"); + assert.strictEqual(normalize("foo/"), "foo/index"); + assert.strictEqual(normalize("./foo/"), "./foo/index"); + assert.strictEqual(normalize("../foo/"), "../foo/index"); + assert.strictEqual(normalize("../sub/"), "../sub/index"); + }); +}); + +describe("normalizePath(path) with {preserveIndex: true, preserveExtension: true}", () => { + const root = "test/input"; + const normalize = config({preserveIndex: true, preserveExtension: true}, root).normalizePath; + it("preserves index links", () => { + assert.strictEqual(normalize("foo/index"), "foo/index.html"); + assert.strictEqual(normalize("foo/index.html"), "foo/index.html"); + assert.strictEqual(normalize("../index"), "../index.html"); + assert.strictEqual(normalize("../index.html"), "../index.html"); + assert.strictEqual(normalize("./index"), "./index.html"); + assert.strictEqual(normalize("./index.html"), "./index.html"); + assert.strictEqual(normalize("/index"), "/index.html"); + assert.strictEqual(normalize("/index.html"), "/index.html"); + assert.strictEqual(normalize("index"), "index.html"); + assert.strictEqual(normalize("index.html"), "index.html"); + }); + it("converts links to directories", () => { + assert.strictEqual(normalize(""), ""); + assert.strictEqual(normalize("/"), "/index.html"); + assert.strictEqual(normalize("./"), "./index.html"); + assert.strictEqual(normalize("../"), "../index.html"); + assert.strictEqual(normalize("foo/"), "foo/index.html"); + assert.strictEqual(normalize("./foo/"), "./foo/index.html"); + assert.strictEqual(normalize("../foo/"), "../foo/index.html"); + assert.strictEqual(normalize("../sub/"), "../sub/index.html"); + }); +}); + describe("mergeToc(spec, toc)", () => { const root = "test/input/build/config"; it("merges page- and project-level toc config", async () => {