From 234e81be5516aa68c2dcb00d4f3a02e0dfa04b9d Mon Sep 17 00:00:00 2001 From: techfg Date: Tue, 9 Apr 2024 01:11:30 -0700 Subject: [PATCH] feat: add trailingSlash support (issue #11) --- .../with-trailing-slash.md | 3 + .../without-trailing-slash.md | 3 + src/index.d.ts | 30 +++++++ src/index.mjs | 35 +++++--- src/index.test.mjs | 68 +++++++++++++++ src/utils.d.ts | 3 + src/utils.mjs | 30 ++++++- src/utils.test.mjs | 83 ++++++++++++++++++- 8 files changed, 240 insertions(+), 15 deletions(-) create mode 100644 src/fixtures/dir-test-custom-slug/with-trailing-slash.md create mode 100644 src/fixtures/dir-test-custom-slug/without-trailing-slash.md diff --git a/src/fixtures/dir-test-custom-slug/with-trailing-slash.md b/src/fixtures/dir-test-custom-slug/with-trailing-slash.md new file mode 100644 index 0000000..4eee99e --- /dev/null +++ b/src/fixtures/dir-test-custom-slug/with-trailing-slash.md @@ -0,0 +1,3 @@ +--- +slug: dir-test-custom-slug/slug-with-trailing-slash/ +--- \ No newline at end of file diff --git a/src/fixtures/dir-test-custom-slug/without-trailing-slash.md b/src/fixtures/dir-test-custom-slug/without-trailing-slash.md new file mode 100644 index 0000000..663f96e --- /dev/null +++ b/src/fixtures/dir-test-custom-slug/without-trailing-slash.md @@ -0,0 +1,3 @@ +--- +slug: dir-test-custom-slug/slug-without-trailing-slash +--- \ No newline at end of file diff --git a/src/index.d.ts b/src/index.d.ts index 7597a1b..3df82a2 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,4 +1,34 @@ +export type TrailingSlash = 'always' | 'never' | 'ignore'; + export interface Options { contentPath?: string; // where you store your content relative to the root directory basePath?: string; // https://docs.astro.build/en/reference/configuration-reference/#base + /** + * @name trailingSlash + * @type {TrailingSlash} + * @default `'ignore'` + * @description + * + * Allows you to control the behavior for how trailing slashes should be handled on transformed urls: + * - `'always'` - Ensure urls always end with a trailing slash regardless of input + * - `'never'` - Ensure urls never end with a trailing slash regardless of input + * - `'ignore'` - Do not modify the url, trailing slash behavior will be determined by the file url itself or a custom slug if present. + * + * When set to `'ignore'` (the default), the following will occur: + * - If there is not a custom slug on the target file, the markdown link itself will determine if there is a trailing slash. + * - `[Example](./my-doc.md/))` will result in a trailing slash + * - `[Example](./my-doc.md))` will not result in a trailing slash + * - If there is a custom slug on the target file, the custom slug determines if there is a trailing slash. + * - `slug: my-doc/` will result in a trailing slash + * - `slug: my-doc` will not result in a trailing slash + * @example + * ```js + * { + * // Use `always` mode + * trailingSlash: `always` + * } + * ``` + * @see {@link https://docs.astro.build/en/reference/configuration-reference/#trailingslash|Astro} + */ + trailingSlash?: TrailingSlash } diff --git a/src/index.mjs b/src/index.mjs index 8a7f9be..2b64d48 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -10,6 +10,7 @@ import { normaliseAstroOutputPath, generateSlug, resolveSlug, + applyTrailingSlash } from "./utils.mjs"; // This package makes a lot of assumptions based on it being used with Astro @@ -19,6 +20,9 @@ const debug = debugFn("astro-rehype-relative-markdown-links"); // This is very specific to Astro const defaultContentPath = ["src", "content"].join(path.sep); +/** @type {import("./index").TrailingSlash} */ +const defaultTrailingSlash = 'ignore'; + /** @param {import('./index').Options} options */ function astroRehypeRelativeMarkdownLinks(options = {}) { return (tree, file) => { @@ -61,12 +65,16 @@ function astroRehypeRelativeMarkdownLinks(options = {}) { const pathSegments = withoutFileExt.split(path.posix.sep); const generatedSlug = generateSlug(pathSegments); const resolvedSlug = resolveSlug(generatedSlug, frontmatterSlug); + const trailingSlashMode = options.trailingSlash || defaultTrailingSlash; - let webPathFinal = path.posix.sep + + const resolvedUrl = path.posix.sep + [ collectionName, resolvedSlug, ].join(path.posix.sep); + + // slug of empty string ('') is a special case in Astro for root page (e.g., index.md) of a collection + let webPathFinal = applyTrailingSlash((frontmatterSlug === '' ? '/' : frontmatterSlug) || url, resolvedUrl, trailingSlashMode); if (queryStringAndFragment) { webPathFinal += queryStringAndFragment; @@ -76,16 +84,21 @@ function astroRehypeRelativeMarkdownLinks(options = {}) { // Debugging debug("--------------------------------"); - debug("md/mdx AST Current File : %s", currentFile); - debug("md/mdx AST Current File Dir : %s", currentFileDirectory); - debug("md/mdx AST href full : %s", nodeHref); - debug("md/mdx AST href path : %s", url); - debug("md/mdx AST href qs and/or hash : %s", queryStringAndFragment); - debug("File relative to current md/mdx : %s", relativeFile); - debug("File relative custom slug : %s", frontmatterSlug); - debug("File relative generated slug : %s", generatedSlug); - debug("File relative resolved slug : %s", resolvedSlug); - debug("Final URL path : %s", webPathFinal); + debug("ContentDir : %s", contentDir); + debug("TrailingSlashMode : %s", trailingSlashMode); + debug("md/mdx AST Current File : %s", currentFile); + debug("md/mdx AST Current File Dir : %s", currentFileDirectory); + debug("md/mdx AST href full : %s", nodeHref); + debug("md/mdx AST href path : %s", url); + debug("md/mdx AST href qs and/or hash : %s", queryStringAndFragment); + debug("File relative to current md/mdx : %s", relativeFile); + debug("File relative to content path : %s", relativeToContentPath); + debug("Collection Name : %s", collectionName); + debug("File relative to collection path : %s", relativeToCollectionPath); + debug("File relative custom slug : %s", frontmatterSlug); + debug("File relative generated slug : %s", generatedSlug); + debug("File relative resolved slug : %s", resolvedSlug); + debug("Final URL path : %s", webPathFinal); node.properties.href = webPathFinal; }); diff --git a/src/index.test.mjs b/src/index.test.mjs index 28a1632..81b17ef 100644 --- a/src/index.test.mjs +++ b/src/index.test.mjs @@ -228,4 +228,72 @@ test("astroRehypeRelativeMarkdownLinks", async (t) => { assert.equal(actual, expected); }, ); + + await t.test("should contain trailing slash when option not specified and file contains", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + await t.test("should not contain trailing slash when option not specified and file does not contain", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + await t.test("should contain trailing slash when option not specified and file does not contain and custom slug contains", async () => { + const input = + 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + await t.test("should not contain trailing slash when option not specified and file contains and custom slug does not contain", async () => { + const input = + 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + await t.test("should not contain trailing slash when option not specified and file contains and custom slug contains", async () => { + const input = + 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); }); diff --git a/src/utils.d.ts b/src/utils.d.ts index 3f0bb74..8f3dbd8 100644 --- a/src/utils.d.ts +++ b/src/utils.d.ts @@ -1,3 +1,5 @@ +import { type TrailingSlash } from "."; + export type SplitPathFromQueryAndFragmentFn = ( path: string, ) => [string, string | null]; @@ -6,3 +8,4 @@ export type IsCurrentDirectoryFn = (path: string) => boolean; export type IsValidRelativeLinkFn = (link: string) => boolean; export type GenerateSlug = (pathSegments: string[]) => string; export type ResolveSlug = (generatedSlug: string, frontmatterSlug?: unknown) => string; +export type ApplyTrailingSlash = (originalUrl: string, resolvedUrl: string, trailingSlash: TrailingSlash) => string; diff --git a/src/utils.mjs b/src/utils.mjs index e428dc1..5b3e19c 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -1,6 +1,6 @@ import path from "path"; import { slug as githubSlug } from "github-slugger"; -import { z } from 'zod'; +import { z } from "zod"; const pathSeparator = path.sep; const validMarkdownExtensions = [".md", ".mdx"]; @@ -100,9 +100,33 @@ export const generateSlug = (pathSegments) => { .map((segment) => githubSlug(segment)) .join("/") .replace(/\/index$/, ''); -} +}; /** @type {import('./utils').ResolveSlug} */ export const resolveSlug = (generatedSlug, frontmatterSlug) => { return z.string().default(generatedSlug).parse(frontmatterSlug); -} \ No newline at end of file +}; + +/** @type {import('./utils').ApplyTrailingSlash} */ +export const applyTrailingSlash = (origUrl, resolvedUrl, trailingSlash = "ignore") => { + const hasTrailingSlash = resolvedUrl.endsWith(`/`); + + if (trailingSlash === "always") { + return hasTrailingSlash ? resolvedUrl : resolvedUrl + '/'; + } + + if (trailingSlash === "never") { + return hasTrailingSlash ? resolvedUrl.slice(0, -1) : resolvedUrl; + } + + const hadTrailingSlash = origUrl.endsWith(`/`); + if (hadTrailingSlash && !hasTrailingSlash) { + return resolvedUrl + '/'; + } + + if (!hadTrailingSlash && hasTrailingSlash) { + return resolvedUrl.slice(0, -1); + } + + return resolvedUrl; +}; diff --git a/src/utils.test.mjs b/src/utils.test.mjs index c42e937..aa36765 100644 --- a/src/utils.test.mjs +++ b/src/utils.test.mjs @@ -7,7 +7,8 @@ import { replaceExt, splitPathFromQueryAndFragment, generateSlug, - resolveSlug + resolveSlug, + applyTrailingSlash } from "./utils.mjs"; describe("replaceExt", () => { @@ -235,3 +236,83 @@ describe("normaliseAstroOutputPath", () => { }); }); }); + +describe("applyTrailingSlash", () => { + describe("always", () => { + test("original does not contain resolved does not contain", () => { + const actual = applyTrailingSlash('./foo.md', '/foo', 'always') + + assert.equal(actual, "/foo/"); + }); + + test("original contains resolved contains", () => { + const actual = applyTrailingSlash('./foo.md/', '/foo/', 'always') + + assert.equal(actual, "/foo/"); + }); + + test("original contains resolved does not contain", () => { + const actual = applyTrailingSlash('./foo.md/', '/foo', 'always') + + assert.equal(actual, "/foo/"); + }); + + test("original does not contain resolved does contain", () => { + const actual = applyTrailingSlash('./foo.md', '/foo/', 'always') + + assert.equal(actual, "/foo/"); + }); + }); + + describe("never", () => { + test("original does not contain resolved does not contain", () => { + const actual = applyTrailingSlash('./foo.md', '/foo', 'never') + + assert.equal(actual, "/foo"); + }); + + test("original contains resolved contains", () => { + const actual = applyTrailingSlash('./foo.md/', '/foo/', 'never') + + assert.equal(actual, "/foo"); + }); + + test("original contains resolved does not contain", () => { + const actual = applyTrailingSlash('./foo.md/', '/foo', 'never') + + assert.equal(actual, "/foo"); + }); + + test("original does not contain resolved does contain", () => { + const actual = applyTrailingSlash('./foo.md', '/foo/', 'never') + + assert.equal(actual, "/foo"); + }); + }); + + describe("ignore", () => { + test("original does not contain resolved does not contain", () => { + const actual = applyTrailingSlash('./foo.md', '/foo', 'ignore') + + assert.equal(actual, "/foo"); + }); + + test("original contains resolved contains", () => { + const actual = applyTrailingSlash('./foo.md/', '/foo/', 'ignore') + + assert.equal(actual, "/foo/"); + }); + + test("original contains resolved does not contain", () => { + const actual = applyTrailingSlash('./foo.md/', '/foo', 'ignore') + + assert.equal(actual, "/foo/"); + }); + + test("original does not contain resolved does contain", () => { + const actual = applyTrailingSlash('./foo.md', '/foo/', 'ignore') + + assert.equal(actual, "/foo"); + }); + }); +});