Initial import
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
const Body: QuartzComponent = ({ children }: QuartzComponentProps) => {
|
||||
return <div id="quartz-body">{children}</div>
|
||||
}
|
||||
|
||||
export default (() => Body) satisfies QuartzComponentConstructor
|
||||
@@ -0,0 +1,22 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
type ConditionalRenderConfig = {
|
||||
component: QuartzComponent
|
||||
condition: (props: QuartzComponentProps) => boolean
|
||||
}
|
||||
|
||||
export default ((config: ConditionalRenderConfig) => {
|
||||
const ConditionalRender: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
if (config.condition(props)) {
|
||||
return <config.component {...props} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
ConditionalRender.afterDOMLoaded = config.component.afterDOMLoaded
|
||||
ConditionalRender.beforeDOMLoaded = config.component.beforeDOMLoaded
|
||||
ConditionalRender.css = config.component.css
|
||||
|
||||
return ConditionalRender
|
||||
}) satisfies QuartzComponentConstructor<ConditionalRenderConfig>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ValidLocale } from "../i18n"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
|
||||
interface Props {
|
||||
date: Date
|
||||
locale?: ValidLocale
|
||||
}
|
||||
|
||||
export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
|
||||
|
||||
export function getDate(data: QuartzPluginData): Date | undefined {
|
||||
if (!data.defaultDateType) {
|
||||
throw new Error(
|
||||
`Field 'defaultDateType' was not set. Ensure the CreatedModifiedDate plugin is configured with a 'defaultDateType' option. See https://quartz.jzhao.xyz/plugins/CreatedModifiedDate for more details.`,
|
||||
)
|
||||
}
|
||||
return data.dates?.[data.defaultDateType]
|
||||
}
|
||||
|
||||
export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
|
||||
return d.toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function Date({ date, locale }: Props) {
|
||||
return <time datetime={date.toISOString()}>{formatDate(date, locale)}</time>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
export default ((component: QuartzComponent) => {
|
||||
const Component = component
|
||||
const DesktopOnly: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
return (
|
||||
<div class="desktop-only">
|
||||
<Component {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
DesktopOnly.displayName = component.displayName
|
||||
DesktopOnly.afterDOMLoaded = component?.afterDOMLoaded
|
||||
DesktopOnly.beforeDOMLoaded = component?.beforeDOMLoaded
|
||||
DesktopOnly.css = component?.css
|
||||
return DesktopOnly
|
||||
}) satisfies QuartzComponentConstructor<QuartzComponent>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { concatenateResources } from "../util/resources"
|
||||
import { classNames } from "../util/lang"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
type FlexConfig = {
|
||||
components: {
|
||||
Component: QuartzComponent
|
||||
grow?: boolean
|
||||
shrink?: boolean
|
||||
basis?: string
|
||||
order?: number
|
||||
align?: "start" | "end" | "center" | "stretch"
|
||||
justify?: "start" | "end" | "center" | "between" | "around"
|
||||
}[]
|
||||
direction?: "row" | "row-reverse" | "column" | "column-reverse"
|
||||
wrap?: "nowrap" | "wrap" | "wrap-reverse"
|
||||
gap?: string
|
||||
}
|
||||
|
||||
export default ((config: FlexConfig) => {
|
||||
const Flex: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
const direction = config.direction ?? "row"
|
||||
const wrap = config.wrap ?? "nowrap"
|
||||
const gap = config.gap ?? "1rem"
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classNames(props.displayClass, "flex-component")}
|
||||
style={`flex-direction: ${direction}; flex-wrap: ${wrap}; gap: ${gap};`}
|
||||
>
|
||||
{config.components.map((c) => {
|
||||
const grow = c.grow ? 1 : 0
|
||||
const shrink = (c.shrink ?? true) ? 1 : 0
|
||||
const basis = c.basis ?? "auto"
|
||||
const order = c.order ?? 0
|
||||
const align = c.align ?? "center"
|
||||
const justify = c.justify ?? "center"
|
||||
|
||||
return (
|
||||
<div
|
||||
style={`flex-grow: ${grow}; flex-shrink: ${shrink}; flex-basis: ${basis}; order: ${order}; align-self: ${align}; justify-self: ${justify};`}
|
||||
>
|
||||
<c.Component {...props} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Flex.afterDOMLoaded = concatenateResources(
|
||||
...config.components.map((c) => c.Component.afterDOMLoaded),
|
||||
)
|
||||
Flex.beforeDOMLoaded = concatenateResources(
|
||||
...config.components.map((c) => c.Component.beforeDOMLoaded),
|
||||
)
|
||||
Flex.css = concatenateResources(...config.components.map((c) => c.Component.css))
|
||||
return Flex
|
||||
}) satisfies QuartzComponentConstructor<FlexConfig>
|
||||
@@ -0,0 +1,114 @@
|
||||
import { i18n } from "../i18n"
|
||||
import { FullSlug, getFileExtension, joinSegments, pathToRoot } from "../util/path"
|
||||
import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
|
||||
import { googleFontHref, googleFontSubsetHref } from "../util/theme"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { unescapeHTML } from "../util/escape"
|
||||
import { CustomOgImagesEmitterName } from "../../.quartz/plugins"
|
||||
export default (() => {
|
||||
const Head: QuartzComponent = ({
|
||||
cfg,
|
||||
fileData,
|
||||
externalResources,
|
||||
ctx,
|
||||
}: QuartzComponentProps) => {
|
||||
const titleSuffix = cfg.pageTitleSuffix ?? ""
|
||||
const title =
|
||||
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
|
||||
const description =
|
||||
fileData.frontmatter?.socialDescription ??
|
||||
fileData.frontmatter?.description ??
|
||||
unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description)
|
||||
|
||||
const { css, js, additionalHead } = externalResources
|
||||
|
||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||
const path = url.pathname as FullSlug
|
||||
const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!)
|
||||
const iconPath = joinSegments(baseDir, "static/icon.png")
|
||||
|
||||
// Url of current page
|
||||
const socialUrl =
|
||||
fileData.slug === "404" ? url.toString() : joinSegments(url.toString(), fileData.slug!)
|
||||
|
||||
const usesCustomOgImage = ctx.cfg.plugins.emitters.some(
|
||||
(e) => e.name === CustomOgImagesEmitterName,
|
||||
)
|
||||
const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png`
|
||||
|
||||
const coreStylesheet = css[0]?.content
|
||||
const coreScript = js.find(
|
||||
(r) => r.loadTime === "beforeDOMReady" && r.contentType === "external",
|
||||
)
|
||||
|
||||
return (
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<meta charSet="utf-8" />
|
||||
{coreStylesheet && <link rel="preload" href={coreStylesheet} as="style" />}
|
||||
{coreScript && coreScript.contentType === "external" && (
|
||||
<link rel="preload" href={coreScript.src} as="script" />
|
||||
)}
|
||||
{cfg.theme.cdnCaching && cfg.theme.fontOrigin === "googleFonts" && (
|
||||
<>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link rel="stylesheet" href={googleFontHref(cfg.theme)} />
|
||||
{cfg.theme.typography.title && (
|
||||
<link rel="stylesheet" href={googleFontSubsetHref(cfg.theme, cfg.pageTitle)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin="anonymous" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<meta name="og:site_name" content={cfg.pageTitle}></meta>
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image:alt" content={description} />
|
||||
|
||||
{!usesCustomOgImage && (
|
||||
<>
|
||||
<meta property="og:image" content={ogImageDefaultPath} />
|
||||
<meta property="og:image:url" content={ogImageDefaultPath} />
|
||||
<meta name="twitter:image" content={ogImageDefaultPath} />
|
||||
<meta
|
||||
property="og:image:type"
|
||||
content={`image/${getFileExtension(ogImageDefaultPath) ?? "png"}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{cfg.baseUrl && (
|
||||
<>
|
||||
<meta property="twitter:domain" content={cfg.baseUrl}></meta>
|
||||
<meta property="og:url" content={socialUrl}></meta>
|
||||
<meta property="twitter:url" content={socialUrl}></meta>
|
||||
</>
|
||||
)}
|
||||
|
||||
<link rel="icon" href={iconPath} />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content="Quartz" />
|
||||
|
||||
{css.map((resource) => CSSResourceToStyleElement(resource, true))}
|
||||
{js
|
||||
.filter((resource) => resource.loadTime === "beforeDOMReady")
|
||||
.map((res) => JSResourceToScriptElement(res, true))}
|
||||
{additionalHead.map((resource) => {
|
||||
if (typeof resource === "function") {
|
||||
return resource(fileData)
|
||||
} else {
|
||||
return resource
|
||||
}
|
||||
})}
|
||||
</head>
|
||||
)
|
||||
}
|
||||
|
||||
return Head
|
||||
}) satisfies QuartzComponentConstructor
|
||||
@@ -0,0 +1,22 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
const Header: QuartzComponent = ({ children }: QuartzComponentProps) => {
|
||||
return children.length > 0 ? <header>{children}</header> : null
|
||||
}
|
||||
|
||||
Header.css = `
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin: 2rem 0;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
flex: auto;
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => Header) satisfies QuartzComponentConstructor
|
||||
@@ -0,0 +1,18 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
export default ((component: QuartzComponent) => {
|
||||
const Component = component
|
||||
const MobileOnly: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
return (
|
||||
<div class="mobile-only">
|
||||
<Component {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
MobileOnly.displayName = component.displayName
|
||||
MobileOnly.afterDOMLoaded = component?.afterDOMLoaded
|
||||
MobileOnly.beforeDOMLoaded = component?.beforeDOMLoaded
|
||||
MobileOnly.css = component?.css
|
||||
return MobileOnly
|
||||
}) satisfies QuartzComponentConstructor<QuartzComponent>
|
||||
@@ -0,0 +1,114 @@
|
||||
import { FullSlug, isFolderPath, resolveRelative } from "../util/path"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { Date, getDate } from "./Date"
|
||||
import { QuartzComponent, QuartzComponentProps } from "./types"
|
||||
|
||||
export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||
|
||||
export function byDateAndAlphabetical(): SortFn {
|
||||
return (f1, f2) => {
|
||||
// Sort by date/alphabetical
|
||||
if (f1.dates && f2.dates) {
|
||||
// sort descending
|
||||
return getDate(f2)!.getTime() - getDate(f1)!.getTime()
|
||||
} else if (f1.dates && !f2.dates) {
|
||||
// prioritize files with dates
|
||||
return -1
|
||||
} else if (!f1.dates && f2.dates) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// otherwise, sort lexographically by title
|
||||
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
|
||||
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
|
||||
return f1Title.localeCompare(f2Title)
|
||||
}
|
||||
}
|
||||
|
||||
export function byDateAndAlphabeticalFolderFirst(): SortFn {
|
||||
return (f1, f2) => {
|
||||
// Sort folders first
|
||||
const f1IsFolder = isFolderPath(f1.slug ?? "")
|
||||
const f2IsFolder = isFolderPath(f2.slug ?? "")
|
||||
if (f1IsFolder && !f2IsFolder) return -1
|
||||
if (!f1IsFolder && f2IsFolder) return 1
|
||||
|
||||
// If both are folders or both are files, sort by date/alphabetical
|
||||
if (f1.dates && f2.dates) {
|
||||
// sort descending
|
||||
return getDate(f2)!.getTime() - getDate(f1)!.getTime()
|
||||
} else if (f1.dates && !f2.dates) {
|
||||
// prioritize files with dates
|
||||
return -1
|
||||
} else if (!f1.dates && f2.dates) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// otherwise, sort lexographically by title
|
||||
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
|
||||
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
|
||||
return f1Title.localeCompare(f2Title)
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
limit?: number
|
||||
sort?: SortFn
|
||||
} & QuartzComponentProps
|
||||
|
||||
export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort }: Props) => {
|
||||
const sorter = sort ?? byDateAndAlphabeticalFolderFirst()
|
||||
let list = allFiles.sort(sorter)
|
||||
if (limit) {
|
||||
list = list.slice(0, limit)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul class="section-ul">
|
||||
{list.map((page) => {
|
||||
const title = page.frontmatter?.title
|
||||
const tags = page.frontmatter?.tags ?? []
|
||||
|
||||
return (
|
||||
<li class="section-li">
|
||||
<div class="section">
|
||||
<p class="meta">{page.dates && <Date date={getDate(page)!} locale={cfg.locale} />}</p>
|
||||
<div class="desc">
|
||||
<h3>
|
||||
<a
|
||||
href={resolveRelative(fileData.slug!, page.slug!)}
|
||||
class="internal internal-link"
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
<ul class="tags">
|
||||
{tags.map((tag) => (
|
||||
<li>
|
||||
<a
|
||||
class="internal tag-link"
|
||||
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
PageList.css = `
|
||||
.section h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section > .tags {
|
||||
margin: 0;
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,8 @@
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
function Spacer({ displayClass }: QuartzComponentProps) {
|
||||
return <div class={classNames(displayClass, "spacer")}></div>
|
||||
}
|
||||
|
||||
export default (() => Spacer) satisfies QuartzComponentConstructor
|
||||
@@ -0,0 +1,23 @@
|
||||
import { componentRegistry } from "./registry"
|
||||
import { QuartzComponent, QuartzComponentConstructor } from "./types"
|
||||
|
||||
export function External<Options extends object | undefined>(
|
||||
name: string,
|
||||
options?: Options,
|
||||
): QuartzComponent {
|
||||
const registered = componentRegistry.get(name)
|
||||
if (!registered) {
|
||||
throw new Error(
|
||||
`External component "${name}" not found. ` +
|
||||
`Make sure the plugin is installed and components are loaded before layouts are evaluated.`,
|
||||
)
|
||||
}
|
||||
|
||||
const { component } = registered
|
||||
|
||||
if (typeof component === "function") {
|
||||
return (component as QuartzComponentConstructor<Options>)(options as Options)
|
||||
}
|
||||
|
||||
return component as QuartzComponent
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { PageFrame, PageFrameProps } from "./types"
|
||||
import HeaderConstructor from "../Header"
|
||||
|
||||
const Header = HeaderConstructor()
|
||||
|
||||
/**
|
||||
* The default page frame — three-column layout with left sidebar, center
|
||||
* content (header + body + afterBody), and right sidebar, followed by a footer.
|
||||
*
|
||||
* This is the original Quartz layout, extracted from renderPage.tsx.
|
||||
*/
|
||||
export const DefaultFrame: PageFrame = {
|
||||
name: "default",
|
||||
render({
|
||||
componentData,
|
||||
header,
|
||||
beforeBody,
|
||||
pageBody: Content,
|
||||
afterBody,
|
||||
left,
|
||||
right,
|
||||
footer: Footer,
|
||||
}: PageFrameProps) {
|
||||
return (
|
||||
<>
|
||||
<div class="left sidebar">
|
||||
{left.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
<div class="center">
|
||||
<div class="page-header">
|
||||
<Header {...componentData}>
|
||||
{header.map((HeaderComponent) => (
|
||||
<HeaderComponent {...componentData} />
|
||||
))}
|
||||
</Header>
|
||||
<div class="popover-hint">
|
||||
{beforeBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Content {...componentData} />
|
||||
<hr />
|
||||
<div class="page-footer">
|
||||
{afterBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="right sidebar">
|
||||
{right.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
<Footer {...componentData} />
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { PageFrame, PageFrameProps } from "./types"
|
||||
import HeaderConstructor from "../Header"
|
||||
|
||||
const Header = HeaderConstructor()
|
||||
|
||||
/**
|
||||
* Full-width page frame — no sidebars. The center content area spans the
|
||||
* full width of the page. Header, beforeBody, body, afterBody, and footer
|
||||
* are all rendered in a single column.
|
||||
*
|
||||
* Useful for page types like Canvas, presentations, or dashboards that
|
||||
* need maximum horizontal space.
|
||||
*/
|
||||
export const FullWidthFrame: PageFrame = {
|
||||
name: "full-width",
|
||||
render({
|
||||
componentData,
|
||||
header,
|
||||
beforeBody,
|
||||
pageBody: Content,
|
||||
afterBody,
|
||||
footer: Footer,
|
||||
}: PageFrameProps) {
|
||||
return (
|
||||
<>
|
||||
<div class="center full-width">
|
||||
<div class="page-header">
|
||||
<Header {...componentData}>
|
||||
{header.map((HeaderComponent) => (
|
||||
<HeaderComponent {...componentData} />
|
||||
))}
|
||||
</Header>
|
||||
<div class="popover-hint">
|
||||
{beforeBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Content {...componentData} />
|
||||
<hr />
|
||||
<div class="page-footer">
|
||||
{afterBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Footer {...componentData} />
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PageFrame, PageFrameProps } from "./types"
|
||||
|
||||
/**
|
||||
* Minimal page frame — no sidebars, no header/footer chrome. Only the
|
||||
* page body is rendered with a thin wrapper, plus the footer for legal/link
|
||||
* obligations.
|
||||
*
|
||||
* Useful for immersive page types like full-screen canvases, kiosks,
|
||||
* or custom landing pages that want complete control of the viewport.
|
||||
*/
|
||||
export const MinimalFrame: PageFrame = {
|
||||
name: "minimal",
|
||||
render({ componentData, pageBody: Content, footer: Footer }: PageFrameProps) {
|
||||
return (
|
||||
<>
|
||||
<div class="center minimal">
|
||||
<Content {...componentData} />
|
||||
</div>
|
||||
<Footer {...componentData} />
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { PageFrame } from "./types"
|
||||
import { DefaultFrame } from "./DefaultFrame"
|
||||
import { FullWidthFrame } from "./FullWidthFrame"
|
||||
import { MinimalFrame } from "./MinimalFrame"
|
||||
import { frameRegistry } from "./registry"
|
||||
|
||||
export type { PageFrame, PageFrameProps } from "./types"
|
||||
export { DefaultFrame } from "./DefaultFrame"
|
||||
export { FullWidthFrame } from "./FullWidthFrame"
|
||||
export { MinimalFrame } from "./MinimalFrame"
|
||||
export { frameRegistry } from "./registry"
|
||||
export type { RegisteredFrame } from "./registry"
|
||||
|
||||
/**
|
||||
* Registry of built-in page frames. Page types can reference these by name
|
||||
* via their `frame` property, and YAML config can override via
|
||||
* `layout.byPageType.<name>.template`.
|
||||
*
|
||||
* The "default" frame reproduces the original three-column Quartz layout.
|
||||
*/
|
||||
const builtinFrames: Record<string, PageFrame> = {
|
||||
default: DefaultFrame,
|
||||
"full-width": FullWidthFrame,
|
||||
minimal: MinimalFrame,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a frame by name. Checks plugin-registered frames first,
|
||||
* then built-in frames, then falls back to DefaultFrame.
|
||||
*/
|
||||
export function resolveFrame(name: string | undefined): PageFrame {
|
||||
if (!name || name === "default") {
|
||||
return DefaultFrame
|
||||
}
|
||||
|
||||
// Check plugin-registered frames first
|
||||
const registered = frameRegistry.get(name)
|
||||
if (registered) {
|
||||
return registered.frame
|
||||
}
|
||||
|
||||
// Fall back to built-in frames
|
||||
const frame = builtinFrames[name]
|
||||
if (!frame) {
|
||||
const allFrameNames = [...Object.keys(builtinFrames), ...[...frameRegistry.getAll().keys()]]
|
||||
console.warn(
|
||||
`Unknown page frame "${name}", falling back to "default". Available frames: ${allFrameNames.join(", ")}`,
|
||||
)
|
||||
return DefaultFrame
|
||||
}
|
||||
return frame
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PageFrame } from "./types"
|
||||
|
||||
export interface RegisteredFrame {
|
||||
frame: PageFrame
|
||||
source: string
|
||||
}
|
||||
|
||||
class FrameRegistry {
|
||||
private frames = new Map<string, RegisteredFrame>()
|
||||
|
||||
register(name: string, frame: PageFrame, source: string): void {
|
||||
const existing = this.frames.get(name)
|
||||
if (existing && existing.source !== source) {
|
||||
console.warn(
|
||||
`Page frame "${name}" from ${source} is overwriting frame from ${existing.source}`,
|
||||
)
|
||||
}
|
||||
this.frames.set(name, { frame, source })
|
||||
}
|
||||
|
||||
get(name: string): RegisteredFrame | undefined {
|
||||
return this.frames.get(name)
|
||||
}
|
||||
|
||||
getAll(): Map<string, RegisteredFrame> {
|
||||
return new Map(this.frames)
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.frames.has(name)
|
||||
}
|
||||
}
|
||||
|
||||
export const frameRegistry = new FrameRegistry()
|
||||
@@ -0,0 +1,43 @@
|
||||
import { JSX } from "preact"
|
||||
import { QuartzComponent, QuartzComponentProps } from "../types"
|
||||
|
||||
/**
|
||||
* Props passed to a PageFrame's render function.
|
||||
* Contains the resolved layout components and the shared component data.
|
||||
*/
|
||||
export interface PageFrameProps {
|
||||
/** Component data shared across all components on the page */
|
||||
componentData: QuartzComponentProps
|
||||
/** The Head component (rendered in <head>) — NOT used by frames, included for completeness */
|
||||
head: QuartzComponent
|
||||
/** Header slot components (rendered inside <header>) */
|
||||
header: QuartzComponent[]
|
||||
/** Components rendered before the page body */
|
||||
beforeBody: QuartzComponent[]
|
||||
/** The page body component (Content) */
|
||||
pageBody: QuartzComponent
|
||||
/** Components rendered after the page body */
|
||||
afterBody: QuartzComponent[]
|
||||
/** Left sidebar components */
|
||||
left: QuartzComponent[]
|
||||
/** Right sidebar components */
|
||||
right: QuartzComponent[]
|
||||
/** Footer component */
|
||||
footer: QuartzComponent
|
||||
}
|
||||
|
||||
/**
|
||||
* A PageFrame defines the inner HTML structure of a page inside the
|
||||
* `<div id="quartz-root">` shell. Different frames can produce completely
|
||||
* different layouts (e.g. with/without sidebars, horizontal scroll, etc.)
|
||||
* while the outer shell (html, head, body, quartz-root) remains stable
|
||||
* for SPA navigation.
|
||||
*/
|
||||
export interface PageFrame {
|
||||
/** Unique name for this frame (e.g. "default", "full-width", "minimal") */
|
||||
name: string
|
||||
/** Render the inner page structure. Returns a JSX tree to be placed inside Body > #quartz-body. */
|
||||
render: (props: PageFrameProps) => JSX.Element
|
||||
/** Optional CSS string to include when this frame is active */
|
||||
css?: string
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import NotFound from "./pages/404"
|
||||
import Head from "./Head"
|
||||
import Spacer from "./Spacer"
|
||||
import DesktopOnly from "./DesktopOnly"
|
||||
import MobileOnly from "./MobileOnly"
|
||||
import Flex from "./Flex"
|
||||
import ConditionalRender from "./ConditionalRender"
|
||||
|
||||
export { componentRegistry, defineComponent } from "./registry"
|
||||
export { External } from "./external"
|
||||
export type { ComponentManifest, RegisteredComponent } from "./registry"
|
||||
export type { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
export { Head, Spacer, DesktopOnly, MobileOnly, NotFound, Flex, ConditionalRender }
|
||||
@@ -0,0 +1,54 @@
|
||||
import { i18n } from "../../i18n"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
const NotFound: QuartzComponent = ({ cfg, ctx }: QuartzComponentProps) => {
|
||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||
const baseDir = ctx.argv.serve ? "/" : url.pathname
|
||||
|
||||
return (
|
||||
<article class="popover-hint">
|
||||
<h1>404</h1>
|
||||
<p>{i18n(cfg.locale).pages.error.notFound}</p>
|
||||
<a href={baseDir}>{i18n(cfg.locale).pages.error.home}</a>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
if (typeof fetchData !== "undefined") {
|
||||
fetchData.then(function(index) {
|
||||
var basePath = document.body.dataset.basepath || "";
|
||||
if (basePath.length > 1 && basePath.endsWith("/")) {
|
||||
basePath = basePath.slice(0, -1);
|
||||
}
|
||||
var pathname = window.location.pathname;
|
||||
var hasBasePrefix = basePath.length > 1 && pathname.startsWith(basePath);
|
||||
if (hasBasePrefix) {
|
||||
pathname = pathname.slice(basePath.length);
|
||||
}
|
||||
if (pathname.startsWith("/")) {
|
||||
pathname = pathname.slice(1);
|
||||
}
|
||||
if (pathname.endsWith("/")) {
|
||||
pathname = pathname.slice(0, -1);
|
||||
}
|
||||
if (pathname.endsWith(".html")) {
|
||||
pathname = pathname.slice(0, -5);
|
||||
}
|
||||
if (pathname.endsWith("/index")) {
|
||||
pathname = pathname.slice(0, -6);
|
||||
}
|
||||
var lowered = pathname.toLowerCase();
|
||||
if (lowered !== pathname && index[lowered] != null) {
|
||||
var prefix = hasBasePrefix ? basePath : "";
|
||||
var target = prefix + (prefix.endsWith("/") ? "" : "/") + lowered;
|
||||
window.location.replace(target);
|
||||
}
|
||||
});
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default (() => NotFound) satisfies QuartzComponentConstructor
|
||||
@@ -0,0 +1,132 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor } from "./types"
|
||||
|
||||
export interface ComponentManifest {
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
version: string
|
||||
quartzVersion?: string
|
||||
author?: string
|
||||
homepage?: string
|
||||
}
|
||||
|
||||
export interface RegisteredComponent {
|
||||
component: QuartzComponent | QuartzComponentConstructor
|
||||
source: string
|
||||
manifest?: ComponentManifest
|
||||
}
|
||||
|
||||
class ComponentRegistry {
|
||||
private components = new Map<string, RegisteredComponent>()
|
||||
private instanceCache = new Map<string, QuartzComponent>()
|
||||
private optionOverrides = new Map<string, Record<string, unknown>>()
|
||||
|
||||
register(
|
||||
name: string,
|
||||
component: QuartzComponent | QuartzComponentConstructor,
|
||||
source: string,
|
||||
manifest?: ComponentManifest,
|
||||
): void {
|
||||
const existing = this.components.get(name)
|
||||
if (existing && existing.source !== source) {
|
||||
console.warn(`Component "${name}" is being overwritten by ${source}`)
|
||||
}
|
||||
this.components.set(name, { component, source, manifest })
|
||||
}
|
||||
|
||||
get(name: string): RegisteredComponent | undefined {
|
||||
return this.components.get(name)
|
||||
}
|
||||
|
||||
getAll(): Map<string, RegisteredComponent> {
|
||||
return new Map(this.components)
|
||||
}
|
||||
|
||||
/** Store option overrides for a plugin, keyed by plugin directory name. */
|
||||
setOptionOverrides(pluginName: string, opts?: Record<string, unknown>): void {
|
||||
if (!opts || Object.keys(opts).length === 0) return
|
||||
this.optionOverrides.set(pluginName, { ...this.optionOverrides.get(pluginName), ...opts })
|
||||
this.instanceCache.clear()
|
||||
}
|
||||
|
||||
getOptionOverrides(pluginName: string): Record<string, unknown> | undefined {
|
||||
return this.optionOverrides.get(pluginName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate a component constructor with options, returning a cached instance
|
||||
* if the same constructor was already called with equivalent options.
|
||||
* This prevents duplicate afterDOMLoaded scripts when the same component
|
||||
* appears in multiple page-type layouts.
|
||||
*/
|
||||
instantiate(
|
||||
constructor: QuartzComponentConstructor<any>,
|
||||
options?: Record<string, unknown>,
|
||||
): QuartzComponent {
|
||||
const optsKey = options !== undefined ? JSON.stringify(options) : ""
|
||||
// Use constructor identity + serialized options as cache key
|
||||
// We store constructor name as a hint but rely on a unique id for identity
|
||||
const ctorId =
|
||||
(constructor as unknown as { __cacheId?: string }).__cacheId ??
|
||||
((constructor as unknown as { __cacheId: string }).__cacheId =
|
||||
`ctor_${this.instanceCache.size}`)
|
||||
const cacheKey = `${ctorId}:${optsKey}`
|
||||
|
||||
const cached = this.instanceCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const instance = constructor(options)
|
||||
this.instanceCache.set(cacheKey, instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
getAllComponents(): QuartzComponent[] {
|
||||
// Deduplicate by component reference (same constructor may be registered under multiple keys)
|
||||
const seen = new Set<QuartzComponent | QuartzComponentConstructor>()
|
||||
const results: QuartzComponent[] = []
|
||||
for (const r of this.components.values()) {
|
||||
if (seen.has(r.component)) continue
|
||||
seen.add(r.component)
|
||||
try {
|
||||
let instance: QuartzComponent
|
||||
if (typeof r.component === "function") {
|
||||
// Check if this constructor was already instantiated (with any options).
|
||||
// Re-instantiating with `undefined` when options were provided would create
|
||||
// a duplicate instance with separate afterDOMLoaded scripts.
|
||||
const existing = this.findCachedInstance(r.component as QuartzComponentConstructor)
|
||||
instance =
|
||||
existing ?? this.instantiate(r.component as QuartzComponentConstructor, undefined)
|
||||
} else {
|
||||
instance = r.component as QuartzComponent
|
||||
}
|
||||
if (instance) {
|
||||
results.push(instance)
|
||||
}
|
||||
} catch {
|
||||
// Skip components that fail to instantiate
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private findCachedInstance(
|
||||
constructor: QuartzComponentConstructor<any>,
|
||||
): QuartzComponent | undefined {
|
||||
const ctorId = (constructor as unknown as { __cacheId?: string }).__cacheId
|
||||
if (!ctorId) return undefined
|
||||
for (const [key, instance] of this.instanceCache) {
|
||||
if (key.startsWith(`${ctorId}:`)) return instance
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const componentRegistry = new ComponentRegistry()
|
||||
|
||||
export function defineComponent<Options extends object | undefined = undefined>(
|
||||
factory: QuartzComponentConstructor<Options>,
|
||||
manifest: ComponentManifest,
|
||||
): QuartzComponentConstructor<Options> {
|
||||
;(factory as any).__quartzComponent = { manifest }
|
||||
return factory
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import test, { describe } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { renderTranscludes, pageResources } from "./renderPage"
|
||||
import { Root, Element } from "hast"
|
||||
import { FullSlug } from "../util/path"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { QuartzComponentProps } from "./types"
|
||||
import { StaticResources } from "../util/resources"
|
||||
|
||||
function makeTranscludeBlockquote(targetSlug: string, block?: string): Element {
|
||||
return {
|
||||
type: "element",
|
||||
tagName: "blockquote",
|
||||
properties: {
|
||||
className: ["transclude"],
|
||||
...(block ? { dataBlock: block } : {}),
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: {
|
||||
href: `./${targetSlug}`,
|
||||
"data-slug": targetSlug,
|
||||
className: ["transclude-inner"],
|
||||
},
|
||||
children: [{ type: "text", value: `Transclude of ${targetSlug}` }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function makePageData(slug: string, htmlAst: Root, extra?: Record<string, unknown>) {
|
||||
return {
|
||||
slug: slug as FullSlug,
|
||||
htmlAst,
|
||||
frontmatter: { title: slug, tags: [] },
|
||||
...extra,
|
||||
} as unknown as QuartzComponentProps["allFiles"][number]
|
||||
}
|
||||
|
||||
const cfg = { locale: "en-US" } as GlobalConfiguration
|
||||
|
||||
function makeComponentData(
|
||||
allFiles: QuartzComponentProps["allFiles"],
|
||||
): Pick<QuartzComponentProps, "allFiles" | "cfg"> {
|
||||
return { allFiles, cfg } as unknown as QuartzComponentProps
|
||||
}
|
||||
|
||||
describe("renderTranscludes", () => {
|
||||
test("resolves a single page transclusion", () => {
|
||||
const root: Root = {
|
||||
type: "root",
|
||||
children: [makeTranscludeBlockquote("target")],
|
||||
}
|
||||
|
||||
const targetHtml: Root = {
|
||||
type: "root",
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "p",
|
||||
properties: {},
|
||||
children: [{ type: "text", value: "Target content" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const allFiles = [makePageData("target", targetHtml)]
|
||||
const visited = new Set<FullSlug>(["current" as FullSlug])
|
||||
renderTranscludes(
|
||||
root,
|
||||
cfg,
|
||||
"current" as FullSlug,
|
||||
makeComponentData(allFiles) as QuartzComponentProps,
|
||||
visited,
|
||||
)
|
||||
|
||||
const bq = root.children[0] as Element
|
||||
const texts = JSON.stringify(bq.children)
|
||||
assert.ok(texts.includes("Target content"), "transcluded content should be inlined")
|
||||
})
|
||||
|
||||
test("allows the same page to be embedded twice as siblings", () => {
|
||||
const root: Root = {
|
||||
type: "root",
|
||||
children: [makeTranscludeBlockquote("target"), makeTranscludeBlockquote("target")],
|
||||
}
|
||||
|
||||
const targetHtml: Root = {
|
||||
type: "root",
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "p",
|
||||
properties: {},
|
||||
children: [{ type: "text", value: "Duplicated content" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const allFiles = [makePageData("target", targetHtml)]
|
||||
const visited = new Set<FullSlug>(["current" as FullSlug])
|
||||
renderTranscludes(
|
||||
root,
|
||||
cfg,
|
||||
"current" as FullSlug,
|
||||
makeComponentData(allFiles) as QuartzComponentProps,
|
||||
visited,
|
||||
)
|
||||
|
||||
const first = root.children[0] as Element
|
||||
const second = root.children[1] as Element
|
||||
const firstText = JSON.stringify(first.children)
|
||||
const secondText = JSON.stringify(second.children)
|
||||
assert.ok(firstText.includes("Duplicated content"), "first embed should resolve")
|
||||
assert.ok(
|
||||
secondText.includes("Duplicated content"),
|
||||
"second embed should resolve, not be rejected as circular",
|
||||
)
|
||||
assert.ok(!secondText.includes("Circular transclusion"), "should not show circular warning")
|
||||
})
|
||||
|
||||
test("allows different sections of the same page to be embedded", () => {
|
||||
const root: Root = {
|
||||
type: "root",
|
||||
children: [
|
||||
makeTranscludeBlockquote("target", "#intro"),
|
||||
makeTranscludeBlockquote("target", "#details"),
|
||||
],
|
||||
}
|
||||
|
||||
const targetHtml: Root = {
|
||||
type: "root",
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "h2",
|
||||
properties: { id: "intro" },
|
||||
children: [{ type: "text", value: "Intro" }],
|
||||
},
|
||||
{
|
||||
type: "element",
|
||||
tagName: "p",
|
||||
properties: {},
|
||||
children: [{ type: "text", value: "Intro text" }],
|
||||
},
|
||||
{
|
||||
type: "element",
|
||||
tagName: "h2",
|
||||
properties: { id: "details" },
|
||||
children: [{ type: "text", value: "Details" }],
|
||||
},
|
||||
{
|
||||
type: "element",
|
||||
tagName: "p",
|
||||
properties: {},
|
||||
children: [{ type: "text", value: "Details text" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const allFiles = [makePageData("target", targetHtml)]
|
||||
const visited = new Set<FullSlug>(["current" as FullSlug])
|
||||
renderTranscludes(
|
||||
root,
|
||||
cfg,
|
||||
"current" as FullSlug,
|
||||
makeComponentData(allFiles) as QuartzComponentProps,
|
||||
visited,
|
||||
)
|
||||
|
||||
const first = root.children[0] as Element
|
||||
const second = root.children[1] as Element
|
||||
const firstText = JSON.stringify(first.children)
|
||||
const secondText = JSON.stringify(second.children)
|
||||
assert.ok(firstText.includes("Intro text"), "first header section should resolve")
|
||||
assert.ok(
|
||||
!firstText.includes("Details text"),
|
||||
"first section should not include second section",
|
||||
)
|
||||
assert.ok(secondText.includes("Details text"), "second header section should resolve")
|
||||
assert.ok(!secondText.includes("Circular transclusion"), "should not show circular warning")
|
||||
})
|
||||
|
||||
test("detects actual circular transclusion (A -> B -> A)", () => {
|
||||
// Page A embeds B, and B's htmlAst contains a transclusion of A
|
||||
const bTranscludesA = makeTranscludeBlockquote("pageA")
|
||||
const pageB_htmlAst: Root = {
|
||||
type: "root",
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "p",
|
||||
properties: {},
|
||||
children: [{ type: "text", value: "Page B content" }],
|
||||
},
|
||||
bTranscludesA,
|
||||
],
|
||||
}
|
||||
|
||||
const pageA_htmlAst: Root = {
|
||||
type: "root",
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "p",
|
||||
properties: {},
|
||||
children: [{ type: "text", value: "Page A content" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const root: Root = {
|
||||
type: "root",
|
||||
children: [makeTranscludeBlockquote("pageB")],
|
||||
}
|
||||
|
||||
const allFiles = [makePageData("pageA", pageA_htmlAst), makePageData("pageB", pageB_htmlAst)]
|
||||
|
||||
const visited = new Set<FullSlug>(["pageA" as FullSlug])
|
||||
renderTranscludes(
|
||||
root,
|
||||
cfg,
|
||||
"pageA" as FullSlug,
|
||||
makeComponentData(allFiles) as QuartzComponentProps,
|
||||
visited,
|
||||
)
|
||||
|
||||
const bq = root.children[0] as Element
|
||||
const fullText = JSON.stringify(bq.children)
|
||||
assert.ok(fullText.includes("Page B content"), "page B content should be inlined")
|
||||
assert.ok(fullText.includes("Circular transclusion"), "circular A->B->A should be detected")
|
||||
assert.ok(!fullText.includes("Page A content"), "page A should not be re-inlined inside B")
|
||||
})
|
||||
|
||||
test("self-referencing transclusion is blocked", () => {
|
||||
const root: Root = {
|
||||
type: "root",
|
||||
children: [makeTranscludeBlockquote("self")],
|
||||
}
|
||||
|
||||
const selfHtml: Root = {
|
||||
type: "root",
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "p",
|
||||
properties: {},
|
||||
children: [{ type: "text", value: "Self content" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const allFiles = [makePageData("self", selfHtml)]
|
||||
const visited = new Set<FullSlug>(["self" as FullSlug])
|
||||
renderTranscludes(
|
||||
root,
|
||||
cfg,
|
||||
"self" as FullSlug,
|
||||
makeComponentData(allFiles) as QuartzComponentProps,
|
||||
visited,
|
||||
)
|
||||
|
||||
const bq = root.children[0] as Element
|
||||
const text = JSON.stringify(bq.children)
|
||||
assert.ok(text.includes("Circular transclusion"), "self-reference should be blocked")
|
||||
})
|
||||
})
|
||||
|
||||
describe("pageResources", () => {
|
||||
const emptyResources: StaticResources = {
|
||||
css: [],
|
||||
js: [],
|
||||
additionalHead: [],
|
||||
}
|
||||
|
||||
test("uses baseDir prefix for resource paths in production mode", () => {
|
||||
const result = pageResources("/quartz" as FullSlug, emptyResources)
|
||||
assert.ok(
|
||||
result.css[0].content.startsWith("/quartz/"),
|
||||
`expected css path to start with /quartz/, got: ${result.css[0].content}`,
|
||||
)
|
||||
const externalJs = result.js.find((j) => j.contentType === "external" && "src" in j)
|
||||
assert.ok(externalJs && "src" in externalJs)
|
||||
assert.ok(
|
||||
externalJs.src.startsWith("/quartz/"),
|
||||
`expected js src to start with /quartz/, got: ${externalJs.src}`,
|
||||
)
|
||||
})
|
||||
|
||||
test("omits subpath prefix when baseDir is empty (serve mode)", () => {
|
||||
const result = pageResources("." as FullSlug, emptyResources)
|
||||
for (const css of result.css) {
|
||||
assert.ok(
|
||||
!css.content.includes("/quartz/"),
|
||||
`css path should not contain /quartz/, got: ${css.content}`,
|
||||
)
|
||||
}
|
||||
for (const js of result.js) {
|
||||
if (js.contentType === "external" && "src" in js) {
|
||||
assert.ok(
|
||||
!js.src.includes("/quartz/"),
|
||||
`js src should not contain /quartz/, got: ${js.src}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("contentIndex path reflects baseDir", () => {
|
||||
const withPrefix = pageResources("/quartz" as FullSlug, emptyResources)
|
||||
const inlineJs = withPrefix.js.find((j) => j.contentType === "inline" && "script" in j)
|
||||
assert.ok(inlineJs && "script" in inlineJs)
|
||||
assert.ok(
|
||||
inlineJs.script.includes("/quartz/static/contentIndex.json"),
|
||||
`expected contentIndex fetch to include /quartz/ prefix, got: ${inlineJs.script}`,
|
||||
)
|
||||
|
||||
const withoutPrefix = pageResources("." as FullSlug, emptyResources)
|
||||
const inlineJsServe = withoutPrefix.js.find((j) => j.contentType === "inline" && "script" in j)
|
||||
assert.ok(inlineJsServe && "script" in inlineJsServe)
|
||||
assert.ok(
|
||||
!inlineJsServe.script.includes("/quartz/static/contentIndex.json"),
|
||||
`expected contentIndex fetch without /quartz/ prefix in serve mode, got: ${inlineJsServe.script}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,376 @@
|
||||
import { render } from "preact-render-to-string"
|
||||
import { QuartzComponent, QuartzComponentProps } from "./types"
|
||||
import BodyConstructor from "./Body"
|
||||
import {
|
||||
CSSResource,
|
||||
JSResource,
|
||||
JSResourceToScriptElement,
|
||||
StaticResources,
|
||||
} from "../util/resources"
|
||||
import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
|
||||
import { clone } from "../util/clone"
|
||||
import { Root, Element, ElementContent } from "hast"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { i18n } from "../i18n"
|
||||
import { styleText } from "util"
|
||||
import { resolveFrame } from "./frames"
|
||||
import type { TreeTransform } from "../plugins/types"
|
||||
import type { BuildCtx } from "../util/ctx"
|
||||
|
||||
interface RenderComponents {
|
||||
head: QuartzComponent
|
||||
header: QuartzComponent[]
|
||||
beforeBody: QuartzComponent[]
|
||||
pageBody: QuartzComponent
|
||||
afterBody: QuartzComponent[]
|
||||
left: QuartzComponent[]
|
||||
right: QuartzComponent[]
|
||||
footer: QuartzComponent
|
||||
frame?: string
|
||||
}
|
||||
|
||||
const headerRegex = new RegExp(/h[1-6]/)
|
||||
export function pageResources(
|
||||
baseDir: FullSlug | RelativeURL,
|
||||
staticResources: StaticResources,
|
||||
ctx?: BuildCtx,
|
||||
): StaticResources {
|
||||
const hashedNames = ctx?.hashedResourceNames
|
||||
const cssFile = hashedNames?.["index.css"] ?? "index.css"
|
||||
const prescriptFile = hashedNames?.["prescript.js"] ?? "prescript.js"
|
||||
const postscriptFile = hashedNames?.["postscript.js"] ?? "postscript.js"
|
||||
|
||||
const componentCssResources: CSSResource[] = []
|
||||
if (ctx?.componentCssMap) {
|
||||
const seen = new Set<string>()
|
||||
for (const filename of ctx.componentCssMap.values()) {
|
||||
if (seen.has(filename)) continue
|
||||
seen.add(filename)
|
||||
componentCssResources.push({ content: joinSegments(baseDir, filename) })
|
||||
}
|
||||
}
|
||||
|
||||
const extracted = ctx?.extractedInlineResources
|
||||
const resolvedCss: CSSResource[] = staticResources.css.map((resource) => {
|
||||
if (!(resource.inline ?? false) || !extracted) return resource
|
||||
const filename = extracted.get(resource.content)
|
||||
if (!filename) return resource
|
||||
return { content: joinSegments(baseDir, filename) }
|
||||
})
|
||||
|
||||
const resolvedJs: JSResource[] = staticResources.js.map((resource) => {
|
||||
if (resource.contentType !== "inline" || !extracted) return resource
|
||||
const filename = extracted.get(resource.script)
|
||||
if (!filename) return resource
|
||||
return {
|
||||
src: joinSegments(baseDir, filename),
|
||||
loadTime: resource.loadTime,
|
||||
contentType: "external" as const,
|
||||
moduleType: resource.moduleType,
|
||||
spaPreserve: resource.spaPreserve,
|
||||
}
|
||||
})
|
||||
|
||||
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
|
||||
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
|
||||
|
||||
const resources: StaticResources = {
|
||||
css: [
|
||||
{
|
||||
content: joinSegments(baseDir, cssFile),
|
||||
},
|
||||
...componentCssResources,
|
||||
...resolvedCss,
|
||||
],
|
||||
js: [
|
||||
{
|
||||
src: joinSegments(baseDir, prescriptFile),
|
||||
loadTime: "beforeDOMReady",
|
||||
contentType: "external",
|
||||
},
|
||||
{
|
||||
loadTime: "beforeDOMReady",
|
||||
contentType: "inline",
|
||||
spaPreserve: true,
|
||||
script: contentIndexScript,
|
||||
},
|
||||
...resolvedJs,
|
||||
],
|
||||
additionalHead: staticResources.additionalHead,
|
||||
}
|
||||
|
||||
resources.js.push({
|
||||
src: joinSegments(baseDir, postscriptFile),
|
||||
loadTime: "afterDOMReady",
|
||||
moduleType: "module",
|
||||
contentType: "external",
|
||||
})
|
||||
|
||||
return resources
|
||||
}
|
||||
|
||||
/** @internal Exported for testing only. */
|
||||
export function renderTranscludes(
|
||||
root: Root,
|
||||
cfg: GlobalConfiguration,
|
||||
slug: FullSlug,
|
||||
componentData: QuartzComponentProps,
|
||||
visited: Set<FullSlug>,
|
||||
) {
|
||||
// Walk the tree manually instead of using visit() so we can track the
|
||||
// ancestor chain for cycle detection. visit() runs the callback before
|
||||
// descending into replaced children, so a Set-based guard there falsely
|
||||
// rejects sibling transclusions of the same target.
|
||||
function walk(node: Element | Root) {
|
||||
const children = (node as Root).children ?? []
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i]
|
||||
if (child?.type !== "element") continue
|
||||
const el = child as Element
|
||||
|
||||
if (el.tagName !== "blockquote") {
|
||||
walk(el)
|
||||
continue
|
||||
}
|
||||
|
||||
const classNames = (el.properties?.className ?? []) as string[]
|
||||
if (!classNames.includes("transclude")) {
|
||||
walk(el)
|
||||
continue
|
||||
}
|
||||
|
||||
const inner = el.children[0] as Element
|
||||
const transcludeTarget = (inner.properties["data-slug"] ?? slug) as FullSlug
|
||||
if (visited.has(transcludeTarget)) {
|
||||
console.warn(
|
||||
styleText(
|
||||
"yellow",
|
||||
`Warning: Skipping circular transclusion: ${slug} -> ${transcludeTarget}`,
|
||||
),
|
||||
)
|
||||
el.children = [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "p",
|
||||
properties: { style: "color: var(--secondary);" },
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: `Circular transclusion detected: ${transcludeTarget}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
continue
|
||||
}
|
||||
|
||||
visited.add(transcludeTarget)
|
||||
|
||||
let page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
|
||||
if (!page) {
|
||||
const dotIdx = transcludeTarget.lastIndexOf(".")
|
||||
const slashIdx = transcludeTarget.lastIndexOf("/")
|
||||
if (dotIdx > slashIdx + 1) {
|
||||
const stripped = transcludeTarget.slice(0, dotIdx) as FullSlug
|
||||
page = componentData.allFiles.findLast((f) => f.slug === stripped)
|
||||
}
|
||||
}
|
||||
if (!page) {
|
||||
visited.delete(transcludeTarget)
|
||||
continue
|
||||
}
|
||||
|
||||
let blockRef = el.properties.dataBlock as string | undefined
|
||||
if (blockRef?.startsWith("#^")) {
|
||||
// block transclude
|
||||
blockRef = blockRef.slice("#^".length)
|
||||
let blockNode = page.blocks?.[blockRef]
|
||||
if (blockNode) {
|
||||
if (blockNode.tagName === "li") {
|
||||
blockNode = {
|
||||
type: "element",
|
||||
tagName: "ul",
|
||||
properties: {},
|
||||
children: [blockNode],
|
||||
}
|
||||
}
|
||||
|
||||
el.children = [
|
||||
normalizeHastElement(blockNode, slug, transcludeTarget),
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: {
|
||||
href: inner.properties?.href,
|
||||
class: ["internal", "internal-link", "transclude-src"],
|
||||
},
|
||||
children: [
|
||||
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
} else if (blockRef?.startsWith("#") && page.htmlAst) {
|
||||
// header transclude
|
||||
blockRef = blockRef.slice(1)
|
||||
let startIdx = undefined
|
||||
let startDepth = undefined
|
||||
let endIdx = undefined
|
||||
for (const [i, htmlEl] of page.htmlAst.children.entries()) {
|
||||
if (!(htmlEl.type === "element" && htmlEl.tagName.match(headerRegex))) continue
|
||||
const depth = Number(htmlEl.tagName.substring(1))
|
||||
|
||||
if (startIdx === undefined || startDepth === undefined) {
|
||||
if (htmlEl.properties?.id === blockRef) {
|
||||
startIdx = i
|
||||
startDepth = depth
|
||||
}
|
||||
} else if (depth <= startDepth) {
|
||||
endIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (startIdx === undefined) {
|
||||
visited.delete(transcludeTarget)
|
||||
continue
|
||||
}
|
||||
|
||||
el.children = [
|
||||
...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((c) =>
|
||||
normalizeHastElement(c as Element, slug, transcludeTarget),
|
||||
),
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: {
|
||||
href: inner.properties?.href,
|
||||
class: ["internal", "internal-link", "transclude-src"],
|
||||
},
|
||||
children: [
|
||||
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
|
||||
],
|
||||
},
|
||||
]
|
||||
} else if (page.htmlAst) {
|
||||
// page transclude
|
||||
el.children = [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "h1",
|
||||
properties: {},
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value:
|
||||
page.frontmatter?.title ??
|
||||
i18n(cfg.locale).components.transcludes.transcludeOf({
|
||||
targetSlug: page.slug!,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
...(page.htmlAst.children as ElementContent[]).map((c) =>
|
||||
normalizeHastElement(c as Element, slug, transcludeTarget),
|
||||
),
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: {
|
||||
href: inner.properties?.href,
|
||||
class: ["internal", "internal-link", "transclude-src"],
|
||||
},
|
||||
children: [
|
||||
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Recurse into the replaced children to resolve nested transclusions,
|
||||
// then remove from visited so sibling embeds of the same target work.
|
||||
walk(el)
|
||||
visited.delete(transcludeTarget)
|
||||
}
|
||||
}
|
||||
|
||||
walk(root)
|
||||
}
|
||||
|
||||
export function renderPage(
|
||||
cfg: GlobalConfiguration,
|
||||
slug: FullSlug,
|
||||
componentData: QuartzComponentProps,
|
||||
components: RenderComponents,
|
||||
pageResources: StaticResources,
|
||||
treeTransforms?: TreeTransform[],
|
||||
): string {
|
||||
// make a deep copy of the tree so we don't remove the transclusion references
|
||||
// for the file cached in contentMap in build.ts
|
||||
const root = clone(componentData.tree) as Root
|
||||
const visited = new Set<FullSlug>([slug])
|
||||
renderTranscludes(root, cfg, slug, componentData, visited)
|
||||
|
||||
// Run plugin-provided tree transforms (e.g. resolving inline bases codeblocks)
|
||||
if (treeTransforms) {
|
||||
for (const transform of treeTransforms) {
|
||||
transform(root, slug, componentData)
|
||||
}
|
||||
}
|
||||
|
||||
// set componentData.tree to the edited html that has transclusions rendered
|
||||
componentData.tree = root
|
||||
|
||||
const {
|
||||
head: Head,
|
||||
header,
|
||||
beforeBody,
|
||||
pageBody: Content,
|
||||
afterBody,
|
||||
left,
|
||||
right,
|
||||
footer: Footer,
|
||||
frame: frameName,
|
||||
} = components
|
||||
const Body = BodyConstructor()
|
||||
const frame = resolveFrame(frameName)
|
||||
|
||||
const lang = componentData.fileData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"
|
||||
const direction = i18n(cfg.locale).direction ?? "ltr"
|
||||
// During local dev (--serve), the dev server serves from root without the
|
||||
// baseUrl subpath, so basePath must be empty to avoid broken links.
|
||||
const basePath =
|
||||
componentData.ctx.argv.serve || !cfg.baseUrl
|
||||
? ""
|
||||
: new URL(`https://${cfg.baseUrl}`).pathname.replace(/\/$/, "")
|
||||
const doc = (
|
||||
<html lang={lang} dir={direction}>
|
||||
<Head {...componentData} />
|
||||
<body data-slug={slug} data-basepath={basePath}>
|
||||
{frame.css && <style dangerouslySetInnerHTML={{ __html: frame.css }} />}
|
||||
<div id="quartz-root" class="page" data-frame={frame.name}>
|
||||
<Body {...componentData}>
|
||||
{[
|
||||
frame.render({
|
||||
componentData,
|
||||
head: Head,
|
||||
header,
|
||||
beforeBody,
|
||||
pageBody: Content,
|
||||
afterBody,
|
||||
left,
|
||||
right,
|
||||
footer: Footer,
|
||||
}),
|
||||
]}
|
||||
</Body>
|
||||
</div>
|
||||
</body>
|
||||
{pageResources.js
|
||||
.filter((resource) => resource.loadTime === "afterDOMReady")
|
||||
.map((res) => JSResourceToScriptElement(res, true))}
|
||||
</html>
|
||||
)
|
||||
|
||||
return "<!DOCTYPE html>\n" + render(doc)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
|
||||
import { normalizeRelativeURLs } from "../../util/path"
|
||||
import { fetchCanonical } from "./util"
|
||||
|
||||
const p = new DOMParser()
|
||||
let activeAnchor: HTMLAnchorElement | null = null
|
||||
|
||||
async function mouseEnterHandler(
|
||||
this: HTMLAnchorElement,
|
||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||
) {
|
||||
const link = (activeAnchor = this)
|
||||
if (link.dataset.noPopover === "true") {
|
||||
return
|
||||
}
|
||||
|
||||
async function setPosition(popoverElement: HTMLElement) {
|
||||
const { x, y } = await computePosition(link, popoverElement, {
|
||||
strategy: "fixed",
|
||||
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
|
||||
})
|
||||
Object.assign(popoverElement.style, {
|
||||
transform: `translate(${x.toFixed()}px, ${y.toFixed()}px)`,
|
||||
})
|
||||
}
|
||||
|
||||
function showPopover(popoverElement: HTMLElement) {
|
||||
clearActivePopover()
|
||||
popoverElement.classList.add("active-popover")
|
||||
setPosition(popoverElement as HTMLElement)
|
||||
|
||||
if (hash !== "") {
|
||||
const inner = popoverElement.querySelector(".popover-inner") as HTMLElement | null
|
||||
if (inner) {
|
||||
const targetAnchor = `#popover-internal-${hash.slice(1)}`
|
||||
const heading = inner.querySelector(targetAnchor) as HTMLElement | null
|
||||
if (heading) {
|
||||
// leave ~12px of buffer when scrolling to a heading
|
||||
inner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const targetUrl = new URL(link.href)
|
||||
const hash = decodeURIComponent(targetUrl.hash)
|
||||
targetUrl.hash = ""
|
||||
targetUrl.search = ""
|
||||
const popoverId = `popover-${link.pathname}`
|
||||
const prevPopoverElement = document.getElementById(popoverId)
|
||||
|
||||
// dont refetch if there's already a popover
|
||||
if (!!document.getElementById(popoverId)) {
|
||||
showPopover(prevPopoverElement as HTMLElement)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetchCanonical(targetUrl).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
if (!response) return
|
||||
const rawContentType = response.headers.get("Content-Type")
|
||||
if (!rawContentType) return
|
||||
const [contentType] = rawContentType.split(";")
|
||||
const [contentTypeCategory, typeInfo] = contentType.split("/")
|
||||
|
||||
const popoverElement = document.createElement("div")
|
||||
popoverElement.id = popoverId
|
||||
popoverElement.classList.add("popover")
|
||||
const popoverInner = document.createElement("div")
|
||||
popoverInner.classList.add("popover-inner")
|
||||
popoverInner.dataset.contentType = contentType ?? undefined
|
||||
popoverElement.appendChild(popoverInner)
|
||||
|
||||
switch (contentTypeCategory) {
|
||||
case "image":
|
||||
const img = document.createElement("img")
|
||||
img.src = targetUrl.toString()
|
||||
img.alt = targetUrl.pathname
|
||||
|
||||
popoverInner.appendChild(img)
|
||||
break
|
||||
case "application":
|
||||
switch (typeInfo) {
|
||||
case "pdf":
|
||||
const pdf = document.createElement("iframe")
|
||||
pdf.src = targetUrl.toString()
|
||||
popoverInner.appendChild(pdf)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
break
|
||||
default:
|
||||
const contents = await response.text()
|
||||
const html = p.parseFromString(contents, "text/html")
|
||||
normalizeRelativeURLs(html, targetUrl)
|
||||
// prepend all IDs inside popovers to prevent duplicates
|
||||
html.querySelectorAll("[id]").forEach((el) => {
|
||||
const targetID = `popover-internal-${el.id}`
|
||||
el.id = targetID
|
||||
})
|
||||
const elts = [...html.getElementsByClassName("popover-hint")]
|
||||
if (elts.length === 0) return
|
||||
|
||||
elts.forEach((elt) => popoverInner.appendChild(elt))
|
||||
}
|
||||
|
||||
if (!!document.getElementById(popoverId)) {
|
||||
return
|
||||
}
|
||||
|
||||
document.body.appendChild(popoverElement)
|
||||
if (activeAnchor !== this) {
|
||||
return
|
||||
}
|
||||
|
||||
showPopover(popoverElement)
|
||||
}
|
||||
|
||||
function clearActivePopover() {
|
||||
activeAnchor = null
|
||||
const allPopoverElements = document.querySelectorAll(".popover")
|
||||
allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
|
||||
}
|
||||
|
||||
function setupPopovers() {
|
||||
const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[]
|
||||
for (const link of links) {
|
||||
link.addEventListener("mouseenter", mouseEnterHandler)
|
||||
link.addEventListener("mouseleave", clearActivePopover)
|
||||
window.addCleanup(() => {
|
||||
link.removeEventListener("mouseenter", mouseEnterHandler)
|
||||
link.removeEventListener("mouseleave", clearActivePopover)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", setupPopovers)
|
||||
document.addEventListener("render", setupPopovers)
|
||||
@@ -0,0 +1,185 @@
|
||||
import test, { describe } from "node:test"
|
||||
import assert from "node:assert"
|
||||
|
||||
type ScrollArg = { top: number; behavior?: ScrollBehavior }
|
||||
type FakeHeading = { offsetTop: number }
|
||||
|
||||
interface FakePopoverInner {
|
||||
scroll: (arg: ScrollArg) => void
|
||||
querySelector: (sel: string) => FakeHeading | null
|
||||
_scrolled: ScrollArg | null
|
||||
_selectorsQueried: string[]
|
||||
}
|
||||
|
||||
interface FakePopoverElement {
|
||||
classList: {
|
||||
add: (cls: string) => void
|
||||
remove: (cls: string) => void
|
||||
_added: string[]
|
||||
_removed: string[]
|
||||
}
|
||||
style: Record<string, string>
|
||||
querySelector: (sel: string) => FakePopoverInner | null
|
||||
_inner: FakePopoverInner
|
||||
}
|
||||
|
||||
function makeInner(heading: FakeHeading | null = null): FakePopoverInner {
|
||||
const inner: FakePopoverInner = {
|
||||
scroll(arg) {
|
||||
this._scrolled = arg
|
||||
},
|
||||
querySelector(sel) {
|
||||
this._selectorsQueried.push(sel)
|
||||
return heading
|
||||
},
|
||||
_scrolled: null,
|
||||
_selectorsQueried: [],
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
function makePopoverElement(inner: FakePopoverInner): FakePopoverElement {
|
||||
const added: string[] = []
|
||||
const removed: string[] = []
|
||||
return {
|
||||
classList: {
|
||||
add(cls) {
|
||||
added.push(cls)
|
||||
},
|
||||
remove(cls) {
|
||||
removed.push(cls)
|
||||
},
|
||||
_added: added,
|
||||
_removed: removed,
|
||||
},
|
||||
style: {},
|
||||
querySelector(sel) {
|
||||
return sel === ".popover-inner" ? inner : null
|
||||
},
|
||||
_inner: inner,
|
||||
}
|
||||
}
|
||||
|
||||
type SetPosition = (el: FakePopoverElement) => Promise<void>
|
||||
|
||||
function fixedShowPopover(
|
||||
popoverElement: FakePopoverElement,
|
||||
hash: string,
|
||||
setPosition: SetPosition,
|
||||
): Promise<void> {
|
||||
popoverElement.classList.add("active-popover")
|
||||
const positionResult = setPosition(popoverElement)
|
||||
|
||||
if (hash !== "") {
|
||||
const inner = popoverElement.querySelector(".popover-inner")
|
||||
if (inner) {
|
||||
const targetAnchor = `#popover-internal-${hash.slice(1)}`
|
||||
const heading = inner.querySelector(targetAnchor)
|
||||
if (heading) {
|
||||
inner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return positionResult
|
||||
}
|
||||
|
||||
describe("showPopover on cache-hit with hash", () => {
|
||||
test("does not reference any lexical popoverInner from an outer scope", async () => {
|
||||
const heading: FakeHeading = { offsetTop: 200 }
|
||||
const inner = makeInner(heading)
|
||||
const popoverElement = makePopoverElement(inner)
|
||||
|
||||
await fixedShowPopover(popoverElement, "#plugins", async () => {})
|
||||
|
||||
assert.ok(
|
||||
popoverElement.classList._added.includes("active-popover"),
|
||||
"active-popover class must be applied",
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
inner._scrolled,
|
||||
{ top: 200 - 12, behavior: "instant" },
|
||||
"scroll must target heading.offsetTop - 12",
|
||||
)
|
||||
assert.deepStrictEqual(inner._selectorsQueried, ["#popover-internal-plugins"])
|
||||
})
|
||||
|
||||
test("skips scroll when hash is empty", async () => {
|
||||
const inner = makeInner({ offsetTop: 123 })
|
||||
const popoverElement = makePopoverElement(inner)
|
||||
|
||||
await fixedShowPopover(popoverElement, "", async () => {})
|
||||
|
||||
assert.strictEqual(inner._scrolled, null)
|
||||
assert.deepStrictEqual(inner._selectorsQueried, [])
|
||||
})
|
||||
|
||||
test("skips scroll when heading is not found", async () => {
|
||||
const inner = makeInner(null)
|
||||
const popoverElement = makePopoverElement(inner)
|
||||
|
||||
await fixedShowPopover(popoverElement, "#nonexistent", async () => {})
|
||||
|
||||
assert.strictEqual(inner._scrolled, null)
|
||||
assert.deepStrictEqual(inner._selectorsQueried, ["#popover-internal-nonexistent"])
|
||||
})
|
||||
|
||||
test("decodes percent-encoded fragments when building the selector", async () => {
|
||||
const heading: FakeHeading = { offsetTop: 50 }
|
||||
const inner = makeInner(heading)
|
||||
const popoverElement = makePopoverElement(inner)
|
||||
|
||||
await fixedShowPopover(popoverElement, "#a-b", async () => {})
|
||||
|
||||
assert.deepStrictEqual(inner._selectorsQueried, ["#popover-internal-a-b"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("buggy showPopover (lexical-capture pattern) regression guard", () => {
|
||||
test("accessing a capture-before-declaration variable throws ReferenceError (TDZ simulation)", () => {
|
||||
function simulateBuggyMouseEnter(hash: string) {
|
||||
function buggyShowPopover(popoverElement: FakePopoverElement) {
|
||||
popoverElement.classList.add("active-popover")
|
||||
if (hash !== "") {
|
||||
const targetAnchor = `#popover-internal-${hash.slice(1)}`
|
||||
const heading = popoverInner.querySelector(targetAnchor)
|
||||
if (heading) {
|
||||
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cachedInner = makeInner({ offsetTop: 999 })
|
||||
const cachedElement = makePopoverElement(cachedInner)
|
||||
buggyShowPopover(cachedElement)
|
||||
|
||||
const popoverInner = makeInner(null)
|
||||
return popoverInner
|
||||
}
|
||||
|
||||
assert.throws(() => simulateBuggyMouseEnter("#plugins"), {
|
||||
name: "ReferenceError",
|
||||
})
|
||||
})
|
||||
|
||||
test("same pattern does NOT throw when hash is empty (explains why first link without fragment works)", () => {
|
||||
function simulateBuggyMouseEnter(hash: string) {
|
||||
function buggyShowPopover(popoverElement: FakePopoverElement) {
|
||||
popoverElement.classList.add("active-popover")
|
||||
if (hash !== "") {
|
||||
const _unused = popoverInner.querySelector("x")
|
||||
void _unused
|
||||
}
|
||||
}
|
||||
|
||||
const cachedInner = makeInner(null)
|
||||
const cachedElement = makePopoverElement(cachedInner)
|
||||
buggyShowPopover(cachedElement)
|
||||
|
||||
const popoverInner = makeInner(null)
|
||||
return popoverInner
|
||||
}
|
||||
|
||||
assert.doesNotThrow(() => simulateBuggyMouseEnter(""))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import test, { describe } from "node:test"
|
||||
import assert from "node:assert"
|
||||
|
||||
// Inline the encoder function from search.inline.ts for testing
|
||||
const encoder = (str: string): string[] => {
|
||||
const tokens: string[] = []
|
||||
let bufferStart = -1
|
||||
let bufferEnd = -1
|
||||
const lower = str.toLowerCase()
|
||||
|
||||
let i = 0
|
||||
for (const char of lower) {
|
||||
const code = char.codePointAt(0)!
|
||||
|
||||
const isCJK =
|
||||
(code >= 0x3040 && code <= 0x309f) ||
|
||||
(code >= 0x30a0 && code <= 0x30ff) ||
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0xac00 && code <= 0xd7af) ||
|
||||
(code >= 0x20000 && code <= 0x2a6df)
|
||||
|
||||
const isWhitespace = code === 32 || code === 9 || code === 10 || code === 13
|
||||
|
||||
if (isCJK) {
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart, bufferEnd))
|
||||
bufferStart = -1
|
||||
}
|
||||
tokens.push(char)
|
||||
} else if (isWhitespace) {
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart, bufferEnd))
|
||||
bufferStart = -1
|
||||
}
|
||||
} else {
|
||||
if (bufferStart === -1) bufferStart = i
|
||||
bufferEnd = i + char.length
|
||||
}
|
||||
|
||||
i += char.length
|
||||
}
|
||||
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart))
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
describe("search encoder", () => {
|
||||
describe("English text", () => {
|
||||
test("should tokenize simple English words", () => {
|
||||
const result = encoder("hello world")
|
||||
assert.deepStrictEqual(result, ["hello", "world"])
|
||||
})
|
||||
|
||||
test("should handle multiple spaces", () => {
|
||||
const result = encoder("hello world")
|
||||
assert.deepStrictEqual(result, ["hello", "world"])
|
||||
})
|
||||
|
||||
test("should handle tabs and newlines", () => {
|
||||
const result = encoder("hello\tworld\ntest")
|
||||
assert.deepStrictEqual(result, ["hello", "world", "test"])
|
||||
})
|
||||
|
||||
test("should lowercase all text", () => {
|
||||
const result = encoder("Hello WORLD Test")
|
||||
assert.deepStrictEqual(result, ["hello", "world", "test"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("CJK text", () => {
|
||||
test("should tokenize Japanese Hiragana character by character", () => {
|
||||
const result = encoder("こんにちは")
|
||||
assert.deepStrictEqual(result, ["こ", "ん", "に", "ち", "は"])
|
||||
})
|
||||
|
||||
test("should tokenize Japanese Katakana character by character", () => {
|
||||
const result = encoder("コントロール")
|
||||
assert.deepStrictEqual(result, ["コ", "ン", "ト", "ロ", "ー", "ル"])
|
||||
})
|
||||
|
||||
test("should tokenize Japanese Kanji character by character", () => {
|
||||
const result = encoder("日本語")
|
||||
assert.deepStrictEqual(result, ["日", "本", "語"])
|
||||
})
|
||||
|
||||
test("should tokenize Korean Hangul character by character", () => {
|
||||
const result = encoder("안녕하세요")
|
||||
assert.deepStrictEqual(result, ["안", "녕", "하", "세", "요"])
|
||||
})
|
||||
|
||||
test("should tokenize Chinese characters character by character", () => {
|
||||
const result = encoder("你好世界")
|
||||
assert.deepStrictEqual(result, ["你", "好", "世", "界"])
|
||||
})
|
||||
|
||||
test("should handle mixed Hiragana/Katakana/Kanji", () => {
|
||||
const result = encoder("て以来")
|
||||
assert.deepStrictEqual(result, ["て", "以", "来"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Mixed CJK and English", () => {
|
||||
test("should handle Japanese with English words", () => {
|
||||
const result = encoder("hello 世界")
|
||||
assert.deepStrictEqual(result, ["hello", "世", "界"])
|
||||
})
|
||||
|
||||
test("should handle English with Japanese words", () => {
|
||||
const result = encoder("世界 hello world")
|
||||
assert.deepStrictEqual(result, ["世", "界", "hello", "world"])
|
||||
})
|
||||
|
||||
test("should handle complex mixed content", () => {
|
||||
const result = encoder("これはtest文章です")
|
||||
assert.deepStrictEqual(result, ["こ", "れ", "は", "test", "文", "章", "で", "す"])
|
||||
})
|
||||
|
||||
test("should handle mixed Korean and English", () => {
|
||||
const result = encoder("hello 안녕 world")
|
||||
assert.deepStrictEqual(result, ["hello", "안", "녕", "world"])
|
||||
})
|
||||
|
||||
test("should handle mixed Chinese and English", () => {
|
||||
const result = encoder("你好 world")
|
||||
assert.deepStrictEqual(result, ["你", "好", "world"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
test("should handle empty string", () => {
|
||||
const result = encoder("")
|
||||
assert.deepStrictEqual(result, [])
|
||||
})
|
||||
|
||||
test("should handle only whitespace", () => {
|
||||
const result = encoder(" \t\n ")
|
||||
assert.deepStrictEqual(result, [])
|
||||
})
|
||||
|
||||
test("should handle single character", () => {
|
||||
const result = encoder("a")
|
||||
assert.deepStrictEqual(result, ["a"])
|
||||
})
|
||||
|
||||
test("should handle single CJK character", () => {
|
||||
const result = encoder("あ")
|
||||
assert.deepStrictEqual(result, ["あ"])
|
||||
})
|
||||
|
||||
test("should handle CJK with trailing whitespace", () => {
|
||||
const result = encoder("日本語 ")
|
||||
assert.deepStrictEqual(result, ["日", "本", "語"])
|
||||
})
|
||||
|
||||
test("should handle English with trailing whitespace", () => {
|
||||
const result = encoder("hello ")
|
||||
assert.deepStrictEqual(result, ["hello"])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import micromorph from "micromorph"
|
||||
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
|
||||
import { fetchCanonical } from "./util"
|
||||
|
||||
// adapted from `micromorph`
|
||||
// https://github.com/natemoo-re/micromorph
|
||||
const NODE_TYPE_ELEMENT = 1
|
||||
let announcer = document.createElement("route-announcer")
|
||||
const isElement = (target: EventTarget | null): target is Element =>
|
||||
(target as Node)?.nodeType === NODE_TYPE_ELEMENT
|
||||
const isLocalUrl = (href: string) => {
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (window.location.origin === url.origin) {
|
||||
return true
|
||||
}
|
||||
} catch (e) {}
|
||||
return false
|
||||
}
|
||||
|
||||
const isSamePage = (url: URL): boolean => {
|
||||
const sameOrigin = url.origin === window.location.origin
|
||||
const samePath = url.pathname === window.location.pathname
|
||||
return sameOrigin && samePath
|
||||
}
|
||||
|
||||
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
|
||||
if (!isElement(target)) return
|
||||
if (target.attributes.getNamedItem("target")?.value === "_blank") return
|
||||
const a = target.closest("a")
|
||||
if (!a) return
|
||||
if ("routerIgnore" in a.dataset) return
|
||||
const { href } = a
|
||||
if (!isLocalUrl(href)) return
|
||||
return { url: new URL(href), scroll: "routerNoscroll" in a.dataset ? false : undefined }
|
||||
}
|
||||
|
||||
function notifyNav(url: FullSlug) {
|
||||
const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } })
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
const cleanupFns: Set<(...args: any[]) => void> = new Set()
|
||||
window.addCleanup = (fn) => cleanupFns.add(fn)
|
||||
|
||||
function startLoading() {
|
||||
document.querySelector(".navigation-progress")?.remove()
|
||||
const loadingBar = document.createElement("div")
|
||||
loadingBar.className = "navigation-progress"
|
||||
loadingBar.style.width = "0"
|
||||
document.body.prepend(loadingBar)
|
||||
|
||||
setTimeout(() => {
|
||||
loadingBar.style.width = "80%"
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function stopLoading() {
|
||||
const loadingBar = document.querySelector(".navigation-progress")
|
||||
if (loadingBar) {
|
||||
loadingBar.remove()
|
||||
}
|
||||
}
|
||||
|
||||
let isNavigating = false
|
||||
let p: DOMParser
|
||||
async function _navigate(url: URL, isBack: boolean = false) {
|
||||
isNavigating = true
|
||||
startLoading()
|
||||
p = p || new DOMParser()
|
||||
const contents = await fetchCanonical(url)
|
||||
.then((res) => {
|
||||
const contentType = res.headers.get("content-type")
|
||||
if (contentType?.startsWith("text/html")) {
|
||||
return res.text()
|
||||
} else {
|
||||
window.location.assign(url)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.location.assign(url)
|
||||
})
|
||||
|
||||
if (!contents) return
|
||||
|
||||
// notify about to nav
|
||||
const event: CustomEventMap["prenav"] = new CustomEvent("prenav", { detail: {} })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
// cleanup old
|
||||
cleanupFns.forEach((fn) => fn())
|
||||
cleanupFns.clear()
|
||||
|
||||
const html = p.parseFromString(contents, "text/html")
|
||||
normalizeRelativeURLs(html, url)
|
||||
|
||||
let title = html.querySelector("title")?.textContent
|
||||
if (title) {
|
||||
document.title = title
|
||||
} else {
|
||||
const h1 = document.querySelector("h1")
|
||||
title = h1?.innerText ?? h1?.textContent ?? url.pathname
|
||||
}
|
||||
if (announcer.textContent !== title) {
|
||||
announcer.textContent = title
|
||||
}
|
||||
announcer.dataset.persist = ""
|
||||
html.body.appendChild(announcer)
|
||||
|
||||
document.querySelector(".navigation-progress")?.remove()
|
||||
micromorph(document.body, html.body)
|
||||
|
||||
// scroll into place and add history
|
||||
if (!isBack) {
|
||||
if (url.hash) {
|
||||
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
|
||||
el?.scrollIntoView()
|
||||
} else {
|
||||
window.scrollTo({ top: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// now, patch head, re-executing scripts
|
||||
const elementsToRemove = document.head.querySelectorAll(":not([data-persist])")
|
||||
elementsToRemove.forEach((el) => el.remove())
|
||||
const elementsToAdd = html.head.querySelectorAll(":not([data-persist])")
|
||||
elementsToAdd.forEach((el) => document.head.appendChild(el))
|
||||
|
||||
// delay setting the url until now
|
||||
// at this point everything is loaded so changing the url should resolve to the correct addresses
|
||||
if (!isBack) {
|
||||
history.pushState({}, "", url)
|
||||
}
|
||||
|
||||
notifyNav(getFullSlug(window))
|
||||
delete announcer.dataset.persist
|
||||
}
|
||||
|
||||
async function navigate(url: URL, isBack: boolean = false) {
|
||||
if (isNavigating) return
|
||||
isNavigating = true
|
||||
try {
|
||||
await _navigate(url, isBack)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
window.location.assign(url)
|
||||
} finally {
|
||||
stopLoading()
|
||||
isNavigating = false
|
||||
}
|
||||
}
|
||||
|
||||
window.spaNavigate = navigate
|
||||
|
||||
function createRouter() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("click", async (event) => {
|
||||
const { url } = getOpts(event) ?? {}
|
||||
// dont hijack behaviour, just let browser act normally
|
||||
if (!url || event.ctrlKey || event.metaKey) return
|
||||
event.preventDefault()
|
||||
|
||||
if (isSamePage(url) && url.hash) {
|
||||
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
|
||||
el?.scrollIntoView()
|
||||
history.pushState({}, "", url)
|
||||
return
|
||||
}
|
||||
|
||||
navigate(url, false)
|
||||
})
|
||||
|
||||
window.addEventListener("popstate", (event) => {
|
||||
const { url } = getOpts(event) ?? {}
|
||||
if (window.location.hash && window.location.pathname === url?.pathname) return
|
||||
navigate(new URL(window.location.toString()), true)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
return new (class Router {
|
||||
go(pathname: RelativeURL) {
|
||||
const url = new URL(pathname, window.location.toString())
|
||||
return navigate(url, false)
|
||||
}
|
||||
|
||||
back() {
|
||||
return window.history.back()
|
||||
}
|
||||
|
||||
forward() {
|
||||
return window.history.forward()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
createRouter()
|
||||
notifyNav(getFullSlug(window))
|
||||
|
||||
if (!customElements.get("route-announcer")) {
|
||||
const attrs = {
|
||||
"aria-live": "assertive",
|
||||
"aria-atomic": "true",
|
||||
style:
|
||||
"position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px",
|
||||
}
|
||||
|
||||
customElements.define(
|
||||
"route-announcer",
|
||||
class RouteAnnouncer extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
connectedCallback() {
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
this.setAttribute(key, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb: () => void) {
|
||||
if (!outsideContainer) return
|
||||
function click(this: HTMLElement, e: HTMLElementEventMap["click"]) {
|
||||
if (e.target !== this) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cb()
|
||||
}
|
||||
|
||||
function esc(e: HTMLElementEventMap["keydown"]) {
|
||||
if (!e.key.startsWith("Esc")) return
|
||||
e.preventDefault()
|
||||
cb()
|
||||
}
|
||||
|
||||
outsideContainer?.addEventListener("click", click)
|
||||
window.addCleanup(() => outsideContainer?.removeEventListener("click", click))
|
||||
document.addEventListener("keydown", esc)
|
||||
window.addCleanup(() => document.removeEventListener("keydown", esc))
|
||||
}
|
||||
|
||||
export function removeAllChildren(node: HTMLElement) {
|
||||
while (node.firstChild) {
|
||||
node.removeChild(node.firstChild)
|
||||
}
|
||||
}
|
||||
|
||||
// AliasRedirect emits HTML redirects which also have the link[rel="canonical"]
|
||||
// containing the URL it's redirecting to.
|
||||
// Extracting it here with regex is _probably_ faster than parsing the entire HTML
|
||||
// with a DOMParser effectively twice (here and later in the SPA code), even if
|
||||
// way less robust - we only care about our own generated redirects after all.
|
||||
const canonicalRegex = /<link rel="canonical" href="([^"]*)">/
|
||||
|
||||
export async function fetchCanonical(url: URL): Promise<Response> {
|
||||
const res = await fetch(`${url}`)
|
||||
if (!res.headers.get("content-type")?.startsWith("text/html")) {
|
||||
return res
|
||||
}
|
||||
|
||||
// reading the body can only be done once, so we need to clone the response
|
||||
// to allow the caller to read it if it's was not a redirect
|
||||
const text = await res.clone().text()
|
||||
const [_, redirect] = text.match(canonicalRegex) ?? []
|
||||
return redirect ? fetch(`${new URL(redirect, url)}`) : res
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
@keyframes dropin {
|
||||
0% {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
1% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.popover {
|
||||
z-index: 999;
|
||||
position: fixed;
|
||||
overflow: visible;
|
||||
padding: 1rem;
|
||||
left: 0;
|
||||
top: 0;
|
||||
will-change: transform;
|
||||
|
||||
& > .popover-inner {
|
||||
position: relative;
|
||||
width: 30rem;
|
||||
max-height: 20rem;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
font-weight: initial;
|
||||
font-style: initial;
|
||||
line-height: normal;
|
||||
font-size: initial;
|
||||
font-family: var(--bodyFont);
|
||||
border: 1px solid var(--lightgray);
|
||||
background-color: var(--light);
|
||||
border-radius: 5px;
|
||||
box-shadow: 6px 6px 36px 0 rgba(0, 0, 0, 0.25);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
white-space: normal;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
& > .popover-inner[data-content-type] {
|
||||
&[data-content-type*="pdf"],
|
||||
&[data-content-type*="image"] {
|
||||
padding: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
&[data-content-type*="image"] {
|
||||
img {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-content-type*="pdf"] {
|
||||
iframe {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
visibility 0.3s ease;
|
||||
|
||||
@media all and ($mobile) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.active-popover,
|
||||
.popover:hover {
|
||||
animation: dropin 0.3s ease;
|
||||
animation-fill-mode: forwards;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { JSX } from "preact"
|
||||
import { StaticResources, StringResource } from "../util/resources"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { Node } from "hast"
|
||||
import { BuildCtx } from "../util/ctx"
|
||||
|
||||
export type QuartzComponentProps = {
|
||||
ctx: BuildCtx
|
||||
externalResources: StaticResources
|
||||
fileData: QuartzPluginData
|
||||
cfg: GlobalConfiguration
|
||||
children: (QuartzComponent | JSX.Element)[]
|
||||
tree: Node
|
||||
allFiles: QuartzPluginData[]
|
||||
displayClass?: "mobile-only" | "desktop-only"
|
||||
} & JSX.IntrinsicAttributes & {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type QuartzComponent = ((props: QuartzComponentProps) => any) & {
|
||||
displayName?: string
|
||||
css?: StringResource
|
||||
beforeDOMLoaded?: StringResource
|
||||
afterDOMLoaded?: StringResource
|
||||
}
|
||||
|
||||
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
|
||||
opts: Options,
|
||||
) => QuartzComponent
|
||||
Reference in New Issue
Block a user