From 539611a05f9a6e71b1520293be6d004c3697955d Mon Sep 17 00:00:00 2001 From: Anton Bulakh Date: Tue, 24 Dec 2024 02:03:09 +0200 Subject: [PATCH] feat: PageProperties component to show frontmatter similar to Obsidian --- quartz/components/PageProperties.tsx | 165 +++++++++++++++++++ quartz/components/index.ts | 2 + quartz/components/styles/pageProperties.scss | 42 +++++ quartz/plugins/transformers/frontmatter.ts | 3 + quartz/plugins/transformers/links.ts | 2 +- 5 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 quartz/components/PageProperties.tsx create mode 100644 quartz/components/styles/pageProperties.scss diff --git a/quartz/components/PageProperties.tsx b/quartz/components/PageProperties.tsx new file mode 100644 index 000000000..523e48437 --- /dev/null +++ b/quartz/components/PageProperties.tsx @@ -0,0 +1,165 @@ +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" +import { classNames } from "../util/lang" +import { ComponentChildren, ComponentType } from "preact" +import { externalLinkRegex, wikilinkRegex } from "../plugins/transformers/ofm" +import style from "./styles/pageProperties.scss" +import { pathToRoot, simplifySlug, slugTag, transformLink } from "../util/path" +import { getFullInternalLink } from "../plugins/transformers/links" + +export type FieldComponent = ComponentType< + QuartzComponentProps & { fieldName: string; fieldValue: any } +> + +interface PagePropertiesOptions { + fieldComponents: { + [name: string]: FieldComponent + } + defaultFieldComponent: FieldComponent +} + +// this is ugly, we kind of ripped out what quartz does from ofm.ts +// at least in my earlier commit we separated out the `getFullInternalLink` part +// also hardcoded "shortest" transform strategy +function renderInternalLink( + value: string, + rawFp: string, + rawHeader: string | undefined, + rawAlias: string | undefined, + props: QuartzComponentProps, +): ComponentChildren { + const fp = rawFp?.trim() ?? "" + const anchor = rawHeader?.trim() ?? "" + const alias = rawAlias?.slice(1).trim() + + const url = fp + anchor + const text = alias ?? fp + + const href = transformLink(props.fileData.slug!, url, { + strategy: "shortest", + allSlugs: props.ctx.allSlugs, + }) + const full = getFullInternalLink(href, simplifySlug(props.fileData.slug!)) + + return ( + + {text} + + ) +} + +export const DefaultFieldComponent: FieldComponent = (props) => { + const { fieldValue } = props + if (fieldValue === null) { + return "null" + } + if (fieldValue === undefined) { + return null + } + if (typeof fieldValue === "string") { + if (fieldValue.match(externalLinkRegex)) { + return ( + + {fieldValue} + + ) + } + const [match] = [...fieldValue.matchAll(wikilinkRegex)] + if (match && match[0] === fieldValue && !fieldValue.startsWith("!")) { + return renderInternalLink(fieldValue, match[1], match[2], match[3], props) + } + return fieldValue + } + if (Array.isArray(fieldValue)) { + if (fieldValue.length === 0) { + return null + } + return ( + + ) + } + if (typeof fieldValue === "object") { + return {JSON.stringify(fieldValue, null, 2)} + } + return fieldValue.toString?.() ?? null +} + +export const TagFieldComponent: FieldComponent = ({ fieldValue, fileData }) => { + const tags = Array.isArray(fieldValue) ? fieldValue : [fieldValue] + const baseDir = pathToRoot(fileData.slug!) + return ( + + ) +} + +// A sentinel value that hides a field +export const HIDE = () => null + +const defaultOptions: PagePropertiesOptions = { + fieldComponents: { + title: HIDE, + date: HIDE, + cssclasses: HIDE, + tags: TagFieldComponent, + ["hide-props"]: HIDE, + }, + defaultFieldComponent: DefaultFieldComponent, +} + +export default ((opts?: Partial) => { + const fieldComponents = { ...defaultOptions.fieldComponents, ...opts?.fieldComponents } + const DefaultFieldComponent = opts?.defaultFieldComponent ?? defaultOptions.defaultFieldComponent + + const PageProperties: QuartzComponent = (props: QuartzComponentProps) => { + if (!props.fileData.frontmatterRaw) { + return null + } + + const hideRaw = props.fileData.frontmatter?.["hide-props"] ?? [] + const hide = Array.isArray(hideRaw) ? hideRaw : [hideRaw] + + const entries = Object.entries(props.fileData.frontmatterRaw).map(([name, value]) => { + // allow hiding through frontmatter + if (hide.includes(name)) { + return null + } + const FieldComponent = fieldComponents[name] ?? DefaultFieldComponent + // allow hiding through config + if (FieldComponent === HIDE) { + return null + } + return ( + <> +
{name}
+
+ +
+ + ) + }) + + // avoid the padding if there's no frontmatter + return entries.length !== 0 ? ( +
{entries}
+ ) : null + } + + PageProperties.css = style + + return PageProperties +}) satisfies QuartzComponentConstructor diff --git a/quartz/components/index.ts b/quartz/components/index.ts index 5b197941c..d64fc61ce 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -11,6 +11,7 @@ import Spacer from "./Spacer" import TableOfContents from "./TableOfContents" import Explorer from "./Explorer" import TagList from "./TagList" +import PageProperties from "./PageProperties" import Graph from "./Graph" import Backlinks from "./Backlinks" import Search from "./Search" @@ -34,6 +35,7 @@ export { TableOfContents, Explorer, TagList, + PageProperties, Graph, Backlinks, Search, diff --git a/quartz/components/styles/pageProperties.scss b/quartz/components/styles/pageProperties.scss new file mode 100644 index 000000000..820e03300 --- /dev/null +++ b/quartz/components/styles/pageProperties.scss @@ -0,0 +1,42 @@ +.page-props { + display: grid; + gap: 4px 16px; + grid-template-columns: max-content; + color: var(--darkgray); + + > dt { + font-weight: bold; + font-family: mono; + font-size: 0.8em; + align-self: center; + } + + > dd { + margin: 0; + grid-column-start: 2; + } + + .internal { + padding: 0 0.25rem; + } + + .property-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.2rem; + + > li { + line-height: initial; + display: flex; + } + + > li:not(:last-child) { + &::after { + content: ","; + } + } + } +} diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index e00c700e0..63fdf56df 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -57,6 +57,8 @@ export const FrontMatter: QuartzTransformerPlugin> = (userOpts) }, }) + ;(file.data as any).frontmatterRaw = structuredClone(data) + if (data.title != null && data.title.toString() !== "") { data.title = data.title.toString() } else { @@ -115,5 +117,6 @@ declare module "vfile" { socialImage: string comments: boolean | string }> + readonly frontmatterRaw: { readonly [key: string]: Readonly } } } diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 88d1b93d8..28f975457 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -35,7 +35,7 @@ const defaultOptions: Options = { indexFrontmatterWikilinks: false, } -function getFullInternalLink(dest: RelativeURL, fileSlug: SimpleSlug): FullSlug { +export function getFullInternalLink(dest: RelativeURL, fileSlug: SimpleSlug): FullSlug { // url.resolve is considered legacy // WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to const url = new URL(dest, "https://base.com/" + stripSlashes(fileSlug, true))