Add shared auto index folder component
This commit is contained in:
parent
2be83d5199
commit
9d138616b1
3 changed files with 258 additions and 0 deletions
20
README.md
20
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.
|
||||
|
|
|
|||
236
quartz/components/AutoIndexFolder.tsx
Normal file
236
quartz/components/AutoIndexFolder.tsx
Normal file
|
|
@ -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<AutoIndexOptions> = {
|
||||
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<AutoIndexOptions> {
|
||||
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<string, AutoIndexSidebarLink[]>()
|
||||
|
||||
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 (
|
||||
<div class={`popover-hint ${options.classPrefix}-index`}>
|
||||
{intro && (
|
||||
<section class={`${options.classPrefix}-index-intro`}>
|
||||
<p>{intro}</p>
|
||||
</section>
|
||||
)}
|
||||
<div class={`${options.classPrefix}-card-list`}>
|
||||
{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 (
|
||||
<article class={`${options.classPrefix}-card`}>
|
||||
<a href={href} class={`${options.classPrefix}-card-link`}>
|
||||
<div class={`${options.classPrefix}-card-head`}>
|
||||
<h2>{title}</h2>
|
||||
<p class={`${options.classPrefix}-card-meta`}>
|
||||
<span class={`${options.classPrefix}-card-author`}>{author}</span>
|
||||
{date && (
|
||||
<>
|
||||
<span class={`${options.classPrefix}-card-meta-separator`}>/</span>
|
||||
<span class={`${options.classPrefix}-card-date`}>
|
||||
{formatPageDate(date)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<p class={`${options.classPrefix}-card-description`}>
|
||||
{getPageDescription(page, options.emptyDescription)}
|
||||
</p>
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return AutoIndexFolder
|
||||
}) satisfies QuartzComponentConstructor<AutoIndexOptions>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue