Skip to content

Commit

Permalink
preserveIndex, preserveExtension (#1784)
Browse files Browse the repository at this point in the history
* preserveIndex, preserveExtension

* add unit tests

---------

Co-authored-by: Philippe Rivière <fil@rezo.net>
  • Loading branch information
mbostock and Fil authored Nov 1, 2024
1 parent b66e152 commit 8c4813a
Show file tree
Hide file tree
Showing 7 changed files with 183 additions and 16 deletions.
8 changes: 6 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,13 @@ footer: ({path}) => `<a href="https://github.com/example/test/blob/main/src${pat

The base path when serving the site. Currently this only affects the custom 404 page, if any.

## cleanUrls <a href="https://github.com/observablehq/framework/releases/tag/v1.3.0" class="observablehq-version-badge" data-version="^1.3.0" title="Added in 1.3.0"></a>
## preserveIndex <a href="https://github.com/observablehq/framework/pulls/1784" class="observablehq-version-badge" data-version="prerelease" title="Added in #1784"></a>

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 <a href="https://github.com/observablehq/framework/pulls/1784" class="observablehq-version-badge" data-version="prerelease" title="Added in #1784"></a>

Whether page links should preserve the `.html` extension. Defaults to false. If true, a link to `/foo` will be formatted as `/foo.html`.

## toc

Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ The <code>build</code> command generates the `dist` directory; you can then copy

<pre data-copy>npx http-server dist</pre>

<div class="tip">By default, Framework generates “clean” URLs by dropping the `.html` extension from page links. Not all webhosts support this; some need the <a href="./config#clean-urls"><b>cleanUrls</b> config option</a> set to false.</div>
<div class="tip">By default, Framework generates “clean” URLs by dropping the `.html` extension from page links. Not all webhosts support this; some need the <a href="./config#preserve-extension"><b>preserveExtension</b> config option</a> set to true.</div>

<div class="tip">When deploying to GitHub Pages without using GitHub’s related actions (<a href="https://github.com/actions/configure-pages">configure-pages</a>,
<a href="https://github.com/actions/deploy-pages">deploy-pages</a>, and
Expand Down
25 changes: 18 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ export interface ConfigSpec {
typographer?: unknown;
quotes?: unknown;
cleanUrls?: unknown;
preserveIndex?: unknown;
preserveExtension?: unknown;
markdownIt?: unknown;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
};
}
Expand Down
4 changes: 2 additions & 2 deletions src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
3 changes: 2 additions & 1 deletion templates/default/observablehq.config.js.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
3 changes: 2 additions & 1 deletion templates/empty/observablehq.config.js.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
154 changes: 152 additions & 2 deletions test/config-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 () => {
Expand Down

0 comments on commit 8c4813a

Please sign in to comment.