diff --git a/README.md b/README.md index 0f86308..ca4ea90 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,26 @@ Then from a consumer repo, use its launcher script, or call the shared builder d node ..\\GameCult-Quartz\\scripts\\build-site.mjs build --siteRoot E:\\Projects\\gamecult-site --contentDir GameCult --overlayDir site --outputDir quartz-site/public ``` +## Shared Components + +### `AutoIndexFolder` + +Use `Component.AutoIndexFolder(...)` when a consumer site needs a folder landing page to +render a newest-first card index from Markdown files under that folder. The shared engine owns +the repeated mechanics: + +- find pages under `rootSlug` +- exclude the folder `index` page +- honor a site-chosen hide frontmatter flag +- sort by published, created, then modified date +- derive author labels from `author` or `authors` +- render card markup with a site-chosen class prefix +- build year-grouped sidebar data with `buildAutoIndexSidebarData` + +Consumer overlays still own the site-specific decision to use the component, the display copy, +the class prefix, and the CSS. If an index needs custom editorial behavior that only one site +understands, keep that behavior in the site overlay. + ## Design Rule If a change only makes sense for one site, it does not belong in this repo. Put it in that site's overlay and leave the engine sober. diff --git a/quartz/components/AutoIndexFolder.tsx b/quartz/components/AutoIndexFolder.tsx new file mode 100644 index 0000000..774ad35 --- /dev/null +++ b/quartz/components/AutoIndexFolder.tsx @@ -0,0 +1,236 @@ +import { QuartzPluginData } from "../plugins/vfile" +import { FullSlug, resolveRelative } from "../util/path" +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" + +type AutoIndexPage = QuartzPluginData & { + slug: FullSlug +} + +export type AutoIndexSidebarLink = { + label: string + slug: FullSlug +} + +export type AutoIndexSidebarGroup = { + title: string + links: AutoIndexSidebarLink[] +} + +export type AutoIndexSidebarData = { + title: string + slug: FullSlug + tagline?: string + summary?: string + groups: AutoIndexSidebarGroup[] +} + +export interface AutoIndexOptions { + rootSlug: string + indexSlug?: string + classPrefix?: string + hideFrontmatterKey?: string + defaultAuthor?: string + emptyDescription?: string + showDescriptionIntro?: boolean + sidebarTagline?: string + sidebarSummary?: (count: number) => string +} + +const defaultOptions: Required = { + rootSlug: "Blog", + indexSlug: "Blog/index", + classPrefix: "auto-index", + hideFrontmatterKey: "hideFromAutoIndex", + defaultAuthor: "GameCult", + emptyDescription: "This post exists, which is already more than many ideas manage.", + showDescriptionIntro: false, + sidebarTagline: "Recent notes, newest first.", + sidebarSummary: (count) => `${count} entries, newest first.`, +} + +function normalizedOptions(options?: AutoIndexOptions): Required { + const rootSlug = options?.rootSlug?.replace(/\/+$/, "") || defaultOptions.rootSlug + return { + ...defaultOptions, + ...options, + rootSlug, + indexSlug: options?.indexSlug ?? `${rootSlug}/index`, + } +} + +function pageDateValue(file: QuartzPluginData) { + return file.dates?.published ?? file.dates?.created ?? file.dates?.modified +} + +function formatPageDate(date: Date) { + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", + }) +} + +function isHidden(file: QuartzPluginData, hideFrontmatterKey: string) { + return file.frontmatter?.[hideFrontmatterKey] === true +} + +export function getAutoIndexAuthorLabel(file: QuartzPluginData, defaultAuthor = "GameCult") { + const author = file.frontmatter?.author + if (typeof author === "string" && author.trim().length > 0) { + return author.trim() + } + + const authors = file.frontmatter?.authors + if (Array.isArray(authors)) { + const names = authors + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()) + + if (names.length > 0) { + return names.join(", ") + } + } + + return defaultAuthor +} + +function getPageDescription(page: QuartzPluginData, emptyDescription: string) { + const frontmatterDescription = page.frontmatter?.description + if (typeof frontmatterDescription === "string" && frontmatterDescription.trim().length > 0) { + return frontmatterDescription.trim() + } + + const pageDescription = page.description + if (typeof pageDescription === "string" && pageDescription.trim().length > 0) { + return pageDescription.trim() + } + + const socialDeck = page.frontmatter?.socialDeck + if (typeof socialDeck === "string" && socialDeck.trim().length > 0) { + return socialDeck.trim() + } + + return emptyDescription +} + +export function collectAutoIndexPages( + allFiles: QuartzPluginData[], + options?: AutoIndexOptions, +): AutoIndexPage[] { + const resolved = normalizedOptions(options) + const prefix = `${resolved.rootSlug}/` + + return allFiles + .filter( + (file): file is AutoIndexPage => + typeof file.slug === "string" && + file.slug.startsWith(prefix) && + file.slug !== resolved.indexSlug && + !isHidden(file, resolved.hideFrontmatterKey) && + !!file.frontmatter?.title, + ) + .sort((a, b) => { + const aDate = pageDateValue(a) + const bDate = pageDateValue(b) + + if (aDate && bDate) { + return bDate.getTime() - aDate.getTime() + } + + if (aDate && !bDate) { + return -1 + } + + if (!aDate && bDate) { + return 1 + } + + return (a.frontmatter?.title ?? "").localeCompare(b.frontmatter?.title ?? "") + }) +} + +export function buildAutoIndexSidebarData( + file: QuartzPluginData, + allFiles: QuartzPluginData[], + options?: AutoIndexOptions, +): AutoIndexSidebarData { + const resolved = normalizedOptions(options) + const pages = collectAutoIndexPages(allFiles, resolved) + const groupsByYear = new Map() + + for (const page of pages) { + const date = pageDateValue(page) + const year = date ? date.getUTCFullYear().toString() : "Undated" + const existing = groupsByYear.get(year) ?? [] + existing.push({ + label: page.frontmatter?.title ?? page.slug, + slug: page.slug, + }) + groupsByYear.set(year, existing) + } + + return { + title: file.frontmatter?.title ?? "Index", + slug: file.slug as FullSlug, + tagline: resolved.sidebarTagline, + summary: resolved.sidebarSummary(pages.length), + groups: [...groupsByYear.entries()].map(([title, links]) => ({ + title, + links, + })), + } +} + +export default ((opts?: AutoIndexOptions) => { + const options = normalizedOptions(opts) + + const AutoIndexFolder: QuartzComponent = ({ fileData, allFiles }: QuartzComponentProps) => { + const pages = collectAutoIndexPages(allFiles, options) + const intro = options.showDescriptionIntro ? fileData.description : undefined + + return ( +
+ {intro && ( +
+

{intro}

+
+ )} +
+ {pages.map((page) => { + const title = page.frontmatter?.title ?? page.slug ?? "Untitled" + const href = resolveRelative(fileData.slug!, page.slug) + const author = getAutoIndexAuthorLabel(page, options.defaultAuthor) + const date = pageDateValue(page) + + return ( + + ) + })} +
+
+ ) + } + + return AutoIndexFolder +}) satisfies QuartzComponentConstructor diff --git a/quartz/components/index.ts b/quartz/components/index.ts index cece8e6..0b23565 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -23,9 +23,11 @@ import Breadcrumbs from "./Breadcrumbs" import Comments from "./Comments" import Flex from "./Flex" import ConditionalRender from "./ConditionalRender" +import AutoIndexFolder from "./AutoIndexFolder" export { ArticleTitle, + AutoIndexFolder, Content, TagContent, FolderContent,