Skip to content

Commit 8c4813a

Browse files
mbostockFil
andauthored
preserveIndex, preserveExtension (#1784)
* preserveIndex, preserveExtension * add unit tests --------- Co-authored-by: Philippe Rivière <[email protected]>
1 parent b66e152 commit 8c4813a

File tree

7 files changed

+183
-16
lines changed

7 files changed

+183
-16
lines changed

docs/config.md

+6-2
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,13 @@ footer: ({path}) => `<a href="https://github.com/example/test/blob/main/src${pat
197197

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

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

202-
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/`.
202+
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.
203+
204+
## preserveExtension <a href="https://github.com/observablehq/framework/pulls/1784" class="observablehq-version-badge" data-version="prerelease" title="Added in #1784"></a>
205+
206+
Whether page links should preserve the `.html` extension. Defaults to false. If true, a link to `/foo` will be formatted as `/foo.html`.
203207

204208
## toc
205209

docs/getting-started.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ The <code>build</code> command generates the `dist` directory; you can then copy
535535

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

538-
<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>
538+
<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>
539539

540540
<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>,
541541
<a href="https://github.com/actions/deploy-pages">deploy-pages</a>, and

src/config.ts

+18-7
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ export interface ConfigSpec {
124124
typographer?: unknown;
125125
quotes?: unknown;
126126
cleanUrls?: unknown;
127+
preserveIndex?: unknown;
128+
preserveExtension?: unknown;
127129
markdownIt?: unknown;
128130
}
129131

@@ -259,7 +261,7 @@ export function normalizeConfig(spec: ConfigSpec = {}, defaultRoot?: string, wat
259261
const footer = pageFragment(spec.footer === undefined ? defaultFooter() : spec.footer);
260262
const search = spec.search == null || spec.search === false ? null : normalizeSearch(spec.search as any);
261263
const interpreters = normalizeInterpreters(spec.interpreters as any);
262-
const normalizePath = getPathNormalizer(spec.cleanUrls);
264+
const normalizePath = getPathNormalizer(spec);
263265

264266
// If this path ends with a slash, then add an implicit /index to the
265267
// end of the path. Otherwise, remove the .html extension (we use clean
@@ -324,13 +326,22 @@ function normalizeDynamicPaths(spec: unknown): Config["paths"] {
324326
return async function* () { yield* paths; }; // prettier-ignore
325327
}
326328

327-
function getPathNormalizer(spec: unknown = true): (path: string) => string {
328-
const cleanUrls = Boolean(spec);
329+
function normalizeCleanUrls(spec: unknown): boolean {
330+
console.warn(`${yellow("Warning:")} the ${bold("cleanUrls")} option is deprecated; use ${bold("preserveIndex")} and ${bold("preserveExtension")} instead.`); // prettier-ignore
331+
return !spec;
332+
}
333+
334+
function getPathNormalizer(spec: ConfigSpec): (path: string) => string {
335+
const preserveIndex = spec.preserveIndex !== undefined ? Boolean(spec.preserveIndex) : false;
336+
const preserveExtension = spec.preserveExtension !== undefined ? Boolean(spec.preserveExtension) : spec.cleanUrls !== undefined ? normalizeCleanUrls(spec.cleanUrls) : false; // prettier-ignore
329337
return (path) => {
330-
if (path && !path.endsWith("/") && !extname(path)) path += ".html";
331-
if (path === "index.html") path = ".";
332-
else if (path.endsWith("/index.html")) path = path.slice(0, -"index.html".length);
333-
else if (cleanUrls) path = path.replace(/\.html$/, "");
338+
const ext = extname(path);
339+
if (path.endsWith(".")) path += "/";
340+
if (ext === ".html") path = path.slice(0, -".html".length);
341+
if (path.endsWith("/index")) path = path.slice(0, -"index".length);
342+
if (preserveIndex && path.endsWith("/")) path += "index";
343+
if (!preserveIndex && path === "index") path = ".";
344+
if (preserveExtension && path && !path.endsWith(".") && !path.endsWith("/") && !extname(path)) path += ".html";
334345
return path;
335346
};
336347
}

src/preview.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,8 @@ export class PreviewServer {
176176
} else {
177177
if ((pathname = normalize(pathname)).startsWith("..")) throw new Error("Invalid path: " + pathname);
178178

179-
// Normalize the pathname (e.g., adding ".html" if cleanUrls is false,
180-
// dropping ".html" if cleanUrls is true) and redirect if necessary.
179+
// Normalize the pathname (e.g., adding ".html" or removing ".html"
180+
// based on preserveExtension) and redirect if necessary.
181181
const normalizedPathname = encodeURI(config.normalizePath(pathname));
182182
if (url.pathname !== normalizedPathname) {
183183
res.writeHead(302, {Location: normalizedPathname + url.search});

templates/default/observablehq.config.js.tmpl

+2-1
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@ export default {
3333
// search: true, // activate search
3434
// linkify: true, // convert URLs in Markdown to links
3535
// typographer: false, // smart quotes and other typographic improvements
36-
// cleanUrls: true, // drop .html from URLs
36+
// preserveExtension: false, // drop .html from URLs
37+
// preserveIndex: false, // drop /index from URLs
3738
};

templates/empty/observablehq.config.js.tmpl

+2-1
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@ export default {
3333
// search: true, // activate search
3434
// linkify: true, // convert URLs in Markdown to links
3535
// typographer: false, // smart quotes and other typographic improvements
36-
// cleanUrls: true, // drop .html from URLs
36+
// preserveExtension: false, // drop .html from URLs
37+
// preserveIndex: false, // drop /index from URLs
3738
};

test/config-test.ts

+152-2
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ describe("normalizeConfig(spec, root)", () => {
187187
});
188188
});
189189

190-
describe("normalizePath(path) with {cleanUrls: false}", () => {
190+
describe("normalizePath(path) with {cleanUrls: false} (deprecated)", () => {
191191
const root = "test/input";
192192
const normalize = config({cleanUrls: false}, root).normalizePath;
193193
it("appends .html to extension-less links", () => {
@@ -234,7 +234,7 @@ describe("normalizePath(path) with {cleanUrls: false}", () => {
234234
});
235235
});
236236

237-
describe("normalizePath(path) with {cleanUrls: true}", () => {
237+
describe("normalizePath(path) with {cleanUrls: true} (deprecated)", () => {
238238
const root = "test/input";
239239
const normalize = config({cleanUrls: true}, root).normalizePath;
240240
it("does not append .html to extension-less links", () => {
@@ -283,6 +283,156 @@ describe("normalizePath(path) with {cleanUrls: true}", () => {
283283
});
284284
});
285285

286+
describe("normalizePath(path) with {preserveExtension: true}", () => {
287+
const root = "test/input";
288+
const normalize = config({preserveExtension: true}, root).normalizePath;
289+
it("appends .html to extension-less links", () => {
290+
assert.strictEqual(normalize("foo"), "foo.html");
291+
});
292+
it("does not append .html to extensioned links", () => {
293+
assert.strictEqual(normalize("foo.png"), "foo.png");
294+
assert.strictEqual(normalize("foo.html"), "foo.html");
295+
assert.strictEqual(normalize("foo.md"), "foo.md");
296+
});
297+
it("preserves absolute paths", () => {
298+
assert.strictEqual(normalize("/foo"), "/foo.html");
299+
assert.strictEqual(normalize("/foo.html"), "/foo.html");
300+
assert.strictEqual(normalize("/foo.png"), "/foo.png");
301+
});
302+
it("converts index links to directories", () => {
303+
assert.strictEqual(normalize("foo/index"), "foo/");
304+
assert.strictEqual(normalize("foo/index.html"), "foo/");
305+
assert.strictEqual(normalize("../index"), "../");
306+
assert.strictEqual(normalize("../index.html"), "../");
307+
assert.strictEqual(normalize("./index"), "./");
308+
assert.strictEqual(normalize("./index.html"), "./");
309+
assert.strictEqual(normalize("/index"), "/");
310+
assert.strictEqual(normalize("/index.html"), "/");
311+
assert.strictEqual(normalize("index"), ".");
312+
assert.strictEqual(normalize("index.html"), ".");
313+
});
314+
it("preserves links to directories", () => {
315+
assert.strictEqual(normalize(""), "");
316+
assert.strictEqual(normalize("/"), "/");
317+
assert.strictEqual(normalize("./"), "./");
318+
assert.strictEqual(normalize("../"), "../");
319+
assert.strictEqual(normalize("foo/"), "foo/");
320+
assert.strictEqual(normalize("./foo/"), "./foo/");
321+
assert.strictEqual(normalize("../foo/"), "../foo/");
322+
assert.strictEqual(normalize("../sub/"), "../sub/");
323+
});
324+
it("preserves a relative path", () => {
325+
assert.strictEqual(normalize("foo"), "foo.html");
326+
assert.strictEqual(normalize("./foo"), "./foo.html");
327+
assert.strictEqual(normalize("../foo"), "../foo.html");
328+
assert.strictEqual(normalize("./foo.png"), "./foo.png");
329+
assert.strictEqual(normalize("../foo.png"), "../foo.png");
330+
});
331+
});
332+
333+
describe("normalizePath(path) with {preserveExtension: false}", () => {
334+
const root = "test/input";
335+
const normalize = config({preserveExtension: false}, root).normalizePath;
336+
it("does not append .html to extension-less links", () => {
337+
assert.strictEqual(normalize("foo"), "foo");
338+
});
339+
it("does not append .html to extensioned links", () => {
340+
assert.strictEqual(normalize("foo.png"), "foo.png");
341+
assert.strictEqual(normalize("foo.md"), "foo.md");
342+
});
343+
it("removes .html from extensioned links", () => {
344+
assert.strictEqual(normalize("foo.html"), "foo");
345+
});
346+
it("preserves absolute paths", () => {
347+
assert.strictEqual(normalize("/foo"), "/foo");
348+
assert.strictEqual(normalize("/foo.html"), "/foo");
349+
assert.strictEqual(normalize("/foo.png"), "/foo.png");
350+
});
351+
it("converts index links to directories", () => {
352+
assert.strictEqual(normalize("foo/index"), "foo/");
353+
assert.strictEqual(normalize("foo/index.html"), "foo/");
354+
assert.strictEqual(normalize("../index"), "../");
355+
assert.strictEqual(normalize("../index.html"), "../");
356+
assert.strictEqual(normalize("./index"), "./");
357+
assert.strictEqual(normalize("./index.html"), "./");
358+
assert.strictEqual(normalize("/index"), "/");
359+
assert.strictEqual(normalize("/index.html"), "/");
360+
assert.strictEqual(normalize("index"), ".");
361+
assert.strictEqual(normalize("index.html"), ".");
362+
});
363+
it("preserves links to directories", () => {
364+
assert.strictEqual(normalize(""), "");
365+
assert.strictEqual(normalize("/"), "/");
366+
assert.strictEqual(normalize("./"), "./");
367+
assert.strictEqual(normalize("../"), "../");
368+
assert.strictEqual(normalize("foo/"), "foo/");
369+
assert.strictEqual(normalize("./foo/"), "./foo/");
370+
assert.strictEqual(normalize("../foo/"), "../foo/");
371+
assert.strictEqual(normalize("../sub/"), "../sub/");
372+
});
373+
it("preserves a relative path", () => {
374+
assert.strictEqual(normalize("foo"), "foo");
375+
assert.strictEqual(normalize("./foo"), "./foo");
376+
assert.strictEqual(normalize("../foo"), "../foo");
377+
assert.strictEqual(normalize("./foo.png"), "./foo.png");
378+
assert.strictEqual(normalize("../foo.png"), "../foo.png");
379+
});
380+
});
381+
382+
describe("normalizePath(path) with {preserveIndex: true}", () => {
383+
const root = "test/input";
384+
const normalize = config({preserveIndex: true}, root).normalizePath;
385+
it("preserves index links", () => {
386+
assert.strictEqual(normalize("foo/index"), "foo/index");
387+
assert.strictEqual(normalize("foo/index.html"), "foo/index");
388+
assert.strictEqual(normalize("../index"), "../index");
389+
assert.strictEqual(normalize("../index.html"), "../index");
390+
assert.strictEqual(normalize("./index"), "./index");
391+
assert.strictEqual(normalize("./index.html"), "./index");
392+
assert.strictEqual(normalize("/index"), "/index");
393+
assert.strictEqual(normalize("/index.html"), "/index");
394+
assert.strictEqual(normalize("index"), "index");
395+
assert.strictEqual(normalize("index.html"), "index");
396+
});
397+
it("converts links to directories", () => {
398+
assert.strictEqual(normalize(""), "");
399+
assert.strictEqual(normalize("/"), "/index");
400+
assert.strictEqual(normalize("./"), "./index");
401+
assert.strictEqual(normalize("../"), "../index");
402+
assert.strictEqual(normalize("foo/"), "foo/index");
403+
assert.strictEqual(normalize("./foo/"), "./foo/index");
404+
assert.strictEqual(normalize("../foo/"), "../foo/index");
405+
assert.strictEqual(normalize("../sub/"), "../sub/index");
406+
});
407+
});
408+
409+
describe("normalizePath(path) with {preserveIndex: true, preserveExtension: true}", () => {
410+
const root = "test/input";
411+
const normalize = config({preserveIndex: true, preserveExtension: true}, root).normalizePath;
412+
it("preserves index links", () => {
413+
assert.strictEqual(normalize("foo/index"), "foo/index.html");
414+
assert.strictEqual(normalize("foo/index.html"), "foo/index.html");
415+
assert.strictEqual(normalize("../index"), "../index.html");
416+
assert.strictEqual(normalize("../index.html"), "../index.html");
417+
assert.strictEqual(normalize("./index"), "./index.html");
418+
assert.strictEqual(normalize("./index.html"), "./index.html");
419+
assert.strictEqual(normalize("/index"), "/index.html");
420+
assert.strictEqual(normalize("/index.html"), "/index.html");
421+
assert.strictEqual(normalize("index"), "index.html");
422+
assert.strictEqual(normalize("index.html"), "index.html");
423+
});
424+
it("converts links to directories", () => {
425+
assert.strictEqual(normalize(""), "");
426+
assert.strictEqual(normalize("/"), "/index.html");
427+
assert.strictEqual(normalize("./"), "./index.html");
428+
assert.strictEqual(normalize("../"), "../index.html");
429+
assert.strictEqual(normalize("foo/"), "foo/index.html");
430+
assert.strictEqual(normalize("./foo/"), "./foo/index.html");
431+
assert.strictEqual(normalize("../foo/"), "../foo/index.html");
432+
assert.strictEqual(normalize("../sub/"), "../sub/index.html");
433+
});
434+
});
435+
286436
describe("mergeToc(spec, toc)", () => {
287437
const root = "test/input/build/config";
288438
it("merges page- and project-level toc config", async () => {

0 commit comments

Comments
 (0)