feat: exclude unlisted pages from tag discovery and listings

Respect the file.data.unlisted convention in both the tag discovery loop
(virtual tag page generation in pageType.generate) and the tag listing
itself (allPagesWithTag and the root-index tag set builder in TagContent).

Unlisted pages no longer contribute tags, and no longer appear in any
tag listing. A tag that exists only on unlisted pages will not cause a
virtual tag page to be generated at all.

Adds 4 unit tests for pageType.generate covering tag discovery from
listed pages, unlisted exclusion, shared tags across listed and unlisted
pages, and tag-only-on-unlisted-pages suppression.
This commit is contained in:
saberzero1 2026-04-11 13:51:04 +02:00
parent 13036ce028
commit 21bd3be046
No known key found for this signature in database
8 changed files with 107 additions and 11 deletions

View file

@ -9,4 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Respect the `file.data.unlisted` convention in tag discovery (virtual tag page generation) and tag listings (`TagContent.tsx`). Unlisted pages are never used to discover tags and never appear in any tag listing. A tag that exists only on unlisted pages will not cause a tag page to be generated.
- Unit tests for tag discovery covering the unlisted-filter behavior.
- Initial Quartz community plugin template.

View file

@ -2761,6 +2761,9 @@ function concatenateResources(...resources) {
const result = resources.filter((r) => r !== void 0).flat();
return result.length === 0 ? void 0 : result;
}
function isListed(file) {
return file.unlisted !== true;
}
var TagContent_default = ((opts) => {
const options = { ...defaultOptions, ...opts };
const TagContent = (props) => {
@ -2772,7 +2775,7 @@ var TagContent_default = ((opts) => {
throw new Error(`Component "TagContent" tried to render a non-tag page: ${slug2}`);
}
const tag = simplifySlug(slug2.slice("tags/".length));
const allPagesWithTag = (t) => allFiles.filter(
const allPagesWithTag = (t) => allFiles.filter(isListed).filter(
(file) => (file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(t)
);
const hastRoot = tree;
@ -2782,7 +2785,7 @@ var TagContent_default = ((opts) => {
if (tag === "/") {
const tags = [
...new Set(
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes)
allFiles.filter(isListed).flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes)
)
].sort((a, b) => a.localeCompare(b));
const tagItemMap = /* @__PURE__ */ new Map();

File diff suppressed because one or more lines are too long

9
dist/index.js vendored
View file

@ -2745,6 +2745,9 @@ function concatenateResources(...resources) {
const result = resources.filter((r) => r !== void 0).flat();
return result.length === 0 ? void 0 : result;
}
function isListed(file) {
return file.unlisted !== true;
}
var TagContent_default = ((opts) => {
const options = { ...defaultOptions, ...opts };
const TagContent = (props) => {
@ -2756,7 +2759,7 @@ var TagContent_default = ((opts) => {
throw new Error(`Component "TagContent" tried to render a non-tag page: ${slug2}`);
}
const tag = simplifySlug(slug2.slice("tags/".length));
const allPagesWithTag = (t) => allFiles.filter(
const allPagesWithTag = (t) => allFiles.filter(isListed).filter(
(file) => (file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(t)
);
const hastRoot = tree;
@ -2766,7 +2769,7 @@ var TagContent_default = ((opts) => {
if (tag === "/") {
const tags = [
...new Set(
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes)
allFiles.filter(isListed).flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes)
)
].sort((a, b) => a.localeCompare(b));
const tagItemMap = /* @__PURE__ */ new Map();
@ -2844,7 +2847,7 @@ var TagPage = (opts) => ({
priority: 10,
match: tagMatcher,
generate({ content, cfg }) {
const allFiles = content.map((c) => c[1].data);
const allFiles = content.map((c) => c[1].data).filter((d) => d?.unlisted !== true);
const locale = cfg?.locale ?? "en-US";
const tags = new Set(
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes)

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

View file

@ -32,12 +32,17 @@ function concatenateResources(
interface PageFileData {
slug?: string;
filePath?: string;
unlisted?: boolean;
frontmatter?: { title?: string; tags?: string[]; cssclasses?: string[] };
description?: unknown;
htmlAst?: Root;
[key: string]: unknown;
}
function isListed(file: PageFileData): boolean {
return file.unlisted !== true;
}
export default ((opts?: Partial<TagContentOptions>) => {
const options: TagContentOptions = { ...defaultOptions, ...opts };
@ -53,9 +58,11 @@ export default ((opts?: Partial<TagContentOptions>) => {
const tag = simplifySlug(slug.slice("tags/".length) as FullSlug);
const allPagesWithTag = (t: string) =>
(allFiles as PageFileData[]).filter((file) =>
(file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(t),
);
(allFiles as PageFileData[])
.filter(isListed)
.filter((file) =>
(file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(t),
);
const hastRoot = tree as Root;
const content = hastRoot.children.length === 0 ? fd.description : htmlToJsx(hastRoot);
@ -67,6 +74,7 @@ export default ((opts?: Partial<TagContentOptions>) => {
const tags = [
...new Set(
(allFiles as PageFileData[])
.filter(isListed)
.flatMap((data) => data.frontmatter?.tags ?? [])
.flatMap(getAllSegmentPrefixes),
),

View file

@ -25,7 +25,9 @@ export const TagPage: QuartzPageTypePlugin<TagPageOptions> = (opts) => ({
priority: 10,
match: tagMatcher,
generate({ content, cfg }) {
const allFiles = content.map((c) => c[1].data);
const allFiles = content
.map((c) => c[1].data)
.filter((d) => (d as { unlisted?: unknown } | undefined)?.unlisted !== true);
const locale = (cfg as { locale?: string } | undefined)?.locale ?? "en-US";
const tags: Set<string> = new Set(

77
test/pageType.test.ts Normal file
View file

@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import type { ProcessedContent } from "@quartz-community/types";
import { TagPage } from "../src/pageType";
function makeProcessedContent(
slug: string,
opts: { tags?: string[]; unlisted?: boolean } = {},
): ProcessedContent {
const data: Record<string, unknown> = {
slug,
relativePath: `${slug}.md`,
frontmatter: { title: slug, tags: opts.tags ?? [] },
};
if (opts.unlisted) data.unlisted = true;
const vfile = { data } as unknown as ProcessedContent[1];
return [{ type: "root", children: [] }, vfile];
}
function generate(
plugin: ReturnType<typeof TagPage>,
content: ProcessedContent[],
): { slug: string; title: string }[] {
const generateFn = plugin.generate;
if (!generateFn) throw new Error("TagPage.generate is undefined");
const pages = generateFn({
content,
cfg: { locale: "en-US" } as never,
ctx: {} as never,
});
return pages.map((p) => ({ slug: String(p.slug), title: p.title }));
}
describe("TagPage pageType generate", () => {
it("discovers tags from listed pages", () => {
const plugin = TagPage();
const virtualPages = generate(plugin, [
makeProcessedContent("notes/a", { tags: ["public", "alpha"] }),
makeProcessedContent("notes/b", { tags: ["public"] }),
]);
const slugs = virtualPages.map((p) => p.slug);
expect(slugs).toContain("tags/public");
expect(slugs).toContain("tags/alpha");
expect(slugs).toContain("tags/index");
});
it("does NOT discover tags from unlisted pages", () => {
const plugin = TagPage();
const virtualPages = generate(plugin, [
makeProcessedContent("notes/public", { tags: ["visible"] }),
makeProcessedContent("notes/secret", { tags: ["hidden"], unlisted: true }),
]);
const slugs = virtualPages.map((p) => p.slug);
expect(slugs).toContain("tags/visible");
expect(slugs).not.toContain("tags/hidden");
});
it("discovers tag only for listed pages even when the unlisted page shares a tag", () => {
const plugin = TagPage();
const virtualPages = generate(plugin, [
makeProcessedContent("notes/a", { tags: ["shared"] }),
makeProcessedContent("notes/b", { tags: ["shared"], unlisted: true }),
]);
const slugs = virtualPages.map((p) => p.slug);
expect(slugs).toContain("tags/shared");
});
it("ignores a tag that only exists on unlisted pages", () => {
const plugin = TagPage();
const virtualPages = generate(plugin, [
makeProcessedContent("notes/public", { tags: ["listed"] }),
makeProcessedContent("notes/secret", { tags: ["secret-only"], unlisted: true }),
]);
const slugs = virtualPages.map((p) => p.slug);
expect(slugs).toContain("tags/listed");
expect(slugs).not.toContain("tags/secret-only");
});
});