From cda52f59380349d048827fb2cb060ad917d8f6f7 Mon Sep 17 00:00:00 2001 From: techfg Date: Sun, 21 Apr 2024 01:29:42 -0700 Subject: [PATCH 01/23] chore: move test fixtures to content directory --- .../_dir-test/content}/collection-dir-1/test.md | 0 src/fixtures/{ => content}/dir-test-custom-slug-2/index.md | 0 src/fixtures/{ => content}/dir-test-custom-slug/index.md | 0 .../{ => content}/dir-test-custom-slug/with-trailing-slash.md | 0 .../{ => content}/dir-test-custom-slug/without-trailing-slash.md | 0 src/fixtures/{ => content}/dir-test-with-extension.md/test-1.md | 0 src/fixtures/{ => content/docs}/_test.md | 0 src/fixtures/{ => content/docs}/dir-exists.md/dir-exists-md-1.md | 0 .../{ => content/docs}/dir-exists.mdx/dir-exists-mdx-1.md | 0 .../{ => content/docs}/dir-exists.txt/dir-exists-txt-1.md | 0 .../docs}/dir-test-custom-slug.md/test-custom-slug-in-dot-dir.md | 0 src/fixtures/{ => content/docs}/dir-test/dir-test-child.md | 0 src/fixtures/{ => content/docs}/dir-test/index.md | 0 src/fixtures/{ => content/docs}/index.md | 0 src/fixtures/{ => content/docs}/test (non-alpha).md | 0 src/fixtures/{ => content/docs}/test with SPACE.md | 0 src/fixtures/{ => content/docs}/test-custom-slug-ext.md | 0 src/fixtures/{ => content/docs}/test-custom-slug.md | 0 src/fixtures/{ => content/docs}/test.md | 0 src/fixtures/{ => content/docs}/test.mdx | 0 src/fixtures/{ => content/docs}/test.txt | 0 21 files changed, 0 insertions(+), 0 deletions(-) rename src/fixtures/{_dir-test/content-dir => content/_dir-test/content}/collection-dir-1/test.md (100%) rename src/fixtures/{ => content}/dir-test-custom-slug-2/index.md (100%) rename src/fixtures/{ => content}/dir-test-custom-slug/index.md (100%) rename src/fixtures/{ => content}/dir-test-custom-slug/with-trailing-slash.md (100%) rename src/fixtures/{ => content}/dir-test-custom-slug/without-trailing-slash.md (100%) rename src/fixtures/{ => content}/dir-test-with-extension.md/test-1.md (100%) rename src/fixtures/{ => content/docs}/_test.md (100%) rename src/fixtures/{ => content/docs}/dir-exists.md/dir-exists-md-1.md (100%) rename src/fixtures/{ => content/docs}/dir-exists.mdx/dir-exists-mdx-1.md (100%) rename src/fixtures/{ => content/docs}/dir-exists.txt/dir-exists-txt-1.md (100%) rename src/fixtures/{ => content/docs}/dir-test-custom-slug.md/test-custom-slug-in-dot-dir.md (100%) rename src/fixtures/{ => content/docs}/dir-test/dir-test-child.md (100%) rename src/fixtures/{ => content/docs}/dir-test/index.md (100%) rename src/fixtures/{ => content/docs}/index.md (100%) rename src/fixtures/{ => content/docs}/test (non-alpha).md (100%) rename src/fixtures/{ => content/docs}/test with SPACE.md (100%) rename src/fixtures/{ => content/docs}/test-custom-slug-ext.md (100%) rename src/fixtures/{ => content/docs}/test-custom-slug.md (100%) rename src/fixtures/{ => content/docs}/test.md (100%) rename src/fixtures/{ => content/docs}/test.mdx (100%) rename src/fixtures/{ => content/docs}/test.txt (100%) diff --git a/src/fixtures/_dir-test/content-dir/collection-dir-1/test.md b/src/fixtures/content/_dir-test/content/collection-dir-1/test.md similarity index 100% rename from src/fixtures/_dir-test/content-dir/collection-dir-1/test.md rename to src/fixtures/content/_dir-test/content/collection-dir-1/test.md diff --git a/src/fixtures/dir-test-custom-slug-2/index.md b/src/fixtures/content/dir-test-custom-slug-2/index.md similarity index 100% rename from src/fixtures/dir-test-custom-slug-2/index.md rename to src/fixtures/content/dir-test-custom-slug-2/index.md diff --git a/src/fixtures/dir-test-custom-slug/index.md b/src/fixtures/content/dir-test-custom-slug/index.md similarity index 100% rename from src/fixtures/dir-test-custom-slug/index.md rename to src/fixtures/content/dir-test-custom-slug/index.md diff --git a/src/fixtures/dir-test-custom-slug/with-trailing-slash.md b/src/fixtures/content/dir-test-custom-slug/with-trailing-slash.md similarity index 100% rename from src/fixtures/dir-test-custom-slug/with-trailing-slash.md rename to src/fixtures/content/dir-test-custom-slug/with-trailing-slash.md diff --git a/src/fixtures/dir-test-custom-slug/without-trailing-slash.md b/src/fixtures/content/dir-test-custom-slug/without-trailing-slash.md similarity index 100% rename from src/fixtures/dir-test-custom-slug/without-trailing-slash.md rename to src/fixtures/content/dir-test-custom-slug/without-trailing-slash.md diff --git a/src/fixtures/dir-test-with-extension.md/test-1.md b/src/fixtures/content/dir-test-with-extension.md/test-1.md similarity index 100% rename from src/fixtures/dir-test-with-extension.md/test-1.md rename to src/fixtures/content/dir-test-with-extension.md/test-1.md diff --git a/src/fixtures/_test.md b/src/fixtures/content/docs/_test.md similarity index 100% rename from src/fixtures/_test.md rename to src/fixtures/content/docs/_test.md diff --git a/src/fixtures/dir-exists.md/dir-exists-md-1.md b/src/fixtures/content/docs/dir-exists.md/dir-exists-md-1.md similarity index 100% rename from src/fixtures/dir-exists.md/dir-exists-md-1.md rename to src/fixtures/content/docs/dir-exists.md/dir-exists-md-1.md diff --git a/src/fixtures/dir-exists.mdx/dir-exists-mdx-1.md b/src/fixtures/content/docs/dir-exists.mdx/dir-exists-mdx-1.md similarity index 100% rename from src/fixtures/dir-exists.mdx/dir-exists-mdx-1.md rename to src/fixtures/content/docs/dir-exists.mdx/dir-exists-mdx-1.md diff --git a/src/fixtures/dir-exists.txt/dir-exists-txt-1.md b/src/fixtures/content/docs/dir-exists.txt/dir-exists-txt-1.md similarity index 100% rename from src/fixtures/dir-exists.txt/dir-exists-txt-1.md rename to src/fixtures/content/docs/dir-exists.txt/dir-exists-txt-1.md diff --git a/src/fixtures/dir-test-custom-slug.md/test-custom-slug-in-dot-dir.md b/src/fixtures/content/docs/dir-test-custom-slug.md/test-custom-slug-in-dot-dir.md similarity index 100% rename from src/fixtures/dir-test-custom-slug.md/test-custom-slug-in-dot-dir.md rename to src/fixtures/content/docs/dir-test-custom-slug.md/test-custom-slug-in-dot-dir.md diff --git a/src/fixtures/dir-test/dir-test-child.md b/src/fixtures/content/docs/dir-test/dir-test-child.md similarity index 100% rename from src/fixtures/dir-test/dir-test-child.md rename to src/fixtures/content/docs/dir-test/dir-test-child.md diff --git a/src/fixtures/dir-test/index.md b/src/fixtures/content/docs/dir-test/index.md similarity index 100% rename from src/fixtures/dir-test/index.md rename to src/fixtures/content/docs/dir-test/index.md diff --git a/src/fixtures/index.md b/src/fixtures/content/docs/index.md similarity index 100% rename from src/fixtures/index.md rename to src/fixtures/content/docs/index.md diff --git a/src/fixtures/test (non-alpha).md b/src/fixtures/content/docs/test (non-alpha).md similarity index 100% rename from src/fixtures/test (non-alpha).md rename to src/fixtures/content/docs/test (non-alpha).md diff --git a/src/fixtures/test with SPACE.md b/src/fixtures/content/docs/test with SPACE.md similarity index 100% rename from src/fixtures/test with SPACE.md rename to src/fixtures/content/docs/test with SPACE.md diff --git a/src/fixtures/test-custom-slug-ext.md b/src/fixtures/content/docs/test-custom-slug-ext.md similarity index 100% rename from src/fixtures/test-custom-slug-ext.md rename to src/fixtures/content/docs/test-custom-slug-ext.md diff --git a/src/fixtures/test-custom-slug.md b/src/fixtures/content/docs/test-custom-slug.md similarity index 100% rename from src/fixtures/test-custom-slug.md rename to src/fixtures/content/docs/test-custom-slug.md diff --git a/src/fixtures/test.md b/src/fixtures/content/docs/test.md similarity index 100% rename from src/fixtures/test.md rename to src/fixtures/content/docs/test.md diff --git a/src/fixtures/test.mdx b/src/fixtures/content/docs/test.mdx similarity index 100% rename from src/fixtures/test.mdx rename to src/fixtures/content/docs/test.mdx diff --git a/src/fixtures/test.txt b/src/fixtures/content/docs/test.txt similarity index 100% rename from src/fixtures/test.txt rename to src/fixtures/content/docs/test.txt From 206af5217e451d8b8e4772c899cf4fc38adb99a4 Mon Sep 17 00:00:00 2001 From: techfg Date: Sun, 21 Apr 2024 01:30:18 -0700 Subject: [PATCH 02/23] chore: update slugs relative to collection dir --- .../content/dir-test-custom-slug/with-trailing-slash.md | 2 +- .../content/dir-test-custom-slug/without-trailing-slash.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fixtures/content/dir-test-custom-slug/with-trailing-slash.md b/src/fixtures/content/dir-test-custom-slug/with-trailing-slash.md index 1cd69e2..6a63399 100644 --- a/src/fixtures/content/dir-test-custom-slug/with-trailing-slash.md +++ b/src/fixtures/content/dir-test-custom-slug/with-trailing-slash.md @@ -1,3 +1,3 @@ --- -slug: dir-test-custom-slug/slug-with-trailing-slash/ +slug: slug-with-trailing-slash/ --- diff --git a/src/fixtures/content/dir-test-custom-slug/without-trailing-slash.md b/src/fixtures/content/dir-test-custom-slug/without-trailing-slash.md index d30d775..c87b8c9 100644 --- a/src/fixtures/content/dir-test-custom-slug/without-trailing-slash.md +++ b/src/fixtures/content/dir-test-custom-slug/without-trailing-slash.md @@ -1,3 +1,3 @@ --- -slug: dir-test-custom-slug/slug-without-trailing-slash +slug: slug-without-trailing-slash --- From ec6a80bcfee5ee07f8cb208ff614e5002cbcba8e Mon Sep 17 00:00:00 2001 From: techfg Date: Sun, 21 Apr 2024 01:32:08 -0700 Subject: [PATCH 03/23] fix: type import --- src/utils.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.mjs b/src/utils.mjs index 9b565fb..ed44d0d 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -156,7 +156,7 @@ export const applyTrailingSlash = ( return resolvedUrl; }; -/** @type {import('./utils').ShouldProcessFile} */ +/** @type {import('./utils.d.ts').ShouldProcessFile} */ export function shouldProcessFile(npath) { // Astro excludes files that include underscore in any segment of the path under contentDIr // see https://github.com/withastro/astro/blob/0fec72b35cccf80b66a85664877ca9dcc94114aa/packages/astro/src/content/utils.ts#L253 From e3c9ac1b2a82e49d451b0698f8c8e967c680e615 Mon Sep 17 00:00:00 2001 From: techfg Date: Sun, 21 Apr 2024 01:35:09 -0700 Subject: [PATCH 04/23] fix: test name --- src/index.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.test.mjs b/src/index.test.mjs index 786c0bd..5c4f7cb 100644 --- a/src/index.test.mjs +++ b/src/index.test.mjs @@ -539,7 +539,7 @@ describe("astroRehypeRelativeMarkdownLinks", () => { assert.equal(actual, expected); }); - test("should not contain trailing slash when option not specified and file contains and custom slug contains", async () => { + test("should contain trailing slash when option not specified and file contains and custom slug contains", async () => { const input = 'foo'; const { value: actual } = await rehype() From d8e88f21bf43ce193567e219f274800d9bb02ff8 Mon Sep 17 00:00:00 2001 From: techfg Date: Sun, 21 Apr 2024 02:39:16 -0700 Subject: [PATCH 05/23] chore: update tests for removal of contentPath option --- src/index.test.mjs | 214 +++++++++++++++++++++---------------------- src/options.test.mjs | 18 ++-- src/utils.test.mjs | 22 ++--- 3 files changed, 127 insertions(+), 127 deletions(-) diff --git a/src/index.test.mjs b/src/index.test.mjs index 5c4f7cb..8920491 100644 --- a/src/index.test.mjs +++ b/src/index.test.mjs @@ -45,36 +45,36 @@ function testSetupRehype(options = {}) { describe("astroRehypeRelativeMarkdownLinks", () => { test("should transform file paths that exist", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should transform and contain index for root collection index.md file paths that exist", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should transform root collection index.md paths with empty string custom slug", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src/fixtures" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = @@ -85,10 +85,10 @@ describe("astroRehypeRelativeMarkdownLinks", () => { test("should transform root collection index.md paths with non-empty string custom slug", async () => { const input = - 'foo'; + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src/fixtures" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = @@ -98,116 +98,116 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should transform non-root collection index.md paths", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should transform encoded file paths that exist with capital letters", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should keep query and fragment for file paths that exist", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should not replace path if relative file does not exist", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should transform file paths that exist with non alphanumeric characters", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should transform with correct path when destination has custom slug", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should transform with correct path when destination has custom slug with file extension", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should transform with correct path when destination in subpath has custom slug", async () => { const input = - 'foo'; + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should not transform content collection path segment", async () => { const input = - 'foo'; + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src/fixtures" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = @@ -217,90 +217,90 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should not transform index.md file paths if file does not exist", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should not replace path if .md directory exists", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should not replace path if .mdx directory exists", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should not replace path if .md directory does not exist", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should not replace path if .mdx directory does not exist", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should not transform non-anchor elements", async () => { - const input = '
foo
'; + const input = '
foo
'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - '
foo
'; + '
foo
'; assert.equal(actual, expected); }); describe("absolute paths", () => { test("should not replace absolute path if file exists", async () => { - const absolutePath = path.resolve("./fixtures/test.md"); + const absolutePath = path.resolve("./fixtures/content/docs/test.md"); const input = `foo`; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = `foo`; @@ -309,11 +309,11 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should not replace absolute path if file does not exist", async () => { - const absolutePath = `${path.dirname(path.resolve("./fixtures/test.md"))}/does-not-exist.md`; + const absolutePath = `${path.dirname(path.resolve("./fixtures/content/docs/test.md"))}/does-not-exist.md`; const input = `foo`; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = `foo`; @@ -330,7 +330,7 @@ describe("astroRehypeRelativeMarkdownLinks", () => { validateOptions: validateOptionsMock, }, }); - const input = 'foo'; + const input = 'foo'; await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinksMock, options) @@ -347,34 +347,34 @@ describe("astroRehypeRelativeMarkdownLinks", () => { test("should validate options when options is empty object", async (context) => await runValidationTest(context, {})); test("should validate options when options contains properties", async (context) => - await runValidationTest(context, { contentPath: "src" })); + await runValidationTest(context, { srcDir: "src/fixtures" })); }); describe("config option - basePath", () => { test("should prefix base to output on file paths that exist", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { - contentPath: "src", + srcDir: "src/fixtures", basePath: "/testBase", }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); }); describe("config option - collectionPathMode:root", () => { - test("should transform and contain index for root index.md when content path same as collection path", async () => { - const input = 'foo'; + test("should transform and contain index for root index.md", async () => { + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { - contentPath: "src/fixtures", + srcDir: "src/fixtures", collectionPathMode: "root", }) .process(input); @@ -385,13 +385,13 @@ describe("astroRehypeRelativeMarkdownLinks", () => { assert.equal(actual, expected); }); - test("should transform root index.md with empty string custom slug when content path same as collection path", async () => { + test("should transform root index.md with empty string custom slug", async () => { const input = - 'foo'; + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { - contentPath: "src/fixtures/dir-test-custom-slug", + src: "src/fixtures", collectionPathMode: "root", }) .process(input); @@ -402,12 +402,12 @@ describe("astroRehypeRelativeMarkdownLinks", () => { assert.equal(actual, expected); }); - test("should transform root path when content path same as collection path", async () => { - const input = 'foo'; + test("should transform root path", async () => { + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { - contentPath: "src/fixtures", + srcDir: "src/fixtures", collectionPathMode: "root", }) .process(input); @@ -418,12 +418,12 @@ describe("astroRehypeRelativeMarkdownLinks", () => { assert.equal(actual, expected); }); - test("should transform root path custom slug when content path same as collection path", async () => { - const input = 'foo'; + test("should transform root path custom slug", async () => { + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { - contentPath: "src/fixtures", + srcDir: "src/fixtures", collectionPathMode: "root", }) .process(input); @@ -434,12 +434,12 @@ describe("astroRehypeRelativeMarkdownLinks", () => { assert.equal(actual, expected); }); - test("should transform subdir index.md when content path same as collection path", async () => { - const input = 'foo'; + test("should transform subdir index.md", async () => { + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { - contentPath: "src/fixtures", + srcDir: "src/fixtures", collectionPathMode: "root", }) .process(input); @@ -450,12 +450,12 @@ describe("astroRehypeRelativeMarkdownLinks", () => { assert.equal(actual, expected); }); - test("should transform subdir path when content path same as collection path", async () => { - const input = 'foo'; + test("should transform subdir path", async () => { + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { - contentPath: "src/fixtures", + srcDir: "src/fixtures", collectionPathMode: "root", }) .process(input); @@ -466,13 +466,13 @@ describe("astroRehypeRelativeMarkdownLinks", () => { assert.equal(actual, expected); }); - test("should transform subdir path custom slug when content path same as collection path", async () => { + test("should transform subdir path custom slug", async () => { const input = - 'foo'; + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { - contentPath: "src/fixtures", + srcDir: "src/fixtures", collectionPathMode: "root", }) .process(input); @@ -486,69 +486,69 @@ describe("astroRehypeRelativeMarkdownLinks", () => { describe("config option - trailingSlash", () => { test("should contain trailing slash when option not specified and file contains", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should not contain trailing slash when option not specified and file does not contain", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should contain trailing slash when option not specified and file does not contain and custom slug contains", async () => { const input = - 'foo'; + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should not contain trailing slash when option not specified and file contains and custom slug does not contain", async () => { const input = - 'foo'; + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should contain trailing slash when option not specified and file contains and custom slug contains", async () => { const input = - 'foo'; + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); @@ -556,39 +556,39 @@ describe("astroRehypeRelativeMarkdownLinks", () => { describe("excluded files", () => { test("should not transform markdown file that exists that begins with underscore", async () => { - const input = 'foo'; + const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should not transform markdown file that exists with underscore in a directory path segment", async () => { const input = - 'foo'; + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { contentPath: "src" }) + .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) .process(input); const expected = - 'foo'; + 'foo'; assert.equal(actual, expected); }); test("should transform markdown file that exists with underscore in a directory path above the content dir but not below it", async () => { const input = - 'foo'; + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { - contentPath: "src/fixtures/_dir-test/content-dir", + srcDir: "src/fixtures/content/_dir-test", }) .process(input); diff --git a/src/options.test.mjs b/src/options.test.mjs index d5717b2..1a9970f 100644 --- a/src/options.test.mjs +++ b/src/options.test.mjs @@ -5,7 +5,7 @@ import path from "path"; /** @type {import('./options.d.ts').Options} */ const defaultOptions = { - contentPath: ["src", "content"].join(path.sep), + srcDir: "./src", trailingSlash: "ignore", collectionPathMode: "subdirectory", }; @@ -54,7 +54,7 @@ describe("validateOptions", () => { }); test("should return specified property value with remaining defaults", async () => { - const options = { contentPath: "foo/bar" }; + const options = { srcDir: "foo/bar" }; expectsValidOptions(options, { ...defaultOptions, ...options, @@ -144,17 +144,17 @@ describe("validateOptions", () => { }); }); - describe("contentPath", () => { - test("should have expected contentPath default", () => { - expectsValidOption({}, "contentPath", defaultOptions.contentPath); + describe("srcDir", () => { + test("should have expected srcDir default", () => { + expectsValidOption({}, "srcDir", defaultOptions.srcDir); }); - test("should be contentPath value specified when string", () => { - expectsValidOption({ contentPath: "foobar" }, "contentPath", "foobar"); + test("should be srcDir value specified when string", () => { + expectsValidOption({ srcDir: "foobar" }, "srcDir", "foobar"); }); - test("should fail when contentPath not a string", () => { - expectsZodError({ contentPath: {} }, "invalid_type"); + test("should fail when srcDir not a string", () => { + expectsZodError({ srcDir: {} }, "invalid_type"); }); }); }); diff --git a/src/utils.test.mjs b/src/utils.test.mjs index 78aaf98..73addf3 100644 --- a/src/utils.test.mjs +++ b/src/utils.test.mjs @@ -240,37 +240,37 @@ describe("normaliseAstroOutputPath", () => { describe("isValidFile", () => { test("return true if relative path to .md file exists", () => { - const actual = isValidFile("./src/fixtures/test.md"); + const actual = isValidFile("./src/fixtures/content/docs/test.md"); assert.equal(actual, true); }); test("return true if relative path to .mdx file that exists", () => { - const actual = isValidFile("./src/fixtures/test.mdx"); + const actual = isValidFile("./src/fixtures/content/docs/test.mdx"); assert.equal(actual, true); }); test("return true if relative path to a file exists", () => { - const actual = isValidFile("./src/fixtures/test.txt"); + const actual = isValidFile("./src/fixtures/content/docs/test.txt"); assert.equal(actual, true); }); test("return false if relative path to .md file does not exist", () => { - const actual = isValidFile("./src/fixtures/does-not-exist.md"); + const actual = isValidFile("./src/fixtures/content/docs/does-not-exist.md"); assert.equal(actual, false); }); test("return false if relative path to .mdx file does not exist", () => { - const actual = isValidFile("./src/fixtures/does-not-exist.mdx"); + const actual = isValidFile("./src/fixtures/content/docs/does-not-exist.mdx"); assert.equal(actual, false); }); test("return false if relative path to a file does not exist", () => { - const actual = isValidFile("./src/fixtures/does-not-exist.txt"); + const actual = isValidFile("./src/fixtures/content/docs/does-not-exist.txt"); assert.equal(actual, false); }); @@ -294,31 +294,31 @@ describe("isValidFile", () => { }); test("return false if path is a directory ending in .md that exists", () => { - const actual = isValidFile("./src/fixtures/dir-exists.md"); + const actual = isValidFile("./src/fixtures/content/docs/dir-exists.md"); assert.equal(actual, false); }); test("return false if path is a directory ending in .md/ that exists", () => { - const actual = isValidFile("./src/fixtures/dir-exists.md/"); + const actual = isValidFile("./src/fixtures/content/docs/dir-exists.md/"); assert.equal(actual, false); }); test("return false if path is a directory ending in .mdx that exists", () => { - const actual = isValidFile("./src/fixtures/dir-exists.mdx"); + const actual = isValidFile("./src/fixtures/content/docs/dir-exists.mdx"); assert.equal(actual, false); }); test("return false if path is a directory ending in .mdx/ that exists", () => { - const actual = isValidFile("./src/fixtures/dir-exists.mdx/"); + const actual = isValidFile("./src/fixtures/content/docs/dir-exists.mdx/"); assert.equal(actual, false); }); test("return false if path is a directory that exists", () => { - const actual = isValidFile("./src/fixtures/dir-exists.txt"); + const actual = isValidFile("./src/fixtures/content/docs/dir-exists.txt"); assert.equal(actual, false); }); From fd42a8d9a9ca9ea88e52a5e52b5bdd32c38ad2d7 Mon Sep 17 00:00:00 2001 From: techfg Date: Sun, 21 Apr 2024 02:41:30 -0700 Subject: [PATCH 06/23] fix: replace contentPath option with srcDir option --- src/index.mjs | 12 +++++------- src/options.mjs | 35 +++++++++++++++++++++-------------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/index.mjs b/src/index.mjs index e102014..a068cb6 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -66,7 +66,7 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { const urlFileContent = fs.readFileSync(urlFilePath); const { data: frontmatter } = matter(urlFileContent); const frontmatterSlug = frontmatter.slug; - const contentDir = path.resolve(options.contentPath); + const contentDir = path.resolve(options.srcDir, "content"); const collectionPathMode = options.collectionPathMode; const trailingSlashMode = options.trailingSlash; @@ -112,12 +112,10 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { const collectionName = path .dirname(relativeToContentPath) .split(FILE_PATH_SEPARATOR)[0]; - const collectionPathSegment = - collectionPathMode === "root" ? PATH_SEGMENT_EMPTY : collectionName; // determine the path of the target file relative to the collection // since the slug for content collection pages is always relative to collection root const relativeToCollectionPath = path.relative( - collectionPathSegment, + collectionName, relativeToContentPath, ); // md/mdx extentions should not be in the final url @@ -135,9 +133,9 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { // directory name of the content collection maps 1:1 to the site page path serviing the content collection // page (see details above) const resolvedUrl = [ - collectionPathSegment === PATH_SEGMENT_EMPTY + collectionPathMode === "root" ? "" - : URL_PATH_SEPARATOR + collectionPathSegment, + : URL_PATH_SEPARATOR + collectionName, resolvedSlug, ].join(URL_PATH_SEPARATOR); @@ -159,6 +157,7 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { // Debugging debug("--------------------------------------"); debug("BasePath : %s", options.basePath); + debug("SrcDir : %s", options.srcDir) debug("ContentDir : %s", contentDir); debug("CollectionPathMode : %s", collectionPathMode); debug("TrailingSlashMode : %s", trailingSlashMode); @@ -173,7 +172,6 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { debug("URL file : %s", urlFilePath); debug("URL file relative to content path : %s", relativeToContentPath); debug("Collection Name : %s", collectionName); - debug("Collection Path Segment : %s", collectionPathSegment); debug( "URL file relative to collection path : %s", relativeToCollectionPath, diff --git a/src/options.mjs b/src/options.mjs index f265615..07434cb 100644 --- a/src/options.mjs +++ b/src/options.mjs @@ -1,32 +1,39 @@ import { z } from "zod"; -import path from "path"; export const OptionsSchema = z.object({ /** - * @name contentPath - * @default `src/content` + * @name srcDir + * @reference https://docs.astro.build/en/reference/configuration-reference/#srcdir + * @default `./src` * @description - * - * This defines where the content (i.e. md, mdx, etc. files) is stored. This should be a path relative to the root directory + * + * Set the directory that Astro will read your site from. + * + * The value can be either an absolute file system path or a path relative to the project root. + * @example + * ```js + * { + * srcDir: './www' + * } + * ``` */ - contentPath: z.string().default(["src", "content"].join(path.sep)), + srcDir: z.string().default("./src"), /** * @name collectionPathMode * @default `subdirectory` * @description * - * Where you store your collections: - * - `subdirectory` - Subdirectories under `contentPath` (ex: `src/content/docs/index.md` where `docs` is the content collection subdirectory of the contentPath `src/content`) - * - `root` - Directly inside `contentPath` (ex: `src/content/docs/index.md` where `src/content/docs` is the `contentPath`) + * Set how the path to the referenced markdown file should be resolved: + * - `'subdirectory'` - Prefix the path with the name of the content collection (ex. `./guides/my-guide.md` referenced from `./resources/my-reference.md` in the content collection `docs` would resolve to the path `/docs/guides/my-guide`) + * - `'root'` - Resolve the path as the site root (ex. `./guides/my-guide.md` referenced from `./resources/my-reference.md` in the content collection `docs` would resolve to the path `/guides/my-guide`) * - * Use the `root` configuration option when you are explicitly setting the {@link contentPath} property to something other than `src/content` and you want the directory you specify - * for {@link contentPath} to be treated a single content collection as if it where located in the site root. In most scenarios, you should set this value to `subdirectory` or not - * set this value and the default of `subdirectory` will be used. + * Use the `subdirectory` configuration option when you are treating your content collection as if it were located in the site root (ex: `src/pages`). In most scenarios, you should set this value to `prefix` or not + * set this value and the default of `prefix` will be used. * @example * ```js * { - * // Use 'subdirectory' mode - * collectionPathMode: 'subdirectory' + * // Use 'root' mode + * collectionPathMode: 'root' * } * ``` */ From 8522870295f8c52a302651d5dd3655f5a39c893d Mon Sep 17 00:00:00 2001 From: techfg Date: Mon, 22 Apr 2024 00:07:36 -0700 Subject: [PATCH 07/23] chore: add tests & types --- src/index.d.ts | 4 +- src/index.test.mjs | 107 +++++++++++++++++++++++++++++++++++-------- src/options.d.ts | 7 ++- src/options.test.mjs | 76 ++++++++++++++++++++---------- src/utils.d.ts | 10 ++-- src/utils.test.mjs | 75 +++++++++++++++++++++++++++++- 6 files changed, 227 insertions(+), 52 deletions(-) diff --git a/src/index.d.ts b/src/index.d.ts index fd5e775..40ad42e 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,8 +1,8 @@ import type { Plugin } from "unified"; import type { Root } from "hast"; -import type { Options } from "./options.d.ts"; +import type { Options, CollectionConfig } from "./options.d.ts"; -export { Options }; +export { Options, CollectionConfig }; /** * Rehype plugin for Astro to add support for transforming relative links in MD and MDX files into their final page paths. diff --git a/src/index.test.mjs b/src/index.test.mjs index 8920491..2e33e65 100644 --- a/src/index.test.mjs +++ b/src/index.test.mjs @@ -71,7 +71,8 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should transform root collection index.md paths with empty string custom slug", async () => { - const input = 'foo'; + const input = + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) @@ -111,7 +112,8 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should transform encoded file paths that exist with capital letters", async () => { - const input = 'foo'; + const input = + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) @@ -150,7 +152,8 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should transform file paths that exist with non alphanumeric characters", async () => { - const input = 'foo'; + const input = + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) @@ -163,7 +166,8 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should transform with correct path when destination has custom slug", async () => { - const input = 'foo'; + const input = + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) @@ -176,7 +180,8 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should transform with correct path when destination has custom slug with file extension", async () => { - const input = 'foo'; + const input = + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) @@ -217,7 +222,8 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should not transform index.md file paths if file does not exist", async () => { - const input = 'foo'; + const input = + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) @@ -256,7 +262,8 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should not replace path if .md directory does not exist", async () => { - const input = 'foo'; + const input = + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) @@ -269,7 +276,8 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should not replace path if .mdx directory does not exist", async () => { - const input = 'foo'; + const input = + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures" }) @@ -368,14 +376,14 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); }); - describe("config option - collectionPathMode:root", () => { + describe("config option - collectionBase", () => { test("should transform and contain index for root index.md", async () => { const input = 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures", - collectionPathMode: "root", + collectionBase: false, }) .process(input); @@ -392,7 +400,7 @@ describe("astroRehypeRelativeMarkdownLinks", () => { .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { src: "src/fixtures", - collectionPathMode: "root", + collectionBase: false, }) .process(input); @@ -408,7 +416,7 @@ describe("astroRehypeRelativeMarkdownLinks", () => { .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures", - collectionPathMode: "root", + collectionBase: false, }) .process(input); @@ -419,12 +427,13 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should transform root path custom slug", async () => { - const input = 'foo'; + const input = + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures", - collectionPathMode: "root", + collectionBase: false, }) .process(input); @@ -435,12 +444,13 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should transform subdir index.md", async () => { - const input = 'foo'; + const input = + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures", - collectionPathMode: "root", + collectionBase: false, }) .process(input); @@ -451,12 +461,13 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); test("should transform subdir path", async () => { - const input = 'foo'; + const input = + 'foo'; const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures", - collectionPathMode: "root", + collectionBase: false, }) .process(input); @@ -473,7 +484,7 @@ describe("astroRehypeRelativeMarkdownLinks", () => { .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { srcDir: "src/fixtures", - collectionPathMode: "root", + collectionBase: false, }) .process(input); @@ -484,6 +495,64 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); }); + describe("config option - collections", async () => { + test("should apply base when top-level collectionBase is false and collection level is 'name'", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: false, + collections: { + docs: { base: "name" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should not apply base when top-level collectionBase is name and collection level is false", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + docs: { base: false }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should not apply base when top-level collectionBase is not specified and collection level is false", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collections: { + docs: { base: false }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + }); + describe("config option - trailingSlash", () => { test("should contain trailing slash when option not specified and file contains", async () => { const input = 'foo'; diff --git a/src/options.d.ts b/src/options.d.ts index ac8ba96..c200a58 100644 --- a/src/options.d.ts +++ b/src/options.d.ts @@ -1,8 +1,11 @@ import { z } from "zod"; -import { OptionsSchema } from "./options.mjs"; +import { OptionsSchema, CollectionConfigSchema } from "./options.mjs"; export type OptionsSchemaType = typeof OptionsSchema; export interface Options extends z.input {} +export interface EffectiveOptions extends z.infer {} export type ValidateOptions = ( options: Options | null | undefined, -) => z.infer; +) => EffectiveOptions; +export type CollectionConfigSchemaType = typeof CollectionConfigSchema; +export interface CollectionConfig extends z.input {} diff --git a/src/options.test.mjs b/src/options.test.mjs index 1a9970f..06d9746 100644 --- a/src/options.test.mjs +++ b/src/options.test.mjs @@ -1,13 +1,16 @@ import { describe, test } from "node:test"; import { validateOptions } from "./options.mjs"; import assert from "node:assert"; -import path from "path"; + +/** @type {import('./options.d.ts').CollectionConfig} */ +const defaultCollectionConfig = {}; /** @type {import('./options.d.ts').Options} */ const defaultOptions = { srcDir: "./src", trailingSlash: "ignore", - collectionPathMode: "subdirectory", + collectionBase: "name", + collections: {}, }; describe("validateOptions", () => { @@ -32,7 +35,7 @@ describe("validateOptions", () => { const expectsValidOption = (options, option, expected) => { const actual = validateOptions(options); - assert.equal(actual[option], expected); + assert.deepStrictEqual(actual[option], expected); }; describe("defaults", () => { @@ -62,37 +65,62 @@ describe("validateOptions", () => { }); }); - describe("collectionPathMode", () => { - test("should have expected collectionPathMode default", () => { - expectsValidOption( - {}, - "collectionPathMode", - defaultOptions.collectionPathMode, - ); + describe("collectionBase", () => { + test("should have expected collectionBase default", () => { + expectsValidOption({}, "collectionBase", defaultOptions.collectionBase); }); - test("should be collectionPathMode subdirectory when subdirectory specified", () => { - expectsValidOption( - { collectionPathMode: "subdirectory" }, - "collectionPathMode", - "subdirectory", - ); + test("should be collectionBase name when name specified", () => { + expectsValidOption({ collectionBase: "name" }, "collectionBase", "name"); + }); + + test("should be collectionBase false when false specified", () => { + expectsValidOption({ collectionBase: false }, "collectionBase", false); + }); + + test("should error when collectionBase is a string", () => { + expectsZodError({ collectionBase: "foobar" }, "invalid_union"); + }); + + test("should fail when collectionBase is an object", () => { + expectsZodError({ collectionBase: {} }, "invalid_union"); + }); + }); + + describe("collections", () => { + test("should have expected collections default", () => { + expectsValidOption({}, "collections", defaultOptions.collections); + }); + + test("should contain empty collection when empty collection specified", () => { + const expected = { docs: {} }; + expectsValidOption({ collections: expected }, "collections", expected); + }); + + test("should contain base false for collection when base false specified", () => { + const expected = { docs: { base: false } }; + expectsValidOption({ collections: expected }, "collections", expected); }); - test("should be collectionPathMode root when root specified", () => { + test("should contain collection defaults when collection contains invalid collection key", () => { expectsValidOption( - { collectionPathMode: "root" }, - "collectionPathMode", - "root", + { collections: { docs: { thisdoesnotexistonschema: "foo" } } }, + "collections", + { docs: defaultCollectionConfig }, ); }); - test("should error when collectionPathMode is not a subdirectory or root", () => { - expectsZodError({ collectionPathMode: "foobar" }, "invalid_union"); + test("should contain multiple collections when multiple collections specified", () => { + const expected = { docs: { base: false }, newsletter: { base: "name" } }; + expectsValidOption({ collections: expected }, "collections", expected); + }); + + test("should error when collections is not an object", () => { + expectsZodError({ collections: false }, "invalid_type"); }); - test("should fail when collectionPathMode is not a string", () => { - expectsZodError({ collectionPathMode: {} }, "invalid_union"); + test("should error when collections contains numeric key", () => { + expectsZodError({ collections: { 5: "name" } }, "invalid_type"); }); }); diff --git a/src/utils.d.ts b/src/utils.d.ts index d47d051..ae4df2c 100644 --- a/src/utils.d.ts +++ b/src/utils.d.ts @@ -1,4 +1,4 @@ -import type { Options } from "./options.d.ts"; +import type { EffectiveOptions } from "./options.d.ts"; export type SplitPathFromQueryAndFragmentFn = ( path: string, @@ -14,12 +14,16 @@ export type ResolveSlug = ( export type ApplyTrailingSlash = ( originalUrl: string, resolvedUrl: string, - trailingSlash: Options["trailingSlash"], + trailingSlash: EffectiveOptions["trailingSlash"], ) => string; export type NormaliseAstroOutputPath = ( initialPath: string, - options: Options, + options: EffectiveOptions, ) => string; export type Slash = (path: string, sep: string) => string; export type NormalizePath = (path: string) => string; export type ShouldProcessFile = (path: string) => boolean; +export type ResolveCollectionBase = ( + collectionName: string, + options: EffectiveOptions, +) => string; diff --git a/src/utils.test.mjs b/src/utils.test.mjs index 73addf3..d546137 100644 --- a/src/utils.test.mjs +++ b/src/utils.test.mjs @@ -10,6 +10,7 @@ import { generateSlug, resolveSlug, applyTrailingSlash, + resolveCollectionBase, } from "./utils.mjs"; describe("replaceExt", () => { @@ -264,13 +265,17 @@ describe("isValidFile", () => { }); test("return false if relative path to .mdx file does not exist", () => { - const actual = isValidFile("./src/fixtures/content/docs/does-not-exist.mdx"); + const actual = isValidFile( + "./src/fixtures/content/docs/does-not-exist.mdx", + ); assert.equal(actual, false); }); test("return false if relative path to a file does not exist", () => { - const actual = isValidFile("./src/fixtures/content/docs/does-not-exist.txt"); + const actual = isValidFile( + "./src/fixtures/content/docs/does-not-exist.txt", + ); assert.equal(actual, false); }); @@ -403,3 +408,69 @@ describe("applyTrailingSlash", () => { }); }); }); + +describe("resolveCollectionBase", () => { + test("returns absolute collection name path when top-level name and no collection override", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: "name", + collections: {}, + }); + assert.equal(actual, "/docs"); + }); + + test("returns absolute collection name path when top-level false and collection override name", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: false, + collections: { docs: { base: "name" } }, + }); + assert.equal(actual, "/docs"); + }); + + test("returns absolute collection name path when top-level name and collection override name", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: "name", + collections: { docs: { base: "name" } }, + }); + assert.equal(actual, "/docs"); + }); + + test("returns absolute collection name path when top-level name and no collection override matches collection name", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: "name", + collections: { fake: { base: false } }, + }); + assert.equal(actual, "/docs"); + }); + + test("returns empty string when top-level false and no collection override", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: false, + collections: {}, + }); + assert.equal(actual, ""); + }); + + test("returns empty string when top-level name and collection override false", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: "name", + collections: { docs: { base: false } }, + }); + assert.equal(actual, ""); + }); + + test("returns empty string when top-level false and collection override false", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: false, + collections: { docs: { base: false } }, + }); + assert.equal(actual, ""); + }); + + test("returns empty string when top-level false and no collection override matches collection name", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: false, + collections: { fake: { base: "name" } }, + }); + assert.equal(actual, ""); + }); +}); From 329af337216fe452faf98b316cefb9e18c427df3 Mon Sep 17 00:00:00 2001 From: techfg Date: Mon, 22 Apr 2024 00:12:06 -0700 Subject: [PATCH 08/23] fix: apply collection base handling on a per collection basis --- docs/README.md | 3 +- docs/interfaces/CollectionConfig.md | 37 ++++++++ docs/interfaces/Options.md | 127 +++++++++++++++++++++------- src/index.mjs | 24 ++++-- src/options.mjs | 77 ++++++++++++----- src/utils.mjs | 12 ++- 6 files changed, 218 insertions(+), 62 deletions(-) create mode 100644 docs/interfaces/CollectionConfig.md diff --git a/docs/README.md b/docs/README.md index c8ca625..5482115 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ astro-rehype-relative-markdown-links ### Interfaces +- [CollectionConfig](interfaces/CollectionConfig.md) - [Options](interfaces/Options.md) ### Functions @@ -37,4 +38,4 @@ Rehype plugin for Astro to add support for transforming relative links in MD and #### Defined in -[src/index.mjs:33](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/index.mjs#L33) +[src/index.mjs:36](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/index.mjs#L36) diff --git a/docs/interfaces/CollectionConfig.md b/docs/interfaces/CollectionConfig.md new file mode 100644 index 0000000..ba5e547 --- /dev/null +++ b/docs/interfaces/CollectionConfig.md @@ -0,0 +1,37 @@ +[astro-rehype-relative-markdown-links](../README.md) / CollectionConfig + +# Interface: CollectionConfig + +## Hierarchy + +- `input`\<`CollectionConfigSchemaType`\> + + ↳ **`CollectionConfig`** + +## Table of contents + +### Properties + +- [base](CollectionConfig.md#base) + +## Properties + +### base + +• `Optional` **base**: ``false`` \| ``"name"`` + +**`Name`** + +base + +**`Description`** + +Override the top-level [collectionBase](Options.md#collectionbase) option for this collection. + +#### Inherited from + +z.input.base + +#### Defined in + +[src/options.mjs:12](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L12) diff --git a/docs/interfaces/Options.md b/docs/interfaces/Options.md index dce7876..67562f1 100644 --- a/docs/interfaces/Options.md +++ b/docs/interfaces/Options.md @@ -13,8 +13,9 @@ ### Properties - [basePath](Options.md#basepath) -- [collectionPathMode](Options.md#collectionpathmode) -- [contentPath](Options.md#contentpath) +- [collectionBase](Options.md#collectionbase) +- [collections](Options.md#collections) +- [srcDir](Options.md#srcdir) - [trailingSlash](Options.md#trailingslash) ## Properties @@ -49,74 +50,138 @@ z.input.basePath #### Defined in -[src/options.mjs:50](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L50) +[src/options.mjs:94](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L94) ___ -### collectionPathMode +### collectionBase -• `Optional` **collectionPathMode**: ``"root"`` \| ``"subdirectory"`` +• `Optional` **collectionBase**: ``false`` \| ``"name"`` **`Name`** -collectionPathMode +collectionBase **`Default`** -`subdirectory` +`"name"` **`Description`** -Where you store your collections: - - `subdirectory` - Subdirectories under `contentPath` (ex: `src/content/docs/index.md` where `docs` is the content collection subdirectory of the contentPath `src/content`) - - `root` - Directly inside `contentPath` (ex: `src/content/docs/index.md` where `src/content/docs` is the `contentPath`) +Set how the base segment of the URL path to the referenced markdown file should be derived: + - `"name"` - Apply the name on disk of the content collection (ex. `./guides/my-guide.md` referenced from `./resources/my-reference.md` in the content collection `docs` would resolve to the path `/docs/guides/my-guide`) + - `false` - Do not apply a base (ex. `./guides/my-guide.md` referenced from `./resources/my-reference.md` in the content collection `docs` would resolve to the path `/guides/my-guide`) -Use the `root` configuration option when you are explicitly setting the [contentPath](Options.md#contentpath) property to something other than `src/content` and you want the directory you specify -for [contentPath](Options.md#contentpath) to be treated a single content collection as if it where located in the site root. In most scenarios, you should set this value to `subdirectory` or not -set this value and the default of `subdirectory` will be used. +Use `false` when you are treating your content collection as if it were located in the site root (ex: `src/pages`). In most scenarios, you should set this value to `"name"` or not +set this value and the default of `"name"` will be used. + +Note that this is a top-level option and will apply to all content collections. If you have multiple collections and only want one of them to be treated as the site root, you should set this value to `"name"` (or leave the default) +and use the [collections](Options.md#collections) option to control the behavior for the specific content collection. **`Example`** ```js { - // Use 'subdirectory' mode - collectionPathMode: 'subdirectory' + // Do not apply a base segment to the transformed URL path + collectionBase: false } ``` +**`See`** + +[collections](Options.md#collections) + #### Inherited from -z.input.collectionPathMode +z.input.collectionBase #### Defined in -[src/options.mjs:33](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L33) +[src/options.mjs:57](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L57) + +___ + +### collections + +• `Optional` **collections**: `Record`\<`string`, \{ `base?`: ``false`` \| ``"name"`` }\> + +**`Name`** + +collections + +**`Default`** + +`{}` + +**`Description`** + +Specify a mapping of collections where the key is the name of a collection on disk and the value is an object of collection specific configuration which will override any top-level +configuration where applicable. + +**`Example`** + +```js +{ + // Do not apply a base segment to the transformed URL for the collection `docs` + collections: { + docs: { + base: false + } + } +} +``` + +**`See`** + +[CollectionConfig](CollectionConfig.md) + +#### Inherited from + +z.input.collections + +#### Defined in + +[src/options.mjs:79](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L79) ___ -### contentPath +### srcDir -• `Optional` **contentPath**: `string` +• `Optional` **srcDir**: `string` **`Name`** -contentPath +srcDir + +**`Reference`** + +https://docs.astro.build/en/reference/configuration-reference/#srcdir **`Default`** -`src/content` +`./src` **`Description`** -This defines where the content (i.e. md, mdx, etc. files) is stored. This should be a path relative to the root directory +Set the directory that Astro will read your site from. + +The value can be either an absolute file system path or a path relative to the project root. + +**`Example`** + +```js +{ + srcDir: './www' +} +``` #### Inherited from -z.input.contentPath +z.input.srcDir #### Defined in -[src/options.mjs:12](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L12) +[src/options.mjs:33](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L33) ___ @@ -130,16 +195,16 @@ trailingSlash **`Default`** -`ignore` +`"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. + - `"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: +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 @@ -152,7 +217,7 @@ When set to `'ignore'` (the default), the following will occur: ```js { // Use `always` mode - trailingSlash: `always` + trailingSlash: "always" } ``` @@ -166,4 +231,4 @@ z.input.trailingSlash #### Defined in -[src/options.mjs:77](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L77) +[src/options.mjs:121](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L121) diff --git a/src/index.mjs b/src/index.mjs index a068cb6..c0e9f0f 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -15,6 +15,7 @@ import { URL_PATH_SEPARATOR, FILE_PATH_SEPARATOR, shouldProcessFile, + resolveCollectionBase, } from "./utils.mjs"; import { validateOptions } from "./options.mjs"; @@ -25,6 +26,7 @@ const debug = debugFn("astro-rehype-relative-markdown-links"); const PATH_SEGMENT_EMPTY = ""; /** @typedef {import('./options.d.ts').Options} Options */ +/** @typedef {import('./options.d.ts').CollectionConfig} CollectionConfig */ /** * Rehype plugin for Astro to add support for transforming relative links in MD and MDX files into their final page paths. * @@ -67,7 +69,6 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { const { data: frontmatter } = matter(urlFileContent); const frontmatterSlug = frontmatter.slug; const contentDir = path.resolve(options.srcDir, "content"); - const collectionPathMode = options.collectionPathMode; const trailingSlashMode = options.trailingSlash; /* @@ -126,18 +127,20 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { const generatedSlug = generateSlug(pathSegments); // if we have a custom slug, use it, else use the default const resolvedSlug = resolveSlug(generatedSlug, frontmatterSlug); + // determine the collection base based on specified options + const resolvedCollectionBase = resolveCollectionBase( + collectionName, + options, + ); // content collection slugs are relative to content collection root (or site root if collectionPathMode is `root`) // so build url including the content collection name (if applicable) and the pages slug // NOTE - When there is a content collection name being applied, this only handles situations where the physical // directory name of the content collection maps 1:1 to the site page path serviing the content collection // page (see details above) - const resolvedUrl = [ - collectionPathMode === "root" - ? "" - : URL_PATH_SEPARATOR + collectionName, - resolvedSlug, - ].join(URL_PATH_SEPARATOR); + const resolvedUrl = [resolvedCollectionBase, resolvedSlug].join( + URL_PATH_SEPARATOR, + ); // slug of empty string ('') is a special case in Astro for root page (e.g., index.md) of a collection let webPathFinal = applyTrailingSlash( @@ -157,9 +160,12 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { // Debugging debug("--------------------------------------"); debug("BasePath : %s", options.basePath); - debug("SrcDir : %s", options.srcDir) + debug("SrcDir : %s", options.srcDir); debug("ContentDir : %s", contentDir); - debug("CollectionPathMode : %s", collectionPathMode); + debug( + "Resolved Collection Base : %s", + resolvedCollectionBase, + ); debug("TrailingSlashMode : %s", trailingSlashMode); debug("md/mdx AST Current File : %s", currentFile); debug("md/mdx AST Current File Dir : %s", currentFileDirectory); diff --git a/src/options.mjs b/src/options.mjs index 07434cb..257aa32 100644 --- a/src/options.mjs +++ b/src/options.mjs @@ -1,14 +1,27 @@ import { z } from "zod"; +const CollectionBase = z.union([z.literal("name"), z.literal(false)]); + +export const CollectionConfigSchema = z.object({ + /** + * @name base + * @description + * + * Override the top-level {@link Options#collectionBase collectionBase} option for this collection. + */ + base: CollectionBase.optional(), +}); + +/** @typedef {import('./options.d.ts').CollectionConfig} CollectionConfig */ export const OptionsSchema = z.object({ /** * @name srcDir * @reference https://docs.astro.build/en/reference/configuration-reference/#srcdir * @default `./src` * @description - * + * * Set the directory that Astro will read your site from. - * + * * The value can be either an absolute file system path or a path relative to the project root. * @example * ```js @@ -19,27 +32,51 @@ export const OptionsSchema = z.object({ */ srcDir: z.string().default("./src"), /** - * @name collectionPathMode - * @default `subdirectory` + * @name collectionBase + * @default `"name"` + * @description + * + * Set how the base segment of the URL path to the referenced markdown file should be derived: + * - `"name"` - Apply the name on disk of the content collection (ex. `./guides/my-guide.md` referenced from `./resources/my-reference.md` in the content collection `docs` would resolve to the path `/docs/guides/my-guide`) + * - `false` - Do not apply a base (ex. `./guides/my-guide.md` referenced from `./resources/my-reference.md` in the content collection `docs` would resolve to the path `/guides/my-guide`) + * + * Use `false` when you are treating your content collection as if it were located in the site root (ex: `src/pages`). In most scenarios, you should set this value to `"name"` or not + * set this value and the default of `"name"` will be used. + * + * Note that this is a top-level option and will apply to all content collections. If you have multiple collections and only want one of them to be treated as the site root, you should set this value to `"name"` (or leave the default) + * and use the {@link collections} option to control the behavior for the specific content collection. + * @example + * ```js + * { + * // Do not apply a base segment to the transformed URL path + * collectionBase: false + * } + * ``` + * @see {@link collections} + */ + collectionBase: CollectionBase.default("name"), + /** + * @name collections + * @default `{}` * @description * - * Set how the path to the referenced markdown file should be resolved: - * - `'subdirectory'` - Prefix the path with the name of the content collection (ex. `./guides/my-guide.md` referenced from `./resources/my-reference.md` in the content collection `docs` would resolve to the path `/docs/guides/my-guide`) - * - `'root'` - Resolve the path as the site root (ex. `./guides/my-guide.md` referenced from `./resources/my-reference.md` in the content collection `docs` would resolve to the path `/guides/my-guide`) + * Specify a mapping of collections where the key is the name of a collection on disk and the value is an object of collection specific configuration which will override any top-level + * configuration where applicable. * - * Use the `subdirectory` configuration option when you are treating your content collection as if it were located in the site root (ex: `src/pages`). In most scenarios, you should set this value to `prefix` or not - * set this value and the default of `prefix` will be used. * @example * ```js * { - * // Use 'root' mode - * collectionPathMode: 'root' + * // Do not apply a base segment to the transformed URL for the collection `docs` + * collections: { + * docs: { + * base: false + * } + * } * } * ``` + * @see {@link CollectionConfig} */ - collectionPathMode: z - .union([z.literal("subdirectory"), z.literal("root")]) - .default("subdirectory"), + collections: z.record(CollectionConfigSchema).default({}), /** * @name basePath * @reference https://docs.astro.build/en/reference/configuration-reference/#base @@ -57,15 +94,15 @@ export const OptionsSchema = z.object({ basePath: z.string().optional(), /** * @name trailingSlash - * @default `ignore` + * @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. + * - `"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: + * 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 @@ -76,7 +113,7 @@ export const OptionsSchema = z.object({ * ```js * { * // Use `always` mode - * trailingSlash: `always` + * trailingSlash: "always" * } * ``` * @see {@link https://docs.astro.build/en/reference/configuration-reference/#trailingslash|Astro} diff --git a/src/utils.mjs b/src/utils.mjs index ed44d0d..53e4cad 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -95,7 +95,7 @@ export const splitPathFromQueryAndFragment = (url) => { }; /** @type {import('./utils.d.ts').NormaliseAstroOutputPath} */ -export const normaliseAstroOutputPath = (initialPath, options = {}) => { +export const normaliseAstroOutputPath = (initialPath, options) => { const buildPath = () => { if (!options.basePath) { return initialPath; @@ -162,3 +162,13 @@ export function shouldProcessFile(npath) { // see https://github.com/withastro/astro/blob/0fec72b35cccf80b66a85664877ca9dcc94114aa/packages/astro/src/content/utils.ts#L253 return !npath.split(path.sep).some((p) => p && p.startsWith("_")); } + +/** @type {import('./utils.d.ts').ResolveCollectionBase} */ +export function resolveCollectionBase(collectionName, options) { + const customBaseMode = options.collections[collectionName]?.base; + const effectiveBaseMode = + customBaseMode === false || typeof customBaseMode === "string" + ? customBaseMode + : options.collectionBase; + return effectiveBaseMode === false ? "" : URL_PATH_SEPARATOR + collectionName; +} From a60ba3e23e9acc471d0220cbc3efc13b9749b13b Mon Sep 17 00:00:00 2001 From: techfg Date: Mon, 22 Apr 2024 00:53:05 -0700 Subject: [PATCH 09/23] chore: add tests --- src/index.test.mjs | 122 +++++++++++++++++++++++++------------------ src/options.test.mjs | 72 ++++++++++++++++--------- src/utils.test.mjs | 120 ++++++++++++++++++++++++++---------------- 3 files changed, 193 insertions(+), 121 deletions(-) diff --git a/src/index.test.mjs b/src/index.test.mjs index 2e33e65..d51b3b6 100644 --- a/src/index.test.mjs +++ b/src/index.test.mjs @@ -496,60 +496,82 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); describe("config option - collections", async () => { - test("should apply base when top-level collectionBase is false and collection level is 'name'", async () => { - const input = 'foo'; - const { value: actual } = await rehype() - .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { - srcDir: "src/fixtures", - collectionBase: false, - collections: { - docs: { base: "name" }, - }, - }) - .process(input); - - const expected = - 'foo'; - - assert.equal(actual, expected); - }); - - test("should not apply base when top-level collectionBase is name and collection level is false", async () => { - const input = 'foo'; - const { value: actual } = await rehype() - .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { - srcDir: "src/fixtures", - collectionBase: "name", - collections: { - docs: { base: false }, - }, - }) - .process(input); + describe("collections:base", () => { + test("should apply base when top-level collectionBase is false and collection level is 'name'", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: false, + collections: { + docs: { base: "name" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); - const expected = - 'foo'; + test("should not apply base when top-level collectionBase is name and collection level is false", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + docs: { base: false }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); - assert.equal(actual, expected); + test("should not apply base when top-level collectionBase is not specified and collection level is false", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collections: { + docs: { base: false }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); }); - test("should not apply base when top-level collectionBase is not specified and collection level is false", async () => { - const input = 'foo'; - const { value: actual } = await rehype() - .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { - srcDir: "src/fixtures", - collections: { - docs: { base: false }, - }, - }) - .process(input); - - const expected = - 'foo'; - - assert.equal(actual, expected); + describe("collections:name", async () => { + test("should contain name from override when name override specified", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collections: { + docs: { name: "my-docs" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); }); }); diff --git a/src/options.test.mjs b/src/options.test.mjs index 06d9746..0d86dcb 100644 --- a/src/options.test.mjs +++ b/src/options.test.mjs @@ -88,39 +88,61 @@ describe("validateOptions", () => { }); describe("collections", () => { - test("should have expected collections default", () => { - expectsValidOption({}, "collections", defaultOptions.collections); - }); + describe("collections:core", () => { + test("should have expected collections default", () => { + expectsValidOption({}, "collections", defaultOptions.collections); + }); - test("should contain empty collection when empty collection specified", () => { - const expected = { docs: {} }; - expectsValidOption({ collections: expected }, "collections", expected); - }); + test("should contain empty collection when empty collection specified", () => { + const expected = { docs: {} }; + expectsValidOption({ collections: expected }, "collections", expected); + }); - test("should contain base false for collection when base false specified", () => { - const expected = { docs: { base: false } }; - expectsValidOption({ collections: expected }, "collections", expected); - }); + test("should contain collection defaults when collection contains invalid collection key", () => { + expectsValidOption( + { collections: { docs: { thisdoesnotexistonschema: "foo" } } }, + "collections", + { docs: defaultCollectionConfig }, + ); + }); - test("should contain collection defaults when collection contains invalid collection key", () => { - expectsValidOption( - { collections: { docs: { thisdoesnotexistonschema: "foo" } } }, - "collections", - { docs: defaultCollectionConfig }, - ); - }); + test("should error when collections is not an object", () => { + expectsZodError({ collections: false }, "invalid_type"); + }); - test("should contain multiple collections when multiple collections specified", () => { - const expected = { docs: { base: false }, newsletter: { base: "name" } }; - expectsValidOption({ collections: expected }, "collections", expected); + test("should error when collections contains numeric key", () => { + expectsZodError({ collections: { 5: "name" } }, "invalid_type"); + }); }); - test("should error when collections is not an object", () => { - expectsZodError({ collections: false }, "invalid_type"); + describe("collections:base", () => { + test("should contain base false for collection when base false specified", () => { + const expected = { docs: { base: false } }; + expectsValidOption({ collections: expected }, "collections", expected); + }); + + test("should contain multiple collections when multiple collections specified", () => { + const expected = { + docs: { base: false }, + newsletter: { base: "name" }, + }; + expectsValidOption({ collections: expected }, "collections", expected); + }); }); - test("should error when collections contains numeric key", () => { - expectsZodError({ collections: { 5: "name" } }, "invalid_type"); + describe("collections:name", () => { + test("should contain name when name specified", () => { + const expected = { docs: { name: "my-docs" } }; + expectsValidOption({ collections: expected }, "collections", expected); + }); + + test("should contain multiple collections when multiple collections specified", () => { + const expected = { + docs: { name: "my-docs" }, + newsletter: { name: "my-newsletter" }, + }; + expectsValidOption({ collections: expected }, "collections", expected); + }); }); }); diff --git a/src/utils.test.mjs b/src/utils.test.mjs index d546137..80ea2e4 100644 --- a/src/utils.test.mjs +++ b/src/utils.test.mjs @@ -410,67 +410,95 @@ describe("applyTrailingSlash", () => { }); describe("resolveCollectionBase", () => { - test("returns absolute collection name path when top-level name and no collection override", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: "name", - collections: {}, + describe("option base", () => { + test("returns absolute collection name path when top-level name and no collection override", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: "name", + collections: {}, + }); + assert.equal(actual, "/docs"); }); - assert.equal(actual, "/docs"); - }); - test("returns absolute collection name path when top-level false and collection override name", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: false, - collections: { docs: { base: "name" } }, + test("returns absolute collection name path when top-level false and collection override name", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: false, + collections: { docs: { base: "name" } }, + }); + assert.equal(actual, "/docs"); }); - assert.equal(actual, "/docs"); - }); - test("returns absolute collection name path when top-level name and collection override name", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: "name", - collections: { docs: { base: "name" } }, + test("returns absolute collection name path when top-level name and collection override name", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: "name", + collections: { docs: { base: "name" } }, + }); + assert.equal(actual, "/docs"); }); - assert.equal(actual, "/docs"); - }); - test("returns absolute collection name path when top-level name and no collection override matches collection name", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: "name", - collections: { fake: { base: false } }, + test("returns absolute collection name path when top-level name and no collection override matches collection name", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: "name", + collections: { fake: { base: false } }, + }); + assert.equal(actual, "/docs"); }); - assert.equal(actual, "/docs"); - }); - test("returns empty string when top-level false and no collection override", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: false, - collections: {}, + test("returns empty string when top-level false and no collection override", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: false, + collections: {}, + }); + assert.equal(actual, ""); }); - assert.equal(actual, ""); - }); - test("returns empty string when top-level name and collection override false", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: "name", - collections: { docs: { base: false } }, + test("returns empty string when top-level name and collection override false", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: "name", + collections: { docs: { base: false } }, + }); + assert.equal(actual, ""); }); - assert.equal(actual, ""); - }); - test("returns empty string when top-level false and collection override false", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: false, - collections: { docs: { base: false } }, + test("returns empty string when top-level false and collection override false", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: false, + collections: { docs: { base: false } }, + }); + assert.equal(actual, ""); + }); + + test("returns empty string when top-level false and no collection override matches collection name", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: false, + collections: { fake: { base: "name" } }, + }); + assert.equal(actual, ""); }); - assert.equal(actual, ""); }); - test("returns empty string when top-level false and no collection override matches collection name", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: false, - collections: { fake: { base: "name" } }, + describe("option name", () => { + test("returns absolute collection name path from disk when no collection override", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: "name", + collections: {}, + }); + assert.equal(actual, "/docs"); + }); + + test("returns absolute collection name path from collection override", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: "name", + collections: { docs: { name: "my-docs" } }, + }); + assert.equal(actual, "/my-docs"); + }); + + test("returns absolute collection name path from disk when no collection overrides matches collection name", () => { + const actual = resolveCollectionBase("docs", { + collectionBase: "name", + collections: { fake: { name: "my-docs" } }, + }); + assert.equal(actual, "/docs"); }); - assert.equal(actual, ""); }); }); From 7a24c29096a8cc285da626fa1d4d60b8873dd5a5 Mon Sep 17 00:00:00 2001 From: techfg Date: Mon, 22 Apr 2024 00:54:00 -0700 Subject: [PATCH 10/23] feat: add ability to specify content collection name override (#24) --- docs/interfaces/CollectionConfig.md | 27 +++++++++++++++++++++++++++ docs/interfaces/Options.md | 12 ++++++------ src/options.mjs | 11 +++++++++++ src/utils.mjs | 12 ++++++++++-- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/docs/interfaces/CollectionConfig.md b/docs/interfaces/CollectionConfig.md index ba5e547..c46c0c8 100644 --- a/docs/interfaces/CollectionConfig.md +++ b/docs/interfaces/CollectionConfig.md @@ -13,6 +13,7 @@ ### Properties - [base](CollectionConfig.md#base) +- [name](CollectionConfig.md#name) ## Properties @@ -35,3 +36,29 @@ z.input.base #### Defined in [src/options.mjs:12](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L12) + +___ + +### name + +• `Optional` **name**: `string` + +**`Name`** + +name + +**`Description`** + +Override the name of the collection from disk. + +Use this option when your collection page path does not correspond to the name of the collection on disk (ex. `src/content/docs/reference.md` resolves to a page path of /my-docs/reference). + +When not specified, the name of the collection from disk will be used where applicable. + +#### Inherited from + +z.input.name + +#### Defined in + +[src/options.mjs:23](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L23) diff --git a/docs/interfaces/Options.md b/docs/interfaces/Options.md index 67562f1..99afd7a 100644 --- a/docs/interfaces/Options.md +++ b/docs/interfaces/Options.md @@ -50,7 +50,7 @@ z.input.basePath #### Defined in -[src/options.mjs:94](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L94) +[src/options.mjs:105](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L105) ___ @@ -97,13 +97,13 @@ z.input.collectionBase #### Defined in -[src/options.mjs:57](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L57) +[src/options.mjs:68](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L68) ___ ### collections -• `Optional` **collections**: `Record`\<`string`, \{ `base?`: ``false`` \| ``"name"`` }\> +• `Optional` **collections**: `Record`\<`string`, \{ `base?`: ``false`` \| ``"name"`` ; `name?`: `string` }\> **`Name`** @@ -141,7 +141,7 @@ z.input.collections #### Defined in -[src/options.mjs:79](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L79) +[src/options.mjs:90](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L90) ___ @@ -181,7 +181,7 @@ z.input.srcDir #### Defined in -[src/options.mjs:33](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L33) +[src/options.mjs:44](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L44) ___ @@ -231,4 +231,4 @@ z.input.trailingSlash #### Defined in -[src/options.mjs:121](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L121) +[src/options.mjs:132](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L132) diff --git a/src/options.mjs b/src/options.mjs index 257aa32..24f66db 100644 --- a/src/options.mjs +++ b/src/options.mjs @@ -10,6 +10,17 @@ export const CollectionConfigSchema = z.object({ * Override the top-level {@link Options#collectionBase collectionBase} option for this collection. */ base: CollectionBase.optional(), + /** + * @name name + * @description + * + * Override the name of the collection from disk. + * + * Use this option when your collection page path does not correspond to the name of the collection on disk (ex. `src/content/docs/reference.md` resolves to a page path of /my-docs/reference). + * + * When not specified, the name of the collection from disk will be used where applicable. + */ + name: z.string().optional(), }); /** @typedef {import('./options.d.ts').CollectionConfig} CollectionConfig */ diff --git a/src/utils.mjs b/src/utils.mjs index 53e4cad..4b2c862 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -165,10 +165,18 @@ export function shouldProcessFile(npath) { /** @type {import('./utils.d.ts').ResolveCollectionBase} */ export function resolveCollectionBase(collectionName, options) { - const customBaseMode = options.collections[collectionName]?.base; + const config = options.collections[collectionName]; + const customBaseMode = config?.base; + const customCollectionName = config?.name; const effectiveBaseMode = customBaseMode === false || typeof customBaseMode === "string" ? customBaseMode : options.collectionBase; - return effectiveBaseMode === false ? "" : URL_PATH_SEPARATOR + collectionName; + const effectiveCollectionName = + typeof customCollectionName === "string" + ? customCollectionName + : collectionName; + return effectiveBaseMode === false + ? "" + : URL_PATH_SEPARATOR + effectiveCollectionName; } From fa5cc3584a3d0002fdc97300f9e9f381d8106e25 Mon Sep 17 00:00:00 2001 From: techfg Date: Mon, 22 Apr 2024 19:09:38 -0700 Subject: [PATCH 11/23] perf: cache frontmatter data --- src/index.mjs | 7 ++----- src/utils.d.ts | 2 ++ src/utils.mjs | 17 ++++++++++++++++- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/index.mjs b/src/index.mjs index c0e9f0f..e492b28 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1,7 +1,5 @@ import { visit } from "unist-util-visit"; import * as path from "path"; -import * as fs from "fs"; -import { default as matter } from "gray-matter"; import { default as debugFn } from "debug"; import { replaceExt, @@ -16,6 +14,7 @@ import { FILE_PATH_SEPARATOR, shouldProcessFile, resolveCollectionBase, + getMatter, } from "./utils.mjs"; import { validateOptions } from "./options.mjs"; @@ -65,9 +64,7 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { } // read gray matter from href file - const urlFileContent = fs.readFileSync(urlFilePath); - const { data: frontmatter } = matter(urlFileContent); - const frontmatterSlug = frontmatter.slug; + const { slug: frontmatterSlug } = getMatter(urlFilePath); const contentDir = path.resolve(options.srcDir, "content"); const trailingSlashMode = options.trailingSlash; diff --git a/src/utils.d.ts b/src/utils.d.ts index ae4df2c..4bcb7e4 100644 --- a/src/utils.d.ts +++ b/src/utils.d.ts @@ -27,3 +27,5 @@ export type ResolveCollectionBase = ( collectionName: string, options: EffectiveOptions, ) => string; +export type MatterData = { slug?: string }; +export type GetMatter = (path: string) => MatterData; diff --git a/src/utils.mjs b/src/utils.mjs index 4b2c862..2bcca12 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -1,8 +1,9 @@ import path from "path"; -import { statSync } from "fs"; +import { readFileSync, statSync } from "fs"; import { slug as githubSlug } from "github-slugger"; import { z } from "zod"; import { asError } from "catch-unknown"; +import matter from "gray-matter"; const validMarkdownExtensions = [".md", ".mdx"]; const isWindows = @@ -180,3 +181,17 @@ export function resolveCollectionBase(collectionName, options) { ? "" : URL_PATH_SEPARATOR + effectiveCollectionName; } + +/** @type {Record} */ +const matterCache = {}; +/** @type {import('./utils.d.ts').GetMatter} */ +export function getMatter(npath) { + const readMatter = () => { + const content = readFileSync(npath); + const { data: frontmatter } = matter(content); + matterCache[npath] = frontmatter; + return frontmatter; + }; + + return matterCache[npath] || readMatter(); +} From 256eee2086c102c324c5f0d51729468c1fa21fce Mon Sep 17 00:00:00 2001 From: techfg Date: Tue, 23 Apr 2024 17:47:48 -0700 Subject: [PATCH 12/23] chore: improve test coverage, names & structure --- src/index.test.mjs | 196 ++++++++++++++++++++++--------------------- src/options.test.mjs | 92 ++++++++++++++++++-- src/utils.test.mjs | 8 +- 3 files changed, 191 insertions(+), 105 deletions(-) diff --git a/src/index.test.mjs b/src/index.test.mjs index d51b3b6..6d1ad82 100644 --- a/src/index.test.mjs +++ b/src/index.test.mjs @@ -377,126 +377,128 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); describe("config option - collectionBase", () => { - test("should transform and contain index for root index.md", async () => { - const input = 'foo'; - const { value: actual } = await rehype() - .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { - srcDir: "src/fixtures", - collectionBase: false, - }) - .process(input); + describe("collectionBase:false", () => { + test("should transform and contain index for root index.md", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: false, + }) + .process(input); - const expected = - 'foo'; + const expected = + 'foo'; - assert.equal(actual, expected); - }); + assert.equal(actual, expected); + }); - test("should transform root index.md with empty string custom slug", async () => { - const input = - 'foo'; - const { value: actual } = await rehype() - .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { - src: "src/fixtures", - collectionBase: false, - }) - .process(input); + test("should transform root index.md with empty string custom slug", async () => { + const input = + 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + src: "src/fixtures", + collectionBase: false, + }) + .process(input); - const expected = - 'foo'; + const expected = + 'foo'; - assert.equal(actual, expected); - }); + assert.equal(actual, expected); + }); - test("should transform root path", async () => { - const input = 'foo'; - const { value: actual } = await rehype() - .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { - srcDir: "src/fixtures", - collectionBase: false, - }) - .process(input); + test("should transform root path", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: false, + }) + .process(input); - const expected = - 'foo'; + const expected = + 'foo'; - assert.equal(actual, expected); - }); + assert.equal(actual, expected); + }); - test("should transform root path custom slug", async () => { - const input = - 'foo'; - const { value: actual } = await rehype() - .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { - srcDir: "src/fixtures", - collectionBase: false, - }) - .process(input); + test("should transform root path custom slug", async () => { + const input = + 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: false, + }) + .process(input); - const expected = - 'foo'; + const expected = + 'foo'; - assert.equal(actual, expected); - }); + assert.equal(actual, expected); + }); - test("should transform subdir index.md", async () => { - const input = - 'foo'; - const { value: actual } = await rehype() - .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { - srcDir: "src/fixtures", - collectionBase: false, - }) - .process(input); + test("should transform subdir index.md", async () => { + const input = + 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: false, + }) + .process(input); - const expected = - 'foo'; + const expected = + 'foo'; - assert.equal(actual, expected); - }); + assert.equal(actual, expected); + }); - test("should transform subdir path", async () => { - const input = - 'foo'; - const { value: actual } = await rehype() - .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { - srcDir: "src/fixtures", - collectionBase: false, - }) - .process(input); + test("should transform subdir path", async () => { + const input = + 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: false, + }) + .process(input); - const expected = - 'foo'; + const expected = + 'foo'; - assert.equal(actual, expected); - }); + assert.equal(actual, expected); + }); - test("should transform subdir path custom slug", async () => { - const input = - 'foo'; - const { value: actual } = await rehype() - .use(testSetupRehype) - .use(astroRehypeRelativeMarkdownLinks, { - srcDir: "src/fixtures", - collectionBase: false, - }) - .process(input); + test("should transform subdir path custom slug", async () => { + const input = + 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: false, + }) + .process(input); - const expected = - 'foo'; + const expected = + 'foo'; - assert.equal(actual, expected); + assert.equal(actual, expected); + }); }); }); describe("config option - collections", async () => { - describe("collections:base", () => { + describe("collections:base:name", () => { test("should apply base when top-level collectionBase is false and collection level is 'name'", async () => { const input = 'foo'; const { value: actual } = await rehype() @@ -515,7 +517,9 @@ describe("astroRehypeRelativeMarkdownLinks", () => { assert.equal(actual, expected); }); + }); + describe("collections:base:false", () => { test("should not apply base when top-level collectionBase is name and collection level is false", async () => { const input = 'foo'; const { value: actual } = await rehype() diff --git a/src/options.test.mjs b/src/options.test.mjs index 0d86dcb..f27e7c1 100644 --- a/src/options.test.mjs +++ b/src/options.test.mjs @@ -78,13 +78,21 @@ describe("validateOptions", () => { expectsValidOption({ collectionBase: false }, "collectionBase", false); }); - test("should error when collectionBase is a string", () => { + test("should error when collectionBase is a string containing an invalid value", () => { expectsZodError({ collectionBase: "foobar" }, "invalid_union"); }); - test("should fail when collectionBase is an object", () => { + test("should error when collectionBase is a number", () => { + expectsZodError({ collectionBase: 5 }, "invalid_union"); + }); + + test("should error when collectionBase is an object", () => { expectsZodError({ collectionBase: {} }, "invalid_union"); }); + + test("should error when collectionBase is null", () => { + expectsZodError({ collectionBase: null }, "invalid_union"); + }); }); describe("collections", () => { @@ -113,6 +121,10 @@ describe("validateOptions", () => { test("should error when collections contains numeric key", () => { expectsZodError({ collections: { 5: "name" } }, "invalid_type"); }); + + test("should error when collections is null", () => { + expectsZodError({ collections: null }, "invalid_type"); + }); }); describe("collections:base", () => { @@ -128,6 +140,34 @@ describe("validateOptions", () => { }; expectsValidOption({ collections: expected }, "collections", expected); }); + + test("should error when base is a string containing an invalid value", () => { + expectsZodError( + { collections: { docs: { base: "foobar" } } }, + "invalid_union", + ); + }); + + test("should error when base is a number", () => { + expectsZodError( + { collections: { docs: { base: 5 } } }, + "invalid_union", + ); + }); + + test("should error when base is an object", () => { + expectsZodError( + { collections: { docs: { base: {} } } }, + "invalid_union", + ); + }); + + test("should error when base is null", () => { + expectsZodError( + { collections: { docs: { base: null } } }, + "invalid_union", + ); + }); }); describe("collections:name", () => { @@ -143,6 +183,24 @@ describe("validateOptions", () => { }; expectsValidOption({ collections: expected }, "collections", expected); }); + + test("should error when name is a number", () => { + expectsZodError({ collections: { docs: { name: 5 } } }, "invalid_type"); + }); + + test("should error when name is an object", () => { + expectsZodError( + { collections: { docs: { name: {} } } }, + "invalid_type", + ); + }); + + test("should error when name is null", () => { + expectsZodError( + { collections: { docs: { name: null } } }, + "invalid_type", + ); + }); }); }); @@ -175,9 +233,17 @@ describe("validateOptions", () => { expectsZodError({ trailingSlash: "foobar" }, "invalid_union"); }); - test("should fail when trailingSlash is not a string", () => { + test("should error when trailingSlash is a number", () => { + expectsZodError({ trailingSlash: 5 }, "invalid_union"); + }); + + test("should error when trailingSlash is a object", () => { expectsZodError({ trailingSlash: {} }, "invalid_union"); }); + + test("should error when trailingSlash is null", () => { + expectsZodError({ trailingSlash: null }, "invalid_union"); + }); }); describe("basePath", () => { @@ -189,9 +255,17 @@ describe("validateOptions", () => { expectsValidOption({ basePath: "foobar" }, "basePath", "foobar"); }); - test("should fail when baesPath not a string", () => { + test("should error when basePath is a number", () => { + expectsZodError({ basePath: 5 }, "invalid_type"); + }); + + test("should error when basePath is a object", () => { expectsZodError({ basePath: {} }, "invalid_type"); }); + + test("should error when basePath is null", () => { + expectsZodError({ basePath: null }, "invalid_type"); + }); }); describe("srcDir", () => { @@ -203,8 +277,16 @@ describe("validateOptions", () => { expectsValidOption({ srcDir: "foobar" }, "srcDir", "foobar"); }); - test("should fail when srcDir not a string", () => { + test("should error when srcDir is a number", () => { + expectsZodError({ srcDir: 5 }, "invalid_type"); + }); + + test("should error when srcDir is a object", () => { expectsZodError({ srcDir: {} }, "invalid_type"); }); + + test("should error when srcDir is null", () => { + expectsZodError({ srcDir: null }, "invalid_type"); + }); }); }); diff --git a/src/utils.test.mjs b/src/utils.test.mjs index 80ea2e4..1ceb020 100644 --- a/src/utils.test.mjs +++ b/src/utils.test.mjs @@ -205,7 +205,7 @@ describe("resolveSlug", () => { describe("normaliseAstroOutputPath", () => { describe("prefix base to path", () => { - test("base with no slashes", () => { + test("should prefix base with no slashes", () => { const actual = normaliseAstroOutputPath("/foo-testing-test", { basePath: "base", }); @@ -213,7 +213,7 @@ describe("normaliseAstroOutputPath", () => { assert.equal(actual, "/base/foo-testing-test"); }); - test("base with slash at start", () => { + test("should prefix base with slash at start", () => { const actual = normaliseAstroOutputPath("/foo-testing-test", { basePath: "/base", }); @@ -221,7 +221,7 @@ describe("normaliseAstroOutputPath", () => { assert.equal(actual, "/base/foo-testing-test"); }); - test("base with slash at end", () => { + test("should prefix base with slash at end", () => { const actual = normaliseAstroOutputPath("/foo-testing-test", { basePath: "base/", }); @@ -229,7 +229,7 @@ describe("normaliseAstroOutputPath", () => { assert.equal(actual, "/base/foo-testing-test"); }); - test("base with slash at start and end", () => { + test("should prefix base with slash at start and end", () => { const actual = normaliseAstroOutputPath("/foo-testing-test", { basePath: "/base/", }); From 25b1d9f96c02b36bf5f417df48a655c730edf08b Mon Sep 17 00:00:00 2001 From: techfg Date: Tue, 23 Apr 2024 23:56:15 -0700 Subject: [PATCH 13/23] chore: refactor resolving collection option overrides --- src/index.mjs | 27 ++++++++----- src/options.d.ts | 8 ++++ src/options.mjs | 11 +++++ src/options.test.mjs | 95 ++++++++++++++++++++++++++++++++++++++++++- src/utils.d.ts | 9 ++--- src/utils.mjs | 29 +++++-------- src/utils.test.mjs | 96 +++++--------------------------------------- 7 files changed, 155 insertions(+), 120 deletions(-) diff --git a/src/index.mjs b/src/index.mjs index e492b28..654356c 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -16,7 +16,7 @@ import { resolveCollectionBase, getMatter, } from "./utils.mjs"; -import { validateOptions } from "./options.mjs"; +import { validateOptions, mergeCollectionOptions } from "./options.mjs"; // This package makes a lot of assumptions based on it being used with Astro @@ -110,11 +110,14 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { const collectionName = path .dirname(relativeToContentPath) .split(FILE_PATH_SEPARATOR)[0]; + // flatten options merging any collection overrides + const collectionOptions = mergeCollectionOptions(collectionName, options); // determine the path of the target file relative to the collection // since the slug for content collection pages is always relative to collection root + const collectionDir = path.join(contentDir, collectionName); const relativeToCollectionPath = path.relative( - collectionName, - relativeToContentPath, + collectionDir, + urlFilePath, ); // md/mdx extentions should not be in the final url const withoutFileExt = replaceExt(relativeToCollectionPath, ""); @@ -125,10 +128,7 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { // if we have a custom slug, use it, else use the default const resolvedSlug = resolveSlug(generatedSlug, frontmatterSlug); // determine the collection base based on specified options - const resolvedCollectionBase = resolveCollectionBase( - collectionName, - options, - ); + const resolvedCollectionBase = resolveCollectionBase(collectionOptions); // content collection slugs are relative to content collection root (or site root if collectionPathMode is `root`) // so build url including the content collection name (if applicable) and the pages slug @@ -152,13 +152,23 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { webPathFinal += urlQueryStringAndFragmentPart; } - webPathFinal = normaliseAstroOutputPath(webPathFinal, options); + webPathFinal = normaliseAstroOutputPath(webPathFinal, collectionOptions); // Debugging debug("--------------------------------------"); debug("BasePath : %s", options.basePath); debug("SrcDir : %s", options.srcDir); debug("ContentDir : %s", contentDir); + debug("CollectionDir : %s", collectionDir); + debug("Collection Name from Disk : %s", collectionName); + debug( + "Resolved Collection Name Option : %s", + collectionOptions.collectionName, + ); + debug( + "Resolved Collection Base Option : %s", + collectionOptions.collectionBase, + ); debug( "Resolved Collection Base : %s", resolvedCollectionBase, @@ -174,7 +184,6 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { ); debug("URL file : %s", urlFilePath); debug("URL file relative to content path : %s", relativeToContentPath); - debug("Collection Name : %s", collectionName); debug( "URL file relative to collection path : %s", relativeToCollectionPath, diff --git a/src/options.d.ts b/src/options.d.ts index c200a58..c0df9f5 100644 --- a/src/options.d.ts +++ b/src/options.d.ts @@ -4,8 +4,16 @@ import { OptionsSchema, CollectionConfigSchema } from "./options.mjs"; export type OptionsSchemaType = typeof OptionsSchema; export interface Options extends z.input {} export interface EffectiveOptions extends z.infer {} +export interface EffectiveCollectionOptions + extends Omit { + collectionName: string; +} export type ValidateOptions = ( options: Options | null | undefined, ) => EffectiveOptions; export type CollectionConfigSchemaType = typeof CollectionConfigSchema; export interface CollectionConfig extends z.input {} +export type MergeCollectionOptions = ( + collectionName: string, + options: EffectiveOptions, +) => EffectiveCollectionOptions; diff --git a/src/options.mjs b/src/options.mjs index 24f66db..14a6378 100644 --- a/src/options.mjs +++ b/src/options.mjs @@ -143,3 +143,14 @@ export const validateOptions = (options) => { return result.data; }; + +/** @type {import('./options.d.ts').MergeCollectionOptions} */ +export const mergeCollectionOptions = (collectionName, options) => { + const config = options.collections[collectionName] || {}; + const { base = options.collectionBase, name = collectionName } = config; + return { + ...options, + collectionBase: base, + collectionName: name, + }; +}; diff --git a/src/options.test.mjs b/src/options.test.mjs index f27e7c1..d950d60 100644 --- a/src/options.test.mjs +++ b/src/options.test.mjs @@ -1,5 +1,5 @@ import { describe, test } from "node:test"; -import { validateOptions } from "./options.mjs"; +import { mergeCollectionOptions, validateOptions } from "./options.mjs"; import assert from "node:assert"; /** @type {import('./options.d.ts').CollectionConfig} */ @@ -290,3 +290,96 @@ describe("validateOptions", () => { }); }); }); + +describe("mergeCollectionOptions", () => { + describe("collectionBase", () => { + test("collectionBase should be name when top-level name and no collection override", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "name", + collections: {}, + }); + assert.equal(actual.collectionBase, "name"); + }); + + test("collectionBase should name when top-level false and collection override name", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: false, + collections: { docs: { base: "name" } }, + }); + assert.equal(actual.collectionBase, "name"); + }); + + test("collectionBase should be name when top-level name and collection override name", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "name", + collections: { docs: { base: "name" } }, + }); + assert.equal(actual.collectionBase, "name"); + }); + + test("collectionBase should be name when top-level name and no collection override matches collection name", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "name", + collections: { fake: { base: false } }, + }); + assert.equal(actual.collectionBase, "name"); + }); + + test("collectionBase should be false when top-level false and no collection override", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: false, + collections: {}, + }); + assert.equal(actual.collectionBase, false); + }); + + test("collectionBase should be false top-level name and collection override false", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "name", + collections: { docs: { base: false } }, + }); + assert.equal(actual.collectionBase, false); + }); + + test("collectionBase should be false when top-level false and collection override false", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: false, + collections: { docs: { base: false } }, + }); + assert.equal(actual.collectionBase, false); + }); + + test("collectionBase should be false when top-level false and no collection override matches collection name", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: false, + collections: { fake: { base: "name" } }, + }); + assert.equal(actual.collectionBase, false); + }); + }); + + describe("collectionName", () => { + test("collectionName should contain name from parameter when no collection override", () => { + const actual = mergeCollectionOptions("docs", { + collections: {}, + }); + assert.equal(actual.collectionName, "docs"); + }); + + test("collectionName should contain name from collection override", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "name", + collections: { docs: { name: "my-docs" } }, + }); + assert.equal(actual.collectionName, "my-docs"); + }); + + test("collectionName should contain name from parameter when no collection overrides matches collection name", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "name", + collections: { fake: { name: "my-docs" } }, + }); + assert.equal(actual.collectionName, "docs"); + }); + }); +}); diff --git a/src/utils.d.ts b/src/utils.d.ts index 4bcb7e4..1813bb9 100644 --- a/src/utils.d.ts +++ b/src/utils.d.ts @@ -1,4 +1,4 @@ -import type { EffectiveOptions } from "./options.d.ts"; +import type { EffectiveCollectionOptions } from "./options.d.ts"; export type SplitPathFromQueryAndFragmentFn = ( path: string, @@ -14,18 +14,17 @@ export type ResolveSlug = ( export type ApplyTrailingSlash = ( originalUrl: string, resolvedUrl: string, - trailingSlash: EffectiveOptions["trailingSlash"], + trailingSlash: EffectiveCollectionOptions["trailingSlash"], ) => string; export type NormaliseAstroOutputPath = ( initialPath: string, - options: EffectiveOptions, + collectionOptions: EffectiveCollectionOptions, ) => string; export type Slash = (path: string, sep: string) => string; export type NormalizePath = (path: string) => string; export type ShouldProcessFile = (path: string) => boolean; export type ResolveCollectionBase = ( - collectionName: string, - options: EffectiveOptions, + collectionOptions: EffectiveCollectionOptions, ) => string; export type MatterData = { slug?: string }; export type GetMatter = (path: string) => MatterData; diff --git a/src/utils.mjs b/src/utils.mjs index 2bcca12..7637f2e 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -96,17 +96,19 @@ export const splitPathFromQueryAndFragment = (url) => { }; /** @type {import('./utils.d.ts').NormaliseAstroOutputPath} */ -export const normaliseAstroOutputPath = (initialPath, options) => { +export const normaliseAstroOutputPath = (initialPath, collectionOptions) => { const buildPath = () => { - if (!options.basePath) { + if (!collectionOptions.basePath) { return initialPath; } - if (options.basePath.startsWith(URL_PATH_SEPARATOR)) { - return path.join(options.basePath, initialPath); + if (collectionOptions.basePath.startsWith(URL_PATH_SEPARATOR)) { + return path.join(collectionOptions.basePath, initialPath); } - return URL_PATH_SEPARATOR + path.join(options.basePath, initialPath); + return ( + URL_PATH_SEPARATOR + path.join(collectionOptions.basePath, initialPath) + ); }; if (!initialPath) { @@ -165,21 +167,10 @@ export function shouldProcessFile(npath) { } /** @type {import('./utils.d.ts').ResolveCollectionBase} */ -export function resolveCollectionBase(collectionName, options) { - const config = options.collections[collectionName]; - const customBaseMode = config?.base; - const customCollectionName = config?.name; - const effectiveBaseMode = - customBaseMode === false || typeof customBaseMode === "string" - ? customBaseMode - : options.collectionBase; - const effectiveCollectionName = - typeof customCollectionName === "string" - ? customCollectionName - : collectionName; - return effectiveBaseMode === false +export function resolveCollectionBase(collectionOptions) { + return collectionOptions.collectionBase === false ? "" - : URL_PATH_SEPARATOR + effectiveCollectionName; + : URL_PATH_SEPARATOR + collectionOptions.collectionName; } /** @type {Record} */ diff --git a/src/utils.test.mjs b/src/utils.test.mjs index 1ceb020..9c76973 100644 --- a/src/utils.test.mjs +++ b/src/utils.test.mjs @@ -410,95 +410,19 @@ describe("applyTrailingSlash", () => { }); describe("resolveCollectionBase", () => { - describe("option base", () => { - test("returns absolute collection name path when top-level name and no collection override", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: "name", - collections: {}, - }); - assert.equal(actual, "/docs"); - }); - - test("returns absolute collection name path when top-level false and collection override name", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: false, - collections: { docs: { base: "name" } }, - }); - assert.equal(actual, "/docs"); - }); - - test("returns absolute collection name path when top-level name and collection override name", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: "name", - collections: { docs: { base: "name" } }, - }); - assert.equal(actual, "/docs"); - }); - - test("returns absolute collection name path when top-level name and no collection override matches collection name", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: "name", - collections: { fake: { base: false } }, - }); - assert.equal(actual, "/docs"); - }); - - test("returns empty string when top-level false and no collection override", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: false, - collections: {}, - }); - assert.equal(actual, ""); - }); - - test("returns empty string when top-level name and collection override false", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: "name", - collections: { docs: { base: false } }, - }); - assert.equal(actual, ""); - }); - - test("returns empty string when top-level false and collection override false", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: false, - collections: { docs: { base: false } }, - }); - assert.equal(actual, ""); - }); - - test("returns empty string when top-level false and no collection override matches collection name", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: false, - collections: { fake: { base: "name" } }, - }); - assert.equal(actual, ""); + test("should return absolute collection name path when base is name", () => { + const actual = resolveCollectionBase({ + collectionBase: "name", + collectionName: "docs", }); + assert.equal(actual, "/docs"); }); - describe("option name", () => { - test("returns absolute collection name path from disk when no collection override", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: "name", - collections: {}, - }); - assert.equal(actual, "/docs"); - }); - - test("returns absolute collection name path from collection override", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: "name", - collections: { docs: { name: "my-docs" } }, - }); - assert.equal(actual, "/my-docs"); - }); - - test("returns absolute collection name path from disk when no collection overrides matches collection name", () => { - const actual = resolveCollectionBase("docs", { - collectionBase: "name", - collections: { fake: { name: "my-docs" } }, - }); - assert.equal(actual, "/docs"); + test("should return empty string when base is false", () => { + const actual = resolveCollectionBase({ + collectionBase: false, + collectionName: undefined, }); + assert.equal(actual, ""); }); }); From 8f0b73c645211cafdf9ce9761647619915537773 Mon Sep 17 00:00:00 2001 From: techfg Date: Tue, 23 Apr 2024 21:57:19 -0700 Subject: [PATCH 14/23] chore: disable cache during test --- package.json | 2 +- src/utils.mjs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d7fbee1..eb75e33 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "pre-release": "yarn run changelog && yarn run prettier && yarn run generate-docs", "generate-docs": "typedoc --readme none --gitRevision main --plugin typedoc-plugin-markdown src", "prettier": "prettier ./src/** -w", - "test": "node --loader=esmock --test", + "test": "MATTER_CACHE_DISABLE=true node --loader=esmock --test", "type-check": "tsc --noEmit --emitDeclarationOnly false" }, "dependencies": { diff --git a/src/utils.mjs b/src/utils.mjs index 7637f2e..b435c2a 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -175,12 +175,15 @@ export function resolveCollectionBase(collectionOptions) { /** @type {Record} */ const matterCache = {}; +const matterCacheEnabled = process.env.MATTER_CACHE_DISABLE !== "true"; /** @type {import('./utils.d.ts').GetMatter} */ export function getMatter(npath) { const readMatter = () => { const content = readFileSync(npath); const { data: frontmatter } = matter(content); - matterCache[npath] = frontmatter; + if (matterCacheEnabled) { + matterCache[npath] = frontmatter; + } return frontmatter; }; From 8592fdb0809a55af7db3e912d33562392b8e074d Mon Sep 17 00:00:00 2001 From: techfg Date: Wed, 24 Apr 2024 16:31:04 -0700 Subject: [PATCH 15/23] chore: add missing test --- src/options.test.mjs | 5 +++++ src/utils.test.mjs | 24 ++++++++++++++---------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/options.test.mjs b/src/options.test.mjs index d950d60..e62fca1 100644 --- a/src/options.test.mjs +++ b/src/options.test.mjs @@ -128,6 +128,11 @@ describe("validateOptions", () => { }); describe("collections:base", () => { + test("should contain base name for collection when base name specified", () => { + const expected = { docs: { base: "name" } }; + expectsValidOption({ collections: expected }, "collections", expected); + }); + test("should contain base false for collection when base false specified", () => { const expected = { docs: { base: false } }; expectsValidOption({ collections: expected }, "collections", expected); diff --git a/src/utils.test.mjs b/src/utils.test.mjs index 9c76973..1618a83 100644 --- a/src/utils.test.mjs +++ b/src/utils.test.mjs @@ -410,19 +410,23 @@ describe("applyTrailingSlash", () => { }); describe("resolveCollectionBase", () => { - test("should return absolute collection name path when base is name", () => { - const actual = resolveCollectionBase({ - collectionBase: "name", - collectionName: "docs", + describe("collectionBase:name", () => { + test("should return absolute collection name path when collectionBase is name", () => { + const actual = resolveCollectionBase({ + collectionBase: "name", + collectionName: "docs", + }); + assert.equal(actual, "/docs"); }); - assert.equal(actual, "/docs"); }); - test("should return empty string when base is false", () => { - const actual = resolveCollectionBase({ - collectionBase: false, - collectionName: undefined, + describe("collectionBase:false", () => { + test("should return empty string when collectionBase is false", () => { + const actual = resolveCollectionBase({ + collectionBase: false, + collectionName: undefined, + }); + assert.equal(actual, ""); }); - assert.equal(actual, ""); }); }); From 8bb063afc8a58d80914eb786e8517a2e1d71413e Mon Sep 17 00:00:00 2001 From: techfg Date: Wed, 24 Apr 2024 18:22:53 -0700 Subject: [PATCH 16/23] feat: add collectionRelative support for transforming urls --- docs/README.md | 2 +- docs/interfaces/CollectionConfig.md | 6 +- docs/interfaces/Options.md | 52 ++-- .../test-custom-slug-is-collection-root.md | 5 + .../dir-grandchild-1/test-custom-slug.md | 5 + .../dir-child-1/dir-grandchild-1/test.md | 3 + .../dir-child-1/dir-grandchild-1/test2.md | 3 + .../dir-child-1/dir-grandchild-2/test.md | 3 + .../test-custom-slug-with-forward-slash.md | 5 + .../relative-tests/dir-child-1/test-me-out.md | 3 + .../relative-tests/dir-child-1/test.md | 3 + .../relative-tests/dir-child-2/test.md | 3 + .../dir-child-3.md/test-me-out.md | 3 + .../relative-tests/dir-child-3.md/test.md | 3 + src/fixtures/content/relative-tests/test.md | 3 + src/fixtures/content/relative-tests/test2.md | 3 + src/index.mjs | 5 +- src/index.test.mjs | 266 +++++++++++++++++- src/options.mjs | 44 ++- src/options.test.mjs | 45 +++ src/utils.d.ts | 11 + src/utils.mjs | 62 +++- src/utils.test.mjs | 80 ++++++ 23 files changed, 574 insertions(+), 44 deletions(-) create mode 100644 src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test-custom-slug-is-collection-root.md create mode 100644 src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test-custom-slug.md create mode 100644 src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test.md create mode 100644 src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test2.md create mode 100644 src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-2/test.md create mode 100644 src/fixtures/content/relative-tests/dir-child-1/test-custom-slug-with-forward-slash.md create mode 100644 src/fixtures/content/relative-tests/dir-child-1/test-me-out.md create mode 100644 src/fixtures/content/relative-tests/dir-child-1/test.md create mode 100644 src/fixtures/content/relative-tests/dir-child-2/test.md create mode 100644 src/fixtures/content/relative-tests/dir-child-3.md/test-me-out.md create mode 100644 src/fixtures/content/relative-tests/dir-child-3.md/test.md create mode 100644 src/fixtures/content/relative-tests/test.md create mode 100644 src/fixtures/content/relative-tests/test2.md diff --git a/docs/README.md b/docs/README.md index 5482115..8a4e150 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,4 +38,4 @@ Rehype plugin for Astro to add support for transforming relative links in MD and #### Defined in -[src/index.mjs:36](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/index.mjs#L36) +[src/index.mjs:35](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/index.mjs#L35) diff --git a/docs/interfaces/CollectionConfig.md b/docs/interfaces/CollectionConfig.md index c46c0c8..35e9014 100644 --- a/docs/interfaces/CollectionConfig.md +++ b/docs/interfaces/CollectionConfig.md @@ -19,7 +19,7 @@ ### base -• `Optional` **base**: ``false`` \| ``"name"`` +• `Optional` **base**: ``false`` \| ``"name"`` \| ``"collectionRelative"`` **`Name`** @@ -35,7 +35,7 @@ z.input.base #### Defined in -[src/options.mjs:12](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L12) +[src/options.mjs:16](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L16) ___ @@ -61,4 +61,4 @@ z.input.name #### Defined in -[src/options.mjs:23](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L23) +[src/options.mjs:27](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L27) diff --git a/docs/interfaces/Options.md b/docs/interfaces/Options.md index 99afd7a..9f7c8f3 100644 --- a/docs/interfaces/Options.md +++ b/docs/interfaces/Options.md @@ -36,11 +36,11 @@ https://docs.astro.build/en/reference/configuration-reference/#base The base path to deploy to. Astro will use this path as the root for your pages and assets both in development and in production build. -In the example below, `astro dev` will start your server at `/docs`. +In the example below, `astro dev` will start your server at `/my-site`. ```js { - base: '/docs' + base: '/my-site' } ``` @@ -50,13 +50,13 @@ z.input.basePath #### Defined in -[src/options.mjs:105](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L105) +[src/options.mjs:121](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L121) ___ ### collectionBase -• `Optional` **collectionBase**: ``false`` \| ``"name"`` +• `Optional` **collectionBase**: ``false`` \| ``"name"`` \| ``"collectionRelative"`` **`Name`** @@ -68,21 +68,33 @@ collectionBase **`Description`** -Set how the base segment of the URL path to the referenced markdown file should be derived: - - `"name"` - Apply the name on disk of the content collection (ex. `./guides/my-guide.md` referenced from `./resources/my-reference.md` in the content collection `docs` would resolve to the path `/docs/guides/my-guide`) - - `false` - Do not apply a base (ex. `./guides/my-guide.md` referenced from `./resources/my-reference.md` in the content collection `docs` would resolve to the path `/guides/my-guide`) +Set how the URL path to the referenced markdown file should be derived: + - `"name"` - An absolute path prefixed with the optional [basePath](Options.md#basepath) followed by the collection name + - `false` - An absolute path prefixed with the optional [basePath](Options.md#basepath) + - `"collectionRelative"` - A relative path from the collection directory -Use `false` when you are treating your content collection as if it were located in the site root (ex: `src/pages`). In most scenarios, you should set this value to `"name"` or not -set this value and the default of `"name"` will be used. +For example, given a file `./guides/section/my-guide.md` referenced from `./guides/section/my-other-guide.md` with +the link `[My Guide](./my-guide.md)` in the content collection `docs`, the transformed url would be: + - `"name"`: `[/basePath]/docs/guides/section/my-guide` + - `false`: `[/basePath]/guides/section/my-guide` + - `"collectionRelative"`: `../../guides/section/my-guide` -Note that this is a top-level option and will apply to all content collections. If you have multiple collections and only want one of them to be treated as the site root, you should set this value to `"name"` (or leave the default) -and use the [collections](Options.md#collections) option to control the behavior for the specific content collection. +Use `false` or `"collectionRelative"` when you are treating your content collection as if it were located in the site +root (ex: `src/content/docs/test.md` resolves to the page path `/test` instead of the typical `/docs/test`). + +Use `"collectionRelative"` when you are serving your content collection pages from multiple page path roots that use a +common content collection (ex: `/my-blog/test` and `/your-blog/test` both point to the file `./src/content/posts/test.md` +in the content collection `posts`). + +Note that this is a top-level option and will apply to all content collections. If you have multiple content collections +and want the behavior to be different on a per content collection basis, add the collection(s) to the [collections](Options.md#collections) +option and provide a value for collection specific [base](CollectionConfig.md) option. **`Example`** ```js { - // Do not apply a base segment to the transformed URL path + // Do not apply a collection name segment to the generated absolute URL path collectionBase: false } ``` @@ -97,13 +109,13 @@ z.input.collectionBase #### Defined in -[src/options.mjs:68](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L68) +[src/options.mjs:84](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L84) ___ ### collections -• `Optional` **collections**: `Record`\<`string`, \{ `base?`: ``false`` \| ``"name"`` ; `name?`: `string` }\> +• `Optional` **collections**: `Record`\<`string`, \{ `base?`: ``false`` \| ``"name"`` \| ``"collectionRelative"`` ; `name?`: `string` }\> **`Name`** @@ -115,14 +127,14 @@ collections **`Description`** -Specify a mapping of collections where the key is the name of a collection on disk and the value is an object of collection specific configuration which will override any top-level -configuration where applicable. +Specify a mapping of collections where the `key` is the name of a collection on disk and the value is a [CollectionConfig](CollectionConfig.md) +which will override any top-level configuration where applicable. **`Example`** ```js { - // Do not apply a base segment to the transformed URL for the collection `docs` + // Do not apply a collection name segment to the generated absolute URL path for the collection `docs` collections: { docs: { base: false @@ -141,7 +153,7 @@ z.input.collections #### Defined in -[src/options.mjs:90](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L90) +[src/options.mjs:106](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L106) ___ @@ -181,7 +193,7 @@ z.input.srcDir #### Defined in -[src/options.mjs:44](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L44) +[src/options.mjs:48](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L48) ___ @@ -231,4 +243,4 @@ z.input.trailingSlash #### Defined in -[src/options.mjs:132](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L132) +[src/options.mjs:148](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L148) diff --git a/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test-custom-slug-is-collection-root.md b/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test-custom-slug-is-collection-root.md new file mode 100644 index 0000000..09fbc91 --- /dev/null +++ b/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test-custom-slug-is-collection-root.md @@ -0,0 +1,5 @@ +--- +slug: test.custom.slug.is.collection.root +--- + +Test diff --git a/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test-custom-slug.md b/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test-custom-slug.md new file mode 100644 index 0000000..07a33eb --- /dev/null +++ b/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test-custom-slug.md @@ -0,0 +1,5 @@ +--- +slug: dir-child-1/dir-grandchild-1/test.custom.slug +--- + +Test diff --git a/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test.md b/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test.md new file mode 100644 index 0000000..157023e --- /dev/null +++ b/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test.md @@ -0,0 +1,3 @@ +## Test + +Test [link](./other-markdown.md) diff --git a/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test2.md b/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test2.md new file mode 100644 index 0000000..157023e --- /dev/null +++ b/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test2.md @@ -0,0 +1,3 @@ +## Test + +Test [link](./other-markdown.md) diff --git a/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-2/test.md b/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-2/test.md new file mode 100644 index 0000000..157023e --- /dev/null +++ b/src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-2/test.md @@ -0,0 +1,3 @@ +## Test + +Test [link](./other-markdown.md) diff --git a/src/fixtures/content/relative-tests/dir-child-1/test-custom-slug-with-forward-slash.md b/src/fixtures/content/relative-tests/dir-child-1/test-custom-slug-with-forward-slash.md new file mode 100644 index 0000000..d5aa8d4 --- /dev/null +++ b/src/fixtures/content/relative-tests/dir-child-1/test-custom-slug-with-forward-slash.md @@ -0,0 +1,5 @@ +--- +slug: dir-child-1/test.custom.slug.with-forward.slash/ +--- + +Test diff --git a/src/fixtures/content/relative-tests/dir-child-1/test-me-out.md b/src/fixtures/content/relative-tests/dir-child-1/test-me-out.md new file mode 100644 index 0000000..157023e --- /dev/null +++ b/src/fixtures/content/relative-tests/dir-child-1/test-me-out.md @@ -0,0 +1,3 @@ +## Test + +Test [link](./other-markdown.md) diff --git a/src/fixtures/content/relative-tests/dir-child-1/test.md b/src/fixtures/content/relative-tests/dir-child-1/test.md new file mode 100644 index 0000000..157023e --- /dev/null +++ b/src/fixtures/content/relative-tests/dir-child-1/test.md @@ -0,0 +1,3 @@ +## Test + +Test [link](./other-markdown.md) diff --git a/src/fixtures/content/relative-tests/dir-child-2/test.md b/src/fixtures/content/relative-tests/dir-child-2/test.md new file mode 100644 index 0000000..157023e --- /dev/null +++ b/src/fixtures/content/relative-tests/dir-child-2/test.md @@ -0,0 +1,3 @@ +## Test + +Test [link](./other-markdown.md) diff --git a/src/fixtures/content/relative-tests/dir-child-3.md/test-me-out.md b/src/fixtures/content/relative-tests/dir-child-3.md/test-me-out.md new file mode 100644 index 0000000..157023e --- /dev/null +++ b/src/fixtures/content/relative-tests/dir-child-3.md/test-me-out.md @@ -0,0 +1,3 @@ +## Test + +Test [link](./other-markdown.md) diff --git a/src/fixtures/content/relative-tests/dir-child-3.md/test.md b/src/fixtures/content/relative-tests/dir-child-3.md/test.md new file mode 100644 index 0000000..157023e --- /dev/null +++ b/src/fixtures/content/relative-tests/dir-child-3.md/test.md @@ -0,0 +1,3 @@ +## Test + +Test [link](./other-markdown.md) diff --git a/src/fixtures/content/relative-tests/test.md b/src/fixtures/content/relative-tests/test.md new file mode 100644 index 0000000..157023e --- /dev/null +++ b/src/fixtures/content/relative-tests/test.md @@ -0,0 +1,3 @@ +## Test + +Test [link](./other-markdown.md) diff --git a/src/fixtures/content/relative-tests/test2.md b/src/fixtures/content/relative-tests/test2.md new file mode 100644 index 0000000..157023e --- /dev/null +++ b/src/fixtures/content/relative-tests/test2.md @@ -0,0 +1,3 @@ +## Test + +Test [link](./other-markdown.md) diff --git a/src/index.mjs b/src/index.mjs index 654356c..d59e47f 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -128,7 +128,10 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { // if we have a custom slug, use it, else use the default const resolvedSlug = resolveSlug(generatedSlug, frontmatterSlug); // determine the collection base based on specified options - const resolvedCollectionBase = resolveCollectionBase(collectionOptions); + const resolvedCollectionBase = resolveCollectionBase(collectionOptions, { + currentFile, + collectionDir, + }); // content collection slugs are relative to content collection root (or site root if collectionPathMode is `root`) // so build url including the content collection name (if applicable) and the pages slug diff --git a/src/index.test.mjs b/src/index.test.mjs index 6d1ad82..1655fbc 100644 --- a/src/index.test.mjs +++ b/src/index.test.mjs @@ -31,10 +31,13 @@ import astroRehypeRelativeMarkdownLinks from "./index.mjs"; - https://github.com/nodejs/node/issues/51164#issuecomment-2034518078 */ +/** @param {Record { - visit(tree, "element", (node) => { - const fileInHistory = path.resolve(__dirname, __filename); + visit(tree, "element", () => { + const fileInHistory = options.currentFilePath + ? path.resolve(options.currentFilePath) + : path.resolve(__dirname, __filename); if (!file.history.includes(fileInHistory)) { file.history.push(fileInHistory); @@ -558,6 +561,265 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); }); + describe("collections:base:collectionRelative", () => { + test("should contain relative path in same directory as cwd when top-level collectionBase is name and collection level is collectionRelative", async () => { + const input = + 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "collectionRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path in same directory when top-level collectionBase is name and collection level is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/relative-tests/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "collectionRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path in same directory when top-level collectionBase is collectionRelative and no collection level override", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/relative-tests/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "collectionRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path down when top-level collectionBase is name and collection level is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/relative-tests/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "collectionRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path up and over when top-level collectionBase is name and collection level is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/test-me-out.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "collectionRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path up, over and down when top-level collectionBase is name and collection level is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/test-me-out.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "collectionRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path up and back down when link is sibling and top-level collectionBase is name and collection level is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "collectionRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path up, down and over when link is first cousin and top-level collectionBase is name and collection level is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test2.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "collectionRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path up, over and down based when current has custom slug matching physical collection directory structure and top-level collectionBase is name and collection level is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test-custom-slug.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "collectionRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path down based when current has custom slug pointing to collection root and top-level collectionBase is name and collection level is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test-custom-slug-is-collection-root.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "collectionRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path up, over and down when custom slug ends with forward slash and collectionBase at collection level is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/test-custom-slug-with-forward-slash.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "collectionRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path up, over and down without base path when base path specified and top-level collectionBase is name and collection level is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-2/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + basePath: "/testBase", + collectionBase: "name", + collections: { + "relative-tests": { base: "collectionRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + }); + describe("collections:name", async () => { test("should contain name from override when name override specified", async () => { const input = 'foo'; diff --git a/src/options.mjs b/src/options.mjs index 14a6378..0883e16 100644 --- a/src/options.mjs +++ b/src/options.mjs @@ -1,6 +1,10 @@ import { z } from "zod"; -const CollectionBase = z.union([z.literal("name"), z.literal(false)]); +const CollectionBase = z.union([ + z.literal("name"), + z.literal("collectionRelative"), + z.literal(false), +]); export const CollectionConfigSchema = z.object({ /** @@ -47,19 +51,31 @@ export const OptionsSchema = z.object({ * @default `"name"` * @description * - * Set how the base segment of the URL path to the referenced markdown file should be derived: - * - `"name"` - Apply the name on disk of the content collection (ex. `./guides/my-guide.md` referenced from `./resources/my-reference.md` in the content collection `docs` would resolve to the path `/docs/guides/my-guide`) - * - `false` - Do not apply a base (ex. `./guides/my-guide.md` referenced from `./resources/my-reference.md` in the content collection `docs` would resolve to the path `/guides/my-guide`) + * Set how the URL path to the referenced markdown file should be derived: + * - `"name"` - An absolute path prefixed with the optional {@link basePath} followed by the collection name + * - `false` - An absolute path prefixed with the optional {@link basePath} + * - `"collectionRelative"` - A relative path from the collection directory * - * Use `false` when you are treating your content collection as if it were located in the site root (ex: `src/pages`). In most scenarios, you should set this value to `"name"` or not - * set this value and the default of `"name"` will be used. + * For example, given a file `./guides/section/my-guide.md` referenced from `./guides/section/my-other-guide.md` with + * the link `[My Guide](./my-guide.md)` in the content collection `docs`, the transformed url would be: + * - `"name"`: `[/basePath]/docs/guides/section/my-guide` + * - `false`: `[/basePath]/guides/section/my-guide` + * - `"collectionRelative"`: `../../guides/section/my-guide` * - * Note that this is a top-level option and will apply to all content collections. If you have multiple collections and only want one of them to be treated as the site root, you should set this value to `"name"` (or leave the default) - * and use the {@link collections} option to control the behavior for the specific content collection. + * Use `false` or `"collectionRelative"` when you are treating your content collection as if it were located in the site + * root (ex: `src/content/docs/test.md` resolves to the page path `/test` instead of the typical `/docs/test`). + * + * Use `"collectionRelative"` when you are serving your content collection pages from multiple page path roots that use a + * common content collection (ex: `/my-blog/test` and `/your-blog/test` both point to the file `./src/content/posts/test.md` + * in the content collection `posts`). + * + * Note that this is a top-level option and will apply to all content collections. If you have multiple content collections + * and want the behavior to be different on a per content collection basis, add the collection(s) to the {@link collections} + * option and provide a value for collection specific {@link CollectionConfig base} option. * @example * ```js * { - * // Do not apply a base segment to the transformed URL path + * // Do not apply a collection name segment to the generated absolute URL path * collectionBase: false * } * ``` @@ -71,13 +87,13 @@ export const OptionsSchema = z.object({ * @default `{}` * @description * - * Specify a mapping of collections where the key is the name of a collection on disk and the value is an object of collection specific configuration which will override any top-level - * configuration where applicable. + * Specify a mapping of collections where the `key` is the name of a collection on disk and the value is a {@link CollectionConfig} + * which will override any top-level configuration where applicable. * * @example * ```js * { - * // Do not apply a base segment to the transformed URL for the collection `docs` + * // Do not apply a collection name segment to the generated absolute URL path for the collection `docs` * collections: { * docs: { * base: false @@ -94,11 +110,11 @@ export const OptionsSchema = z.object({ * @description * The base path to deploy to. Astro will use this path as the root for your pages and assets both in development and in production build. * - * In the example below, `astro dev` will start your server at `/docs`. + * In the example below, `astro dev` will start your server at `/my-site`. * * ```js * { - * base: '/docs' + * base: '/my-site' * } * ``` */ diff --git a/src/options.test.mjs b/src/options.test.mjs index e62fca1..5dc5ce7 100644 --- a/src/options.test.mjs +++ b/src/options.test.mjs @@ -78,6 +78,14 @@ describe("validateOptions", () => { expectsValidOption({ collectionBase: false }, "collectionBase", false); }); + test("should be collectionBase collectionRelative when collectionRelative specified", () => { + expectsValidOption( + { collectionBase: "collectionRelative" }, + "collectionBase", + "collectionRelative", + ); + }); + test("should error when collectionBase is a string containing an invalid value", () => { expectsZodError({ collectionBase: "foobar" }, "invalid_union"); }); @@ -138,6 +146,11 @@ describe("validateOptions", () => { expectsValidOption({ collections: expected }, "collections", expected); }); + test("should contain base collectionRelative for collection when base collectionRelative specified", () => { + const expected = { docs: { base: "collectionRelative" } }; + expectsValidOption({ collections: expected }, "collections", expected); + }); + test("should contain multiple collections when multiple collections specified", () => { const expected = { docs: { base: false }, @@ -361,6 +374,38 @@ describe("mergeCollectionOptions", () => { }); assert.equal(actual.collectionBase, false); }); + + test("collectionBase should be collectionRelative when top-level collectionRelative and no collection override", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "collectionRelative", + collections: {}, + }); + assert.equal(actual.collectionBase, "collectionRelative"); + }); + + test("collectionBase should be collectionRelative top-level name and collection override collectionRelative", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "name", + collections: { docs: { base: "collectionRelative" } }, + }); + assert.equal(actual.collectionBase, "collectionRelative"); + }); + + test("collectionBase should be collectionRelative when top-level collectionRelative and collection override collectionRelative", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "collectionRelative", + collections: { docs: { base: "collectionRelative" } }, + }); + assert.equal(actual.collectionBase, "collectionRelative"); + }); + + test("collectionBase should be collectionRelative when top-level collectionRelative and no collection override matches collection name", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "collectionRelative", + collections: { fake: { base: "name" } }, + }); + assert.equal(actual.collectionBase, "collectionRelative"); + }); }); describe("collectionName", () => { diff --git a/src/utils.d.ts b/src/utils.d.ts index 1813bb9..f739b54 100644 --- a/src/utils.d.ts +++ b/src/utils.d.ts @@ -23,8 +23,19 @@ export type NormaliseAstroOutputPath = ( export type Slash = (path: string, sep: string) => string; export type NormalizePath = (path: string) => string; export type ShouldProcessFile = (path: string) => boolean; +export type ProcessingDetails = { + currentFile: string; + collectionDir: string; +}; +export type GetCurrentFileSlugDirPath = ( + processingDetails: ProcessingDetails, +) => string; +export type getRelativePathFromCurrentFileToCollection = ( + processingDetails: ProcessingDetails, +) => string; export type ResolveCollectionBase = ( collectionOptions: EffectiveCollectionOptions, + processingDetails: ProcessingDetails, ) => string; export type MatterData = { slug?: string }; export type GetMatter = (path: string) => MatterData; diff --git a/src/utils.mjs b/src/utils.mjs index b435c2a..e557d4d 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -20,6 +20,57 @@ function normalizePath(npath) { return path.posix.normalize(isWindows ? slash(npath, path.posix.sep) : npath); } +/** @type {import('./utils.d.ts').GetCurrentFileSlugDirPath} */ +function getCurrentFileSlugDirPath(processingDetails) { + /* + To determine "where we are", we use the slug from the current file (if there is one) or we use the physical path on disk of + the current file. Note that if Astro's getStaticPaths is manipulating the slug in a way that is not consistent with the slug + in the file or the structure on disk, relative path resolution may be incorrect. This is no different than any other part + of this plugin since we assume across the board that the page paths are either the path from the slug or the path of the + physical file ondisk, either relative to the collection directory itself. + */ + const { currentFile, collectionDir } = processingDetails; + const { slug: frontmatterSlug } = getMatter(currentFile); + const relativeToCollectionPath = path.relative(collectionDir, currentFile); + + /* + resolveSlug will ensure that any custom slug present is valid or return the file path if no custom slug is present. We don't + generate an actual slug for the file path on disk for a few reasons: + 1. It could modify the number of path segments (e.g., strip periods from relative ('.', '..') segments, etc.) which would cause + relative path resolution to be incorrect + 2. Don't need the actual slug because we're not building a webpath from it, we only need the correct number of path segments so + we can build the proper relative path to the collection directory from where we are + 3. The number of segments in the generated slug and the actual file path would be the same regardless + */ + const resolvedSlug = resolveSlug(relativeToCollectionPath, frontmatterSlug); + + // append the resolved slug to the collecton directory to create a fully qualified path + const resolvedSlugPath = path.join(collectionDir, resolvedSlug); + + // get the directory containing the page - note that the page itself could be a directory in the URL world but in the file + // world its a file and we need to determine how many directories to travel to get to the collection directory + return path.dirname(resolvedSlugPath); +} + +/** + * Build a relative path that takes us from "where we are" to the "collection directory". + * + * For example, if we are in `/src/content/docs/foo/bar/test.md`, "we are at" `/docs/foo/bar/test` + * and the "collection directory" would be `../..`. Similarly, if "we are at" `/docs/foo/test.md`, + * the "collection directory" would be `.`. + * + * @type {import('./utils.d.ts').getRelativePathFromCurrentFileToCollection} + */ +function getRelativePathFromCurrentFileToCollection(processingDetails) { + // "where we are" + const resolvedSlugDirPath = getCurrentFileSlugDirPath(processingDetails); + // determine relative path from current file "directory" to the collection directory + // resolving to current directory ('.') if the page is in the root of the collection directory + return ( + path.relative(resolvedSlugDirPath, processingDetails.collectionDir) || "." + ); +} + /** @type {string} */ export const FILE_PATH_SEPARATOR = path.sep; @@ -98,7 +149,10 @@ export const splitPathFromQueryAndFragment = (url) => { /** @type {import('./utils.d.ts').NormaliseAstroOutputPath} */ export const normaliseAstroOutputPath = (initialPath, collectionOptions) => { const buildPath = () => { - if (!collectionOptions.basePath) { + if ( + !collectionOptions.basePath || + collectionOptions.collectionBase === "collectionRelative" + ) { return initialPath; } @@ -167,10 +221,12 @@ export function shouldProcessFile(npath) { } /** @type {import('./utils.d.ts').ResolveCollectionBase} */ -export function resolveCollectionBase(collectionOptions) { +export function resolveCollectionBase(collectionOptions, processingDetails) { return collectionOptions.collectionBase === false ? "" - : URL_PATH_SEPARATOR + collectionOptions.collectionName; + : collectionOptions.collectionBase === "collectionRelative" + ? getRelativePathFromCurrentFileToCollection(processingDetails) + : URL_PATH_SEPARATOR + collectionOptions.collectionName; } /** @type {Record} */ diff --git a/src/utils.test.mjs b/src/utils.test.mjs index 1618a83..d70563a 100644 --- a/src/utils.test.mjs +++ b/src/utils.test.mjs @@ -1,5 +1,10 @@ import { test, describe } from "node:test"; import assert from "node:assert"; +import esmock from "esmock"; + +/* + NOTE ON ESMOCK USAGE - See details in index.test.mjs +*/ import { isValidRelativeLink, @@ -236,6 +241,15 @@ describe("normaliseAstroOutputPath", () => { assert.equal(actual, "/base/foo-testing-test"); }); + + test("should not prefix base when collectionBase is collectionRelative", () => { + const actual = normaliseAstroOutputPath("/foo-testing-test", { + basePath: "/base", + collectionBase: "collectionRelative", + }); + + assert.equal(actual, "/foo-testing-test"); + }); }); }); @@ -429,4 +443,70 @@ describe("resolveCollectionBase", () => { assert.equal(actual, ""); }); }); + + describe("collectionBase:collectionRelative", () => { + const runRelativeTest = async ( + fileContent, + collectionName, + currentFile, + collectionDir, + expected, + ) => { + const { resolveCollectionBase: resolveCollectionBaseMock } = await esmock( + "./utils.mjs", + { + fs: { readFileSync: () => fileContent }, + }, + ); + + const actual = resolveCollectionBaseMock( + { + collectionBase: "collectionRelative", + collectionName: collectionName, + }, + { currentFile, collectionDir }, + ); + assert.equal(actual, expected); + }; + + test("should return relative path in current dir based on file path when collectionBase is collectionRelative", async (t) => { + await runRelativeTest( + "", + "docs", + "/content/docs/test.md", + "/content/docs", + ".", + ); + }); + + test("should return relative path up two dirs based on file path when collectionBase is collectionRelative", async (t) => { + await runRelativeTest( + "", + "docs", + "/content/docs/foo/bar/test.md", + "/content/docs", + "../..", + ); + }); + + test("should return relative path in current dir based on custom slug when collectionBase is collectionRelative", async (t) => { + await runRelativeTest( + "---\nslug: hello\n---", + "docs", + "/content/docs/foo/bar/test.md", + "/content/docs", + ".", + ); + }); + + test("should return relative path up one dir based on custom slug when collectionBase is collectionRelative", async (t) => { + await runRelativeTest( + "---\nslug: foo/hello\n---", + "docs", + "/content/docs/test.md", + "/content/docs", + "..", + ); + }); + }); }); From 34915aabf259f6eb9e61edef8719eee1c6ada668 Mon Sep 17 00:00:00 2001 From: techfg Date: Wed, 24 Apr 2024 21:21:41 -0700 Subject: [PATCH 17/23] feat: add pathRelative support for transforming urls --- docs/README.md | 2 +- docs/interfaces/CollectionConfig.md | 6 +- docs/interfaces/Options.md | 20 ++- src/index.mjs | 22 +-- src/index.test.mjs | 259 ++++++++++++++++++++++++++++ src/options.mjs | 7 +- src/options.test.mjs | 45 +++++ src/utils.d.ts | 4 + src/utils.mjs | 31 +++- src/utils.test.mjs | 84 +++++++++ 10 files changed, 454 insertions(+), 26 deletions(-) diff --git a/docs/README.md b/docs/README.md index 8a4e150..5482115 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,4 +38,4 @@ Rehype plugin for Astro to add support for transforming relative links in MD and #### Defined in -[src/index.mjs:35](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/index.mjs#L35) +[src/index.mjs:36](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/index.mjs#L36) diff --git a/docs/interfaces/CollectionConfig.md b/docs/interfaces/CollectionConfig.md index 35e9014..df3f412 100644 --- a/docs/interfaces/CollectionConfig.md +++ b/docs/interfaces/CollectionConfig.md @@ -19,7 +19,7 @@ ### base -• `Optional` **base**: ``false`` \| ``"name"`` \| ``"collectionRelative"`` +• `Optional` **base**: ``false`` \| ``"name"`` \| ``"collectionRelative"`` \| ``"pathRelative"`` **`Name`** @@ -35,7 +35,7 @@ z.input.base #### Defined in -[src/options.mjs:16](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L16) +[src/options.mjs:17](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L17) ___ @@ -61,4 +61,4 @@ z.input.name #### Defined in -[src/options.mjs:27](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L27) +[src/options.mjs:28](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L28) diff --git a/docs/interfaces/Options.md b/docs/interfaces/Options.md index 9f7c8f3..260f667 100644 --- a/docs/interfaces/Options.md +++ b/docs/interfaces/Options.md @@ -50,13 +50,13 @@ z.input.basePath #### Defined in -[src/options.mjs:121](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L121) +[src/options.mjs:124](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L124) ___ ### collectionBase -• `Optional` **collectionBase**: ``false`` \| ``"name"`` \| ``"collectionRelative"`` +• `Optional` **collectionBase**: ``false`` \| ``"name"`` \| ``"collectionRelative"`` \| ``"pathRelative"`` **`Name`** @@ -72,17 +72,19 @@ Set how the URL path to the referenced markdown file should be derived: - `"name"` - An absolute path prefixed with the optional [basePath](Options.md#basepath) followed by the collection name - `false` - An absolute path prefixed with the optional [basePath](Options.md#basepath) - `"collectionRelative"` - A relative path from the collection directory + - `"pathRelative"` - A relative path from the current page path For example, given a file `./guides/section/my-guide.md` referenced from `./guides/section/my-other-guide.md` with the link `[My Guide](./my-guide.md)` in the content collection `docs`, the transformed url would be: - `"name"`: `[/basePath]/docs/guides/section/my-guide` - `false`: `[/basePath]/guides/section/my-guide` - `"collectionRelative"`: `../../guides/section/my-guide` + - `"pathRelative"`: `my-guide` -Use `false` or `"collectionRelative"` when you are treating your content collection as if it were located in the site +Use `false`, `"collectionRelative"`, or `"pathRelative"` when you are treating your content collection as if it were located in the site root (ex: `src/content/docs/test.md` resolves to the page path `/test` instead of the typical `/docs/test`). -Use `"collectionRelative"` when you are serving your content collection pages from multiple page path roots that use a +Use `"collectionRelative"` or `"pathRelative"` when you are serving your content collection pages from multiple page path roots that use a common content collection (ex: `/my-blog/test` and `/your-blog/test` both point to the file `./src/content/posts/test.md` in the content collection `posts`). @@ -109,13 +111,13 @@ z.input.collectionBase #### Defined in -[src/options.mjs:84](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L84) +[src/options.mjs:87](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L87) ___ ### collections -• `Optional` **collections**: `Record`\<`string`, \{ `base?`: ``false`` \| ``"name"`` \| ``"collectionRelative"`` ; `name?`: `string` }\> +• `Optional` **collections**: `Record`\<`string`, \{ `base?`: ``false`` \| ``"name"`` \| ``"collectionRelative"`` \| ``"pathRelative"`` ; `name?`: `string` }\> **`Name`** @@ -153,7 +155,7 @@ z.input.collections #### Defined in -[src/options.mjs:106](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L106) +[src/options.mjs:109](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L109) ___ @@ -193,7 +195,7 @@ z.input.srcDir #### Defined in -[src/options.mjs:48](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L48) +[src/options.mjs:49](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L49) ___ @@ -243,4 +245,4 @@ z.input.trailingSlash #### Defined in -[src/options.mjs:148](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L148) +[src/options.mjs:151](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L151) diff --git a/src/index.mjs b/src/index.mjs index d59e47f..a1eb2db 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -15,6 +15,7 @@ import { shouldProcessFile, resolveCollectionBase, getMatter, + getRelativePathFromCurrentFileToDestination, } from "./utils.mjs"; import { validateOptions, mergeCollectionOptions } from "./options.mjs"; @@ -128,19 +129,24 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { // if we have a custom slug, use it, else use the default const resolvedSlug = resolveSlug(generatedSlug, frontmatterSlug); // determine the collection base based on specified options - const resolvedCollectionBase = resolveCollectionBase(collectionOptions, { + /** @type {import('./utils.d.ts').ProcessingDetails} */ + const processingDetails = { currentFile, collectionDir, - }); - + destinationSlug: resolvedSlug, + }; // content collection slugs are relative to content collection root (or site root if collectionPathMode is `root`) // so build url including the content collection name (if applicable) and the pages slug // NOTE - When there is a content collection name being applied, this only handles situations where the physical // directory name of the content collection maps 1:1 to the site page path serviing the content collection // page (see details above) - const resolvedUrl = [resolvedCollectionBase, resolvedSlug].join( - URL_PATH_SEPARATOR, - ); + const resolvedUrl = + collectionOptions.collectionBase === "pathRelative" + ? getRelativePathFromCurrentFileToDestination(processingDetails) + : [ + resolveCollectionBase(collectionOptions, processingDetails), + resolvedSlug, + ].join(URL_PATH_SEPARATOR); // slug of empty string ('') is a special case in Astro for root page (e.g., index.md) of a collection let webPathFinal = applyTrailingSlash( @@ -172,10 +178,6 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { "Resolved Collection Base Option : %s", collectionOptions.collectionBase, ); - debug( - "Resolved Collection Base : %s", - resolvedCollectionBase, - ); debug("TrailingSlashMode : %s", trailingSlashMode); debug("md/mdx AST Current File : %s", currentFile); debug("md/mdx AST Current File Dir : %s", currentFileDirectory); diff --git a/src/index.test.mjs b/src/index.test.mjs index 1655fbc..f7d4662 100644 --- a/src/index.test.mjs +++ b/src/index.test.mjs @@ -820,6 +820,265 @@ describe("astroRehypeRelativeMarkdownLinks", () => { }); }); + describe("collections:base:pathRelative", () => { + test("should contain relative path in same directory as cwd when top-level collectionBase is name and collection level is pathRelative", async () => { + const input = + 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "pathRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path in same directory when top-level collectionBase is name and collection level is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/relative-tests/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "pathRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path in same directory when top-level collectionBase is pathRelative and no collection level override", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/relative-tests/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "pathRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path down when top-level collectionBase is name and collection level is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/relative-tests/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "pathRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path up and over when top-level collectionBase is name and collection level is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/test-me-out.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "pathRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path up, over and down when top-level collectionBase is name and collection level is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/test-me-out.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "pathRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path in same directory when link is sibling and top-level collectionBase is name and collection level is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "pathRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path up and over when link is first cousin and top-level collectionBase is name and collection level is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test2.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "pathRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path up, over and down based when current has custom slug matching physical collection directory structure and top-level collectionBase is name and collection level is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test-custom-slug.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "pathRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path down based when current has custom slug pointing to collection root and top-level collectionBase is name and collection level is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/dir-grandchild-1/test-custom-slug-is-collection-root.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "pathRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path up, over and down when custom slug ends with forward slash and collectionBase at collection level is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-1/test-custom-slug-with-forward-slash.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "name", + collections: { + "relative-tests": { base: "pathRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path up, over and down without base path when base path specified and top-level collectionBase is name and collection level is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/relative-tests/dir-child-2/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + basePath: "/testBase", + collectionBase: "name", + collections: { + "relative-tests": { base: "pathRelative" }, + }, + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + }); + describe("collections:name", async () => { test("should contain name from override when name override specified", async () => { const input = 'foo'; diff --git a/src/options.mjs b/src/options.mjs index 0883e16..1dff7b4 100644 --- a/src/options.mjs +++ b/src/options.mjs @@ -3,6 +3,7 @@ import { z } from "zod"; const CollectionBase = z.union([ z.literal("name"), z.literal("collectionRelative"), + z.literal("pathRelative"), z.literal(false), ]); @@ -55,17 +56,19 @@ export const OptionsSchema = z.object({ * - `"name"` - An absolute path prefixed with the optional {@link basePath} followed by the collection name * - `false` - An absolute path prefixed with the optional {@link basePath} * - `"collectionRelative"` - A relative path from the collection directory + * - `"pathRelative"` - A relative path from the current page path * * For example, given a file `./guides/section/my-guide.md` referenced from `./guides/section/my-other-guide.md` with * the link `[My Guide](./my-guide.md)` in the content collection `docs`, the transformed url would be: * - `"name"`: `[/basePath]/docs/guides/section/my-guide` * - `false`: `[/basePath]/guides/section/my-guide` * - `"collectionRelative"`: `../../guides/section/my-guide` + * - `"pathRelative"`: `my-guide` * - * Use `false` or `"collectionRelative"` when you are treating your content collection as if it were located in the site + * Use `false`, `"collectionRelative"`, or `"pathRelative"` when you are treating your content collection as if it were located in the site * root (ex: `src/content/docs/test.md` resolves to the page path `/test` instead of the typical `/docs/test`). * - * Use `"collectionRelative"` when you are serving your content collection pages from multiple page path roots that use a + * Use `"collectionRelative"` or `"pathRelative"` when you are serving your content collection pages from multiple page path roots that use a * common content collection (ex: `/my-blog/test` and `/your-blog/test` both point to the file `./src/content/posts/test.md` * in the content collection `posts`). * diff --git a/src/options.test.mjs b/src/options.test.mjs index 5dc5ce7..e61ce8c 100644 --- a/src/options.test.mjs +++ b/src/options.test.mjs @@ -86,6 +86,14 @@ describe("validateOptions", () => { ); }); + test("should be collectionBase pathRelative when pathRelative specified", () => { + expectsValidOption( + { collectionBase: "pathRelative" }, + "collectionBase", + "pathRelative", + ); + }); + test("should error when collectionBase is a string containing an invalid value", () => { expectsZodError({ collectionBase: "foobar" }, "invalid_union"); }); @@ -151,6 +159,11 @@ describe("validateOptions", () => { expectsValidOption({ collections: expected }, "collections", expected); }); + test("should contain base pathRelative for collection when base pathRelative specified", () => { + const expected = { docs: { base: "pathRelative" } }; + expectsValidOption({ collections: expected }, "collections", expected); + }); + test("should contain multiple collections when multiple collections specified", () => { const expected = { docs: { base: false }, @@ -406,6 +419,38 @@ describe("mergeCollectionOptions", () => { }); assert.equal(actual.collectionBase, "collectionRelative"); }); + + test("collectionBase should be pathRelative when top-level pathRelative and no collection override", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "pathRelative", + collections: {}, + }); + assert.equal(actual.collectionBase, "pathRelative"); + }); + + test("collectionBase should be pathRelative top-level name and collection override pathRelative", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "name", + collections: { docs: { base: "pathRelative" } }, + }); + assert.equal(actual.collectionBase, "pathRelative"); + }); + + test("collectionBase should be pathRelative when top-level pathRelative and collection override pathRelative", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "pathRelative", + collections: { docs: { base: "pathRelative" } }, + }); + assert.equal(actual.collectionBase, "pathRelative"); + }); + + test("collectionBase should be pathRelative when top-level pathRelative and no collection override matches collection name", () => { + const actual = mergeCollectionOptions("docs", { + collectionBase: "pathRelative", + collections: { fake: { base: "name" } }, + }); + assert.equal(actual.collectionBase, "pathRelative"); + }); }); describe("collectionName", () => { diff --git a/src/utils.d.ts b/src/utils.d.ts index f739b54..1e583da 100644 --- a/src/utils.d.ts +++ b/src/utils.d.ts @@ -26,6 +26,7 @@ export type ShouldProcessFile = (path: string) => boolean; export type ProcessingDetails = { currentFile: string; collectionDir: string; + destinationSlug: string; }; export type GetCurrentFileSlugDirPath = ( processingDetails: ProcessingDetails, @@ -33,6 +34,9 @@ export type GetCurrentFileSlugDirPath = ( export type getRelativePathFromCurrentFileToCollection = ( processingDetails: ProcessingDetails, ) => string; +export type GetRelativePathFromCurrentFileToDestination = ( + processingDetails: ProcessingDetails, +) => string; export type ResolveCollectionBase = ( collectionOptions: EffectiveCollectionOptions, processingDetails: ProcessingDetails, diff --git a/src/utils.mjs b/src/utils.mjs index e557d4d..053b38d 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -64,6 +64,7 @@ function getCurrentFileSlugDirPath(processingDetails) { function getRelativePathFromCurrentFileToCollection(processingDetails) { // "where we are" const resolvedSlugDirPath = getCurrentFileSlugDirPath(processingDetails); + // determine relative path from current file "directory" to the collection directory // resolving to current directory ('.') if the page is in the root of the collection directory return ( @@ -151,7 +152,8 @@ export const normaliseAstroOutputPath = (initialPath, collectionOptions) => { const buildPath = () => { if ( !collectionOptions.basePath || - collectionOptions.collectionBase === "collectionRelative" + collectionOptions.collectionBase === "collectionRelative" || + collectionOptions.collectionBase === "pathRelative" ) { return initialPath; } @@ -229,6 +231,33 @@ export function resolveCollectionBase(collectionOptions, processingDetails) { : URL_PATH_SEPARATOR + collectionOptions.collectionName; } +/** + * Build a relative path that takes us from "where we are" to "where we are going". + * + * For example, if we are in `/src/content/docs/foo/bar/test.md` and going to + * `/src/content/docs/foo/fiddly/guide.md`, "we are at" `/docs/foo/bar/test` + * and "going to" `/docs/foo/fiddly/guide` which would result in `../fiddly/guide`. + * Similarly, if "we are at" `/docs/foo/bar/test.md` and "going to" `/docs/foo/bar/reference.md`, + * the result would be "reference" because they are in the same directory. + * + * @type {import('./utils.d.ts').getRelativePathFromCurrentFileToCollection} + */ +/** @type {import('./utils.d.ts').GetRelativePathFromCurrentFileToDestination} */ +export function getRelativePathFromCurrentFileToDestination(processingDetails) { + // "where we are" + const resolvedSlugDirPath = getCurrentFileSlugDirPath(processingDetails); + + // "where we are going" after applying the collection directory + // because destinationSlug is relative to collection directory + const destinationPath = path.join( + processingDetails.collectionDir, + processingDetails.destinationSlug, + ); + + // determine relative path from the current file "directory" to the destination + return path.relative(resolvedSlugDirPath, destinationPath); +} + /** @type {Record} */ const matterCache = {}; const matterCacheEnabled = process.env.MATTER_CACHE_DISABLE !== "true"; diff --git a/src/utils.test.mjs b/src/utils.test.mjs index d70563a..ef0a2c2 100644 --- a/src/utils.test.mjs +++ b/src/utils.test.mjs @@ -510,3 +510,87 @@ describe("resolveCollectionBase", () => { }); }); }); + +describe("getRelativePathFromCurrentFileToDestination", () => { + const runRelativeTest = async ( + fileContent, + currentFile, + collectionDir, + destinationSlug, + expected, + ) => { + const { + getRelativePathFromCurrentFileToDestination: + getRelativePathFromCurrentFileToDestinationMock, + } = await esmock("./utils.mjs", { + fs: { readFileSync: () => fileContent }, + }); + + const actual = getRelativePathFromCurrentFileToDestinationMock({ + currentFile, + collectionDir, + destinationSlug, + }); + assert.equal(actual, expected); + }; + + test("should return relative path in current dir based on file path when collectionBase is pathRelative", async (t) => { + await runRelativeTest( + "", + "/content/docs/foo/bar/bang/test.md", + "/content/docs", + "foo/bar/bang/test2", + "test2", + ); + }); + + test("should return relative path up two dirs based on file path when collectionBase is pathRelative", async (t) => { + await runRelativeTest( + "", + "/content/docs/foo/bar/bang/test.md", + "/content/docs", + "foo/test2", + "../../test2", + ); + }); + + test("should return relative path down one dir based on file path when collectionBase is pathRelative", async (t) => { + await runRelativeTest( + "", + "/content/docs/foo/bar/test.md", + "/content/docs", + "foo/bar/bang/my-page", + "bang/my-page", + ); + }); + + test("should return relative path up one, over and down one based on file path when collectionBase is pathRelative", async (t) => { + await runRelativeTest( + "", + "/content/docs/foo/bar/bang/test.md", + "/content/docs", + "foo/bar/fiddle/my-page", + "../fiddle/my-page", + ); + }); + + test("should return relative path in current dir based on custom slug when collectionBase is pathRelative", async (t) => { + await runRelativeTest( + "---\nslug: iamroot\n---", + "/content/docs/foo/bar/bang/test.md", + "/content/docs", + "test2", + "test2", + ); + }); + + test("should return relative path up one dir based on custom slug when collectionBase is pathRelative", async (t) => { + await runRelativeTest( + "---\nslug: iamroot/iampage\n---", + "/content/docs/test.md", + "/content/docs", + "test2", + "../test2", + ); + }); +}); From 96296a2e3c49d41daea121bb4090beaff154371c Mon Sep 17 00:00:00 2001 From: techfg Date: Wed, 24 Apr 2024 22:44:39 -0700 Subject: [PATCH 18/23] fix: file paths in tests for windows --- src/utils.test.mjs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/utils.test.mjs b/src/utils.test.mjs index ef0a2c2..a89ce2c 100644 --- a/src/utils.test.mjs +++ b/src/utils.test.mjs @@ -16,6 +16,7 @@ import { resolveSlug, applyTrailingSlash, resolveCollectionBase, + FILE_PATH_SEPARATOR, } from "./utils.mjs"; describe("replaceExt", () => { @@ -485,7 +486,7 @@ describe("resolveCollectionBase", () => { "docs", "/content/docs/foo/bar/test.md", "/content/docs", - "../..", + ["..", ".."].join(FILE_PATH_SEPARATOR), ); }); @@ -550,7 +551,7 @@ describe("getRelativePathFromCurrentFileToDestination", () => { "/content/docs/foo/bar/bang/test.md", "/content/docs", "foo/test2", - "../../test2", + ["..", "..", "test2"].join(FILE_PATH_SEPARATOR), ); }); @@ -560,7 +561,7 @@ describe("getRelativePathFromCurrentFileToDestination", () => { "/content/docs/foo/bar/test.md", "/content/docs", "foo/bar/bang/my-page", - "bang/my-page", + ["bang", "my-page"].join(FILE_PATH_SEPARATOR), ); }); @@ -570,7 +571,7 @@ describe("getRelativePathFromCurrentFileToDestination", () => { "/content/docs/foo/bar/bang/test.md", "/content/docs", "foo/bar/fiddle/my-page", - "../fiddle/my-page", + ["..", "fiddle", "my-page"].join(FILE_PATH_SEPARATOR), ); }); @@ -590,7 +591,7 @@ describe("getRelativePathFromCurrentFileToDestination", () => { "/content/docs/test.md", "/content/docs", "test2", - "../test2", + ["..", "test2"].join(FILE_PATH_SEPARATOR), ); }); }); From 9da9037c14b48fbe5f8c342249598bec55d6ecde Mon Sep 17 00:00:00 2001 From: techfg Date: Thu, 25 Apr 2024 01:29:20 -0700 Subject: [PATCH 19/23] chore: namespace env variable --- package.json | 2 +- src/utils.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index eb75e33..4cda990 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "pre-release": "yarn run changelog && yarn run prettier && yarn run generate-docs", "generate-docs": "typedoc --readme none --gitRevision main --plugin typedoc-plugin-markdown src", "prettier": "prettier ./src/** -w", - "test": "MATTER_CACHE_DISABLE=true node --loader=esmock --test", + "test": "ARRML_MATTER_CACHE_DISABLE=true node --loader=esmock --test", "type-check": "tsc --noEmit --emitDeclarationOnly false" }, "dependencies": { diff --git a/src/utils.mjs b/src/utils.mjs index 053b38d..1c1b3db 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -260,7 +260,7 @@ export function getRelativePathFromCurrentFileToDestination(processingDetails) { /** @type {Record} */ const matterCache = {}; -const matterCacheEnabled = process.env.MATTER_CACHE_DISABLE !== "true"; +const matterCacheEnabled = process.env.ARRML_MATTER_CACHE_DISABLE !== "true"; /** @type {import('./utils.d.ts').GetMatter} */ export function getMatter(npath) { const readMatter = () => { From 70a8f20011b337059c190c806aa09ef799036b83 Mon Sep 17 00:00:00 2001 From: techfg Date: Sat, 27 Apr 2024 22:51:52 -0700 Subject: [PATCH 20/23] fix: test configuration --- src/index.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.test.mjs b/src/index.test.mjs index f7d4662..4aeebd8 100644 --- a/src/index.test.mjs +++ b/src/index.test.mjs @@ -403,7 +403,7 @@ describe("astroRehypeRelativeMarkdownLinks", () => { const { value: actual } = await rehype() .use(testSetupRehype) .use(astroRehypeRelativeMarkdownLinks, { - src: "src/fixtures", + srcDir: "src/fixtures", collectionBase: false, }) .process(input); From 00e7f5fa5e24a8097380814b9f33db304b51f6de Mon Sep 17 00:00:00 2001 From: techfg Date: Sun, 28 Apr 2024 16:15:43 -0700 Subject: [PATCH 21/23] fix: relative paths to index pages --- docs/interfaces/Options.md | 17 +- .../dir-test-custom-slug/subdir/test.md | 3 + .../content/dir-test-custom-slug/test.md | 3 + .../docs/dir-test/dir-test-child/index.md | 1 + src/index.mjs | 27 +- src/index.test.mjs | 334 ++++++++++++++++++ src/options.mjs | 9 +- src/utils.mjs | 20 +- src/utils.test.mjs | 54 ++- 9 files changed, 450 insertions(+), 18 deletions(-) create mode 100644 src/fixtures/content/dir-test-custom-slug/subdir/test.md create mode 100644 src/fixtures/content/dir-test-custom-slug/test.md create mode 100644 src/fixtures/content/docs/dir-test/dir-test-child/index.md diff --git a/docs/interfaces/Options.md b/docs/interfaces/Options.md index 260f667..259c858 100644 --- a/docs/interfaces/Options.md +++ b/docs/interfaces/Options.md @@ -50,7 +50,7 @@ z.input.basePath #### Defined in -[src/options.mjs:124](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L124) +[src/options.mjs:131](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L131) ___ @@ -88,9 +88,16 @@ Use `"collectionRelative"` or `"pathRelative"` when you are serving your content common content collection (ex: `/my-blog/test` and `/your-blog/test` both point to the file `./src/content/posts/test.md` in the content collection `posts`). -Note that this is a top-level option and will apply to all content collections. If you have multiple content collections +Important Notes: +- This is a top-level option and will apply to all content collections. If you have multiple content collections and want the behavior to be different on a per content collection basis, add the collection(s) to the [collections](Options.md#collections) option and provide a value for collection specific [base](CollectionConfig.md) option. +- When using either `"collectionRelative"` or `"pathRelative"`, due to the nature of relative links, you MUST ensure +that any directory paths in your site (e.g., urls to `index` pages), contain a trailing slash. For example, given +`./src/content/docs/index.md`, the url should be `/docs/` and not `/docs` as any link generated on that page by the plugin +for a page inside of `./src/content/docs` directory will not navigate correctly since, in relative terms, `/docs` is +different than `/docs/`. Along this line, it is highly encouraged to apply `trailingSlash="always"` to your Astro site and +this plugin to help avoid relative pathing issues. **`Example`** @@ -111,7 +118,7 @@ z.input.collectionBase #### Defined in -[src/options.mjs:87](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L87) +[src/options.mjs:94](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L94) ___ @@ -155,7 +162,7 @@ z.input.collections #### Defined in -[src/options.mjs:109](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L109) +[src/options.mjs:116](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L116) ___ @@ -245,4 +252,4 @@ z.input.trailingSlash #### Defined in -[src/options.mjs:151](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L151) +[src/options.mjs:158](https://github.com/vernak2539/astro-rehype-relative-markdown-links/blob/main/src/options.mjs#L158) diff --git a/src/fixtures/content/dir-test-custom-slug/subdir/test.md b/src/fixtures/content/dir-test-custom-slug/subdir/test.md new file mode 100644 index 0000000..157023e --- /dev/null +++ b/src/fixtures/content/dir-test-custom-slug/subdir/test.md @@ -0,0 +1,3 @@ +## Test + +Test [link](./other-markdown.md) diff --git a/src/fixtures/content/dir-test-custom-slug/test.md b/src/fixtures/content/dir-test-custom-slug/test.md new file mode 100644 index 0000000..157023e --- /dev/null +++ b/src/fixtures/content/dir-test-custom-slug/test.md @@ -0,0 +1,3 @@ +## Test + +Test [link](./other-markdown.md) diff --git a/src/fixtures/content/docs/dir-test/dir-test-child/index.md b/src/fixtures/content/docs/dir-test/dir-test-child/index.md new file mode 100644 index 0000000..bf0297b --- /dev/null +++ b/src/fixtures/content/docs/dir-test/dir-test-child/index.md @@ -0,0 +1 @@ +## Test diff --git a/src/index.mjs b/src/index.mjs index a1eb2db..2681e43 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -12,6 +12,8 @@ import { applyTrailingSlash, URL_PATH_SEPARATOR, FILE_PATH_SEPARATOR, + PATH_SEGMENT_EMPTY, + PATH_SEGMENT_INDEX_REGEX, shouldProcessFile, resolveCollectionBase, getMatter, @@ -23,8 +25,6 @@ import { validateOptions, mergeCollectionOptions } from "./options.mjs"; const debug = debugFn("astro-rehype-relative-markdown-links"); -const PATH_SEGMENT_EMPTY = ""; - /** @typedef {import('./options.d.ts').Options} Options */ /** @typedef {import('./options.d.ts').CollectionConfig} CollectionConfig */ /** @@ -135,6 +135,24 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { collectionDir, destinationSlug: resolvedSlug, }; + // slug of empty string ('') is a special case in Astro for root page (e.g., index.md) of a collection + const frontMatterSlugIsCollectionRootIndex = + frontmatterSlug === PATH_SEGMENT_EMPTY; + const isIndexPage = + frontMatterSlugIsCollectionRootIndex || + (typeof frontmatterSlug !== "string" && + withoutFileExt.match(PATH_SEGMENT_INDEX_REGEX)); + const resolvedUrlIsRelative = + collectionOptions.collectionBase === "pathRelative" || + collectionOptions.collectionBase === "collectionRelative"; + // When root index of collection or any index page when resolving to relative path, + // by default, force a trailing slash to end of resolved url + const originalUrlOverride = + frontMatterSlugIsCollectionRootIndex || + (isIndexPage && resolvedUrlIsRelative) + ? URL_PATH_SEPARATOR + : undefined; + // content collection slugs are relative to content collection root (or site root if collectionPathMode is `root`) // so build url including the content collection name (if applicable) and the pages slug // NOTE - When there is a content collection name being applied, this only handles situations where the physical @@ -148,11 +166,8 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { resolvedSlug, ].join(URL_PATH_SEPARATOR); - // slug of empty string ('') is a special case in Astro for root page (e.g., index.md) of a collection let webPathFinal = applyTrailingSlash( - (frontmatterSlug === PATH_SEGMENT_EMPTY - ? URL_PATH_SEPARATOR - : frontmatterSlug) || urlPathPart, + originalUrlOverride || frontmatterSlug || urlPathPart, resolvedUrl, trailingSlashMode, ); diff --git a/src/index.test.mjs b/src/index.test.mjs index 4aeebd8..5faac43 100644 --- a/src/index.test.mjs +++ b/src/index.test.mjs @@ -818,6 +818,173 @@ describe("astroRehypeRelativeMarkdownLinks", () => { assert.equal(actual, expected); }); + + test("should contain relative path and index for root collection index.md when collectionBase is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/docs/index.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "collectionRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for root collection index.md with empty string custom slug when linked from self and collectionBase is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/dir-test-custom-slug/index.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "collectionRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for root collection index.md with empty string custom slug when linked from different page in collection root when collectionBase is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/dir-test-custom-slug/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "collectionRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for root collection index.md with empty string custom slug when linked from collection child directory and collectionBase is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/dir-test-custom-slug/subdir/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "collectionRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path for root collection index.md paths with non-empty string custom slug when collectionBase is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/dir-test-custom-slug-2/index.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "collectionRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for collection child directory index.md paths when linked from same directory and collectionBase is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/docs/dir-test/index.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "collectionRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for collection child directory index.md when linked from parent directory and collectionBase is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/docs/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "collectionRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for collection grandchild directory index.md paths when linked from same directory and collectionBase is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/docs/dir-test/dir-test-child/index.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "collectionRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for collection grandchild directory index.md when linked from parent directory and collectionBase is collectionRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/docs/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "collectionRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); }); describe("collections:base:pathRelative", () => { @@ -1077,6 +1244,173 @@ describe("astroRehypeRelativeMarkdownLinks", () => { assert.equal(actual, expected); }); + + test("should contain relative path and index for root collection index.md when collectionBase is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/docs/index.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "pathRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for root collection index.md with empty string custom slug when linked from self and collectionBase is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/dir-test-custom-slug/index.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "pathRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for root collection index.md with empty string custom slug when linked from different page in collection root when collectionBase is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/dir-test-custom-slug/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "pathRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for root collection index.md with empty string custom slug when linked from collection child directory and collectionBase is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/dir-test-custom-slug/subdir/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "pathRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path for root collection index.md paths with non-empty string custom slug when collectionBase is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/dir-test-custom-slug-2/index.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "pathRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for collection child directory index.md paths when linked from same directory and collectionBase is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/docs/dir-test/index.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "pathRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for collection child directory index.md when linked from parent directory and collectionBase is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/docs/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "pathRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for collection grandchild directory index.md paths when linked from same directory and collectionBase is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: + "./src/fixtures/content/docs/dir-test/dir-test-child/index.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "pathRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); + + test("should contain relative path ending with forward slash for collection grandchild directory index.md when linked from parent directory and collectionBase is pathRelative", async () => { + const input = 'foo'; + const { value: actual } = await rehype() + .use(testSetupRehype, { + currentFilePath: "./src/fixtures/content/docs/test.md", + }) + .use(astroRehypeRelativeMarkdownLinks, { + srcDir: "src/fixtures", + collectionBase: "pathRelative", + }) + .process(input); + + const expected = + 'foo'; + + assert.equal(actual, expected); + }); }); describe("collections:name", async () => { diff --git a/src/options.mjs b/src/options.mjs index 1dff7b4..86ecf83 100644 --- a/src/options.mjs +++ b/src/options.mjs @@ -72,9 +72,16 @@ export const OptionsSchema = z.object({ * common content collection (ex: `/my-blog/test` and `/your-blog/test` both point to the file `./src/content/posts/test.md` * in the content collection `posts`). * - * Note that this is a top-level option and will apply to all content collections. If you have multiple content collections + * Important Notes: + * - This is a top-level option and will apply to all content collections. If you have multiple content collections * and want the behavior to be different on a per content collection basis, add the collection(s) to the {@link collections} * option and provide a value for collection specific {@link CollectionConfig base} option. + * - When using either `"collectionRelative"` or `"pathRelative"`, due to the nature of relative links, you MUST ensure + * that any directory paths in your site (e.g., urls to `index` pages), contain a trailing slash. For example, given + * `./src/content/docs/index.md`, the url should be `/docs/` and not `/docs` as any link generated on that page by the plugin + * for a page inside of `./src/content/docs` directory will not navigate correctly since, in relative terms, `/docs` is + * different than `/docs/`. Along this line, it is highly encouraged to apply `trailingSlash="always"` to your Astro site and + * this plugin to help avoid relative pathing issues. * @example * ```js * { diff --git a/src/utils.mjs b/src/utils.mjs index 1c1b3db..96f9cb7 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -27,11 +27,16 @@ function getCurrentFileSlugDirPath(processingDetails) { the current file. Note that if Astro's getStaticPaths is manipulating the slug in a way that is not consistent with the slug in the file or the structure on disk, relative path resolution may be incorrect. This is no different than any other part of this plugin since we assume across the board that the page paths are either the path from the slug or the path of the - physical file ondisk, either relative to the collection directory itself. + physical file on disk, either relative to the collection directory itself. */ const { currentFile, collectionDir } = processingDetails; const { slug: frontmatterSlug } = getMatter(currentFile); - const relativeToCollectionPath = path.relative(collectionDir, currentFile); + + // slug of empty string ('') is a special case in Astro for root page (e.g., index.md) of a collection + // so we know the collection directory is the slug directory + if (frontmatterSlug === PATH_SEGMENT_EMPTY) { + return collectionDir; + } /* resolveSlug will ensure that any custom slug present is valid or return the file path if no custom slug is present. We don't @@ -42,6 +47,7 @@ function getCurrentFileSlugDirPath(processingDetails) { we can build the proper relative path to the collection directory from where we are 3. The number of segments in the generated slug and the actual file path would be the same regardless */ + const relativeToCollectionPath = path.relative(collectionDir, currentFile); const resolvedSlug = resolveSlug(relativeToCollectionPath, frontmatterSlug); // append the resolved slug to the collecton directory to create a fully qualified path @@ -78,6 +84,12 @@ export const FILE_PATH_SEPARATOR = path.sep; /** @type {string} */ export const URL_PATH_SEPARATOR = "/"; +/** @type {string } */ +export const PATH_SEGMENT_EMPTY = ""; + +/** @type {RegExp} */ +export const PATH_SEGMENT_INDEX_REGEX = /\/index$/; + /** @type {import('./utils.d.ts').ReplaceExtFn} */ export const replaceExt = (npath, ext) => { if (typeof npath !== "string" || npath.length === 0) { @@ -179,7 +191,7 @@ export const generateSlug = (pathSegments) => { return pathSegments .map((segment) => githubSlug(segment)) .join(URL_PATH_SEPARATOR) - .replace(/\/index$/, ""); + .replace(PATH_SEGMENT_INDEX_REGEX, ""); }; /** @type {import('./utils.d.ts').ResolveSlug} */ @@ -255,7 +267,7 @@ export function getRelativePathFromCurrentFileToDestination(processingDetails) { ); // determine relative path from the current file "directory" to the destination - return path.relative(resolvedSlugDirPath, destinationPath); + return path.relative(resolvedSlugDirPath, destinationPath) || "."; } /** @type {Record} */ diff --git a/src/utils.test.mjs b/src/utils.test.mjs index a89ce2c..b20c548 100644 --- a/src/utils.test.mjs +++ b/src/utils.test.mjs @@ -509,6 +509,16 @@ describe("resolveCollectionBase", () => { "..", ); }); + + test("should return relative path in current dir based on empty custom slug when collectionBase is collectionRelative", async (t) => { + await runRelativeTest( + "---\nslug: ''\n---", + "docs", + "/content/docs/index.md", + "/content/docs", + ".", + ); + }); }); }); @@ -575,7 +585,7 @@ describe("getRelativePathFromCurrentFileToDestination", () => { ); }); - test("should return relative path in current dir based on custom slug when collectionBase is pathRelative", async (t) => { + test("should return relative path in current dir based on custom current file slug when collectionBase is pathRelative", async (t) => { await runRelativeTest( "---\nslug: iamroot\n---", "/content/docs/foo/bar/bang/test.md", @@ -585,7 +595,7 @@ describe("getRelativePathFromCurrentFileToDestination", () => { ); }); - test("should return relative path up one dir based on custom slug when collectionBase is pathRelative", async (t) => { + test("should return relative path up one dir based on custom current file slug when collectionBase is pathRelative", async (t) => { await runRelativeTest( "---\nslug: iamroot/iampage\n---", "/content/docs/test.md", @@ -594,4 +604,44 @@ describe("getRelativePathFromCurrentFileToDestination", () => { ["..", "test2"].join(FILE_PATH_SEPARATOR), ); }); + + test("should return relative path in current dir based on empty current file slug when collectionBase is pathRelative", async (t) => { + await runRelativeTest( + "---\nslug: ''\n---", + "/content/docs/index.md", + "/content/docs", + "test2", + "test2", + ); + }); + + test("should return relative path down one dir based on empty current file slug when collectionBase is pathRelative", async (t) => { + await runRelativeTest( + "---\nslug: ''\n---", + "/content/docs/index.md", + "/content/docs", + "foo/test2", + "foo/test2", + ); + }); + + test("should return relative path in current dir based on empty destination slug when collectionBase is pathRelative", async (t) => { + await runRelativeTest( + "---\nslug: test\n---", + "/content/docs/test.md", + "/content/docs", + "", + ".", + ); + }); + + test("should return relative path in parent dir based on empty destination slug when collectionBase is pathRelative", async (t) => { + await runRelativeTest( + "---\nslug: foo/test\n---", + "/content/docs/foo/test.md", + "/content/docs", + "", + "..", + ); + }); }); From d0d2f01e9a0a4e9db6245a589b840a479ae29fdb Mon Sep 17 00:00:00 2001 From: techfg Date: Sun, 28 Apr 2024 16:28:07 -0700 Subject: [PATCH 22/23] fix: test on windows --- src/utils.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.test.mjs b/src/utils.test.mjs index b20c548..5d0b0dc 100644 --- a/src/utils.test.mjs +++ b/src/utils.test.mjs @@ -621,7 +621,7 @@ describe("getRelativePathFromCurrentFileToDestination", () => { "/content/docs/index.md", "/content/docs", "foo/test2", - "foo/test2", + ["foo", "test2"].join(FILE_PATH_SEPARATOR) ); }); From 1cd555f525705ac3746520560e86ff8219647ed7 Mon Sep 17 00:00:00 2001 From: techfg Date: Sun, 28 Apr 2024 18:19:15 -0700 Subject: [PATCH 23/23] fix: file paths on windows --- src/index.mjs | 4 ++-- src/utils.mjs | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/index.mjs b/src/index.mjs index 2681e43..daf88f8 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -13,7 +13,7 @@ import { URL_PATH_SEPARATOR, FILE_PATH_SEPARATOR, PATH_SEGMENT_EMPTY, - PATH_SEGMENT_INDEX_REGEX, + FILE_PATH_SEGMENT_INDEX_REGEX, shouldProcessFile, resolveCollectionBase, getMatter, @@ -141,7 +141,7 @@ function astroRehypeRelativeMarkdownLinks(opts = {}) { const isIndexPage = frontMatterSlugIsCollectionRootIndex || (typeof frontmatterSlug !== "string" && - withoutFileExt.match(PATH_SEGMENT_INDEX_REGEX)); + FILE_PATH_SEGMENT_INDEX_REGEX.test(withoutFileExt)); const resolvedUrlIsRelative = collectionOptions.collectionBase === "pathRelative" || collectionOptions.collectionBase === "collectionRelative"; diff --git a/src/utils.mjs b/src/utils.mjs index 96f9cb7..42cc6d3 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -88,7 +88,10 @@ export const URL_PATH_SEPARATOR = "/"; export const PATH_SEGMENT_EMPTY = ""; /** @type {RegExp} */ -export const PATH_SEGMENT_INDEX_REGEX = /\/index$/; +export const URL_PATH_SEGMENT_INDEX_REGEX = /\/index$/; + +/** @type {RegExp} */ +export const FILE_PATH_SEGMENT_INDEX_REGEX = /[\\/]index$/; /** @type {import('./utils.d.ts').ReplaceExtFn} */ export const replaceExt = (npath, ext) => { @@ -191,7 +194,7 @@ export const generateSlug = (pathSegments) => { return pathSegments .map((segment) => githubSlug(segment)) .join(URL_PATH_SEPARATOR) - .replace(PATH_SEGMENT_INDEX_REGEX, ""); + .replace(URL_PATH_SEGMENT_INDEX_REGEX, ""); }; /** @type {import('./utils.d.ts').ResolveSlug} */