feat: support non-singleton explorer

This commit is contained in:
Jacky Zhao 2025-03-10 15:13:04 -07:00
parent dd940a007c
commit a8001e9554
15 changed files with 168 additions and 146 deletions

View file

@ -19,6 +19,7 @@ import { options } from "./util/sourcemap"
import { Mutex } from "async-mutex" import { Mutex } from "async-mutex"
import DepGraph from "./depgraph" import DepGraph from "./depgraph"
import { getStaticResourcesFromPlugins } from "./plugins" import { getStaticResourcesFromPlugins } from "./plugins"
import { randomIdNonSecure } from "./util/random"
type Dependencies = Record<string, DepGraph<FilePath> | null> type Dependencies = Record<string, DepGraph<FilePath> | null>
@ -38,13 +39,9 @@ type BuildData = {
type FileEvent = "add" | "change" | "delete" type FileEvent = "add" | "change" | "delete"
function newBuildId() {
return Math.random().toString(36).substring(2, 8)
}
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const ctx: BuildCtx = { const ctx: BuildCtx = {
buildId: newBuildId(), buildId: randomIdNonSecure(),
argv, argv,
cfg, cfg,
allSlugs: [], allSlugs: [],
@ -162,7 +159,7 @@ async function partialRebuildFromEntrypoint(
return return
} }
const buildId = newBuildId() const buildId = randomIdNonSecure()
ctx.buildId = buildId ctx.buildId = buildId
buildData.lastBuildMs = new Date().getTime() buildData.lastBuildMs = new Date().getTime()
const release = await mut.acquire() const release = await mut.acquire()
@ -359,7 +356,7 @@ async function rebuildFromEntrypoint(
toRemove.add(filePath) toRemove.add(filePath)
} }
const buildId = newBuildId() const buildId = randomIdNonSecure()
ctx.buildId = buildId ctx.buildId = buildId
buildData.lastBuildMs = new Date().getTime() buildData.lastBuildMs = new Date().getTime()
const release = await mut.acquire() const release = await mut.acquire()

View file

@ -3,7 +3,7 @@ import style from "./styles/backlinks.scss"
import { resolveRelative, simplifySlug } from "../util/path" import { resolveRelative, simplifySlug } from "../util/path"
import { i18n } from "../i18n" import { i18n } from "../i18n"
import { classNames } from "../util/lang" import { classNames } from "../util/lang"
import OverflowList from "./OverflowList" import OverflowListFactory from "./OverflowList"
interface BacklinksOptions { interface BacklinksOptions {
hideWhenEmpty: boolean hideWhenEmpty: boolean
@ -15,6 +15,7 @@ const defaultOptions: BacklinksOptions = {
export default ((opts?: Partial<BacklinksOptions>) => { export default ((opts?: Partial<BacklinksOptions>) => {
const options: BacklinksOptions = { ...defaultOptions, ...opts } const options: BacklinksOptions = { ...defaultOptions, ...opts }
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
const Backlinks: QuartzComponent = ({ const Backlinks: QuartzComponent = ({
fileData, fileData,
@ -30,7 +31,7 @@ export default ((opts?: Partial<BacklinksOptions>) => {
return ( return (
<div class={classNames(displayClass, "backlinks")}> <div class={classNames(displayClass, "backlinks")}>
<h3>{i18n(cfg.locale).components.backlinks.title}</h3> <h3>{i18n(cfg.locale).components.backlinks.title}</h3>
<OverflowList id="backlinks-ul"> <OverflowList>
{backlinkFiles.length > 0 ? ( {backlinkFiles.length > 0 ? (
backlinkFiles.map((f) => ( backlinkFiles.map((f) => (
<li> <li>
@ -48,7 +49,7 @@ export default ((opts?: Partial<BacklinksOptions>) => {
} }
Backlinks.css = style Backlinks.css = style
Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul") Backlinks.afterDOMLoaded = overflowListAfterDOMLoaded
return Backlinks return Backlinks
}) satisfies QuartzComponentConstructor }) satisfies QuartzComponentConstructor

View file

@ -6,7 +6,8 @@ import script from "./scripts/explorer.inline"
import { classNames } from "../util/lang" import { classNames } from "../util/lang"
import { i18n } from "../i18n" import { i18n } from "../i18n"
import { FileTrieNode } from "../util/fileTrie" import { FileTrieNode } from "../util/fileTrie"
import OverflowList from "./OverflowList" import OverflowListFactory from "./OverflowList"
import { concatenateResources } from "../util/resources"
type OrderEntries = "sort" | "filter" | "map" type OrderEntries = "sort" | "filter" | "map"
@ -56,6 +57,7 @@ export type FolderState = {
export default ((userOpts?: Partial<Options>) => { export default ((userOpts?: Partial<Options>) => {
const opts: Options = { ...defaultOptions, ...userOpts } const opts: Options = { ...defaultOptions, ...userOpts }
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => { const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
return ( return (
@ -73,8 +75,7 @@ export default ((userOpts?: Partial<Options>) => {
> >
<button <button
type="button" type="button"
id="mobile-explorer" class="explorer-toggle mobile-explorer hide-until-loaded"
class="explorer-toggle hide-until-loaded"
data-mobile={true} data-mobile={true}
aria-controls="explorer-content" aria-controls="explorer-content"
> >
@ -95,8 +96,7 @@ export default ((userOpts?: Partial<Options>) => {
</button> </button>
<button <button
type="button" type="button"
id="desktop-explorer" class="title-button explorer-toggle desktop-explorer"
class="title-button explorer-toggle"
data-mobile={false} data-mobile={false}
aria-expanded={true} aria-expanded={true}
> >
@ -116,8 +116,8 @@ export default ((userOpts?: Partial<Options>) => {
<polyline points="6 9 12 15 18 9"></polyline> <polyline points="6 9 12 15 18 9"></polyline>
</svg> </svg>
</button> </button>
<div id="explorer-content" aria-expanded={false}> <div class="explorer-content" aria-expanded={false}>
<OverflowList id="explorer-ul" /> <OverflowList class="explorer-ul" />
</div> </div>
<template id="template-file"> <template id="template-file">
<li> <li>
@ -157,6 +157,6 @@ export default ((userOpts?: Partial<Options>) => {
} }
Explorer.css = style Explorer.css = style
Explorer.afterDOMLoaded = script + OverflowList.afterDOMLoaded("explorer-ul") Explorer.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
return Explorer return Explorer
}) satisfies QuartzComponentConstructor }) satisfies QuartzComponentConstructor

View file

@ -1,22 +1,31 @@
import { JSX } from "preact" import { JSX } from "preact"
import { randomIdNonSecure } from "../util/random"
const OverflowList = ({ const OverflowList = ({
children, children,
...props ...props
}: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => { }: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => {
return ( return (
<ul class="overflow" {...props}> <ul {...props} class={[props.class, "overflow"].filter(Boolean).join(" ")} id={props.id}>
{children} {children}
<li class="overflow-end" /> <li class="overflow-end" />
</ul> </ul>
) )
} }
OverflowList.afterDOMLoaded = (id: string) => ` export default () => {
const id = randomIdNonSecure()
return {
OverflowList: (props: JSX.HTMLAttributes<HTMLUListElement>) => (
<OverflowList {...props} id={id} />
),
overflowListAfterDOMLoaded: `
document.addEventListener("nav", (e) => { document.addEventListener("nav", (e) => {
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
for (const entry of entries) { for (const entry of entries) {
const parentUl = entry.target.parentElement const parentUl = entry.target.parentElement
if (!parentUl) return
if (entry.isIntersecting) { if (entry.isIntersecting) {
parentUl.classList.remove("gradient-active") parentUl.classList.remove("gradient-active")
} else { } else {
@ -34,6 +43,6 @@ document.addEventListener("nav", (e) => {
observer.observe(end) observer.observe(end)
window.addCleanup(() => observer.disconnect()) window.addCleanup(() => observer.disconnect())
}) })
` `,
}
export default OverflowList }

View file

@ -6,7 +6,8 @@ import { classNames } from "../util/lang"
// @ts-ignore // @ts-ignore
import script from "./scripts/toc.inline" import script from "./scripts/toc.inline"
import { i18n } from "../i18n" import { i18n } from "../i18n"
import OverflowList from "./OverflowList" import OverflowListFactory from "./OverflowList"
import { concatenateResources } from "../util/resources"
interface Options { interface Options {
layout: "modern" | "legacy" layout: "modern" | "legacy"
@ -16,11 +17,14 @@ const defaultOptions: Options = {
layout: "modern", layout: "modern",
} }
const TableOfContents: QuartzComponent = ({ export default ((opts?: Partial<Options>) => {
const layout = opts?.layout ?? defaultOptions.layout
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
const TableOfContents: QuartzComponent = ({
fileData, fileData,
displayClass, displayClass,
cfg, cfg,
}: QuartzComponentProps) => { }: QuartzComponentProps) => {
if (!fileData.toc) { if (!fileData.toc) {
return null return null
} }
@ -50,7 +54,7 @@ const TableOfContents: QuartzComponent = ({
</svg> </svg>
</button> </button>
<div class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}> <div class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}>
<OverflowList id="toc-ul"> <OverflowList>
{fileData.toc.map((tocEntry) => ( {fileData.toc.map((tocEntry) => (
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}> <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}> <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
@ -62,11 +66,12 @@ const TableOfContents: QuartzComponent = ({
</div> </div>
</div> </div>
) )
} }
TableOfContents.css = modernStyle
TableOfContents.afterDOMLoaded = script + OverflowList.afterDOMLoaded("toc-ul")
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => { TableOfContents.css = modernStyle
TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
if (!fileData.toc) { if (!fileData.toc) {
return null return null
} }
@ -86,10 +91,8 @@ const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzCompone
</ul> </ul>
</details> </details>
) )
} }
LegacyTableOfContents.css = legacyStyle LegacyTableOfContents.css = legacyStyle
export default ((opts?: Partial<Options>) => {
const layout = opts?.layout ?? defaultOptions.layout
return layout === "modern" ? TableOfContents : LegacyTableOfContents return layout === "modern" ? TableOfContents : LegacyTableOfContents
}) satisfies QuartzComponentConstructor }) satisfies QuartzComponentConstructor

View file

@ -9,6 +9,7 @@ import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { QuartzPluginData } from "../../plugins/vfile" import { QuartzPluginData } from "../../plugins/vfile"
import { ComponentChildren } from "preact" import { ComponentChildren } from "preact"
import { concatenateResources } from "../../util/resources"
interface FolderContentOptions { interface FolderContentOptions {
/** /**
@ -104,6 +105,6 @@ export default ((opts?: Partial<FolderContentOptions>) => {
) )
} }
FolderContent.css = style + PageList.css FolderContent.css = concatenateResources(style, PageList.css)
return FolderContent return FolderContent
}) satisfies QuartzComponentConstructor }) satisfies QuartzComponentConstructor

View file

@ -7,6 +7,7 @@ import { Root } from "hast"
import { htmlToJsx } from "../../util/jsx" import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { ComponentChildren } from "preact" import { ComponentChildren } from "preact"
import { concatenateResources } from "../../util/resources"
interface TagContentOptions { interface TagContentOptions {
sort?: SortFn sort?: SortFn
@ -124,6 +125,6 @@ export default ((opts?: Partial<TagContentOptions>) => {
} }
} }
TagContent.css = style + PageList.css TagContent.css = concatenateResources(style, PageList.css)
return TagContent return TagContent
}) satisfies QuartzComponentConstructor }) satisfies QuartzComponentConstructor

View file

@ -21,14 +21,13 @@ type FolderState = {
let currentExplorerState: Array<FolderState> let currentExplorerState: Array<FolderState>
function toggleExplorer(this: HTMLElement) { function toggleExplorer(this: HTMLElement) {
const explorers = document.querySelectorAll(".explorer") const nearestExplorer = this.closest(".explorer") as HTMLElement
for (const explorer of explorers) { if (!nearestExplorer) return
explorer.classList.toggle("collapsed") nearestExplorer.classList.toggle("collapsed")
explorer.setAttribute( nearestExplorer.setAttribute(
"aria-expanded", "aria-expanded",
explorer.getAttribute("aria-expanded") === "true" ? "false" : "true", nearestExplorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
) )
}
} }
function toggleFolder(evt: MouseEvent) { function toggleFolder(evt: MouseEvent) {
@ -145,7 +144,7 @@ function createFolderNode(
} }
async function setupExplorer(currentSlug: FullSlug) { async function setupExplorer(currentSlug: FullSlug) {
const allExplorers = document.querySelectorAll(".explorer") as NodeListOf<HTMLElement> const allExplorers = document.querySelectorAll("div.explorer") as NodeListOf<HTMLElement>
for (const explorer of allExplorers) { for (const explorer of allExplorers) {
const dataFns = JSON.parse(explorer.dataset.dataFns || "{}") const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
@ -192,7 +191,7 @@ async function setupExplorer(currentSlug: FullSlug) {
collapsed: oldIndex.get(path) === true, collapsed: oldIndex.get(path) === true,
})) }))
const explorerUl = document.getElementById("explorer-ul") const explorerUl = explorer.querySelector(".explorer-ul")
if (!explorerUl) continue if (!explorerUl) continue
// Create and insert new content // Create and insert new content
@ -219,14 +218,12 @@ async function setupExplorer(currentSlug: FullSlug) {
} }
// Set up event handlers // Set up event handlers
const explorerButtons = explorer.querySelectorAll( const explorerButtons = explorer.getElementsByClassName(
"button.explorer-toggle", "explorer-toggle",
) as NodeListOf<HTMLElement> ) as HTMLCollectionOf<HTMLElement>
if (explorerButtons) { for (const button of explorerButtons) {
window.addCleanup(() => button.addEventListener("click", toggleExplorer)
explorerButtons.forEach((button) => button.removeEventListener("click", toggleExplorer)), window.addCleanup(() => button.removeEventListener("click", toggleExplorer))
)
explorerButtons.forEach((button) => button.addEventListener("click", toggleExplorer))
} }
// Set up folder click handlers // Set up folder click handlers
@ -235,8 +232,8 @@ async function setupExplorer(currentSlug: FullSlug) {
"folder-button", "folder-button",
) as HTMLCollectionOf<HTMLElement> ) as HTMLCollectionOf<HTMLElement>
for (const button of folderButtons) { for (const button of folderButtons) {
window.addCleanup(() => button.removeEventListener("click", toggleFolder))
button.addEventListener("click", toggleFolder) button.addEventListener("click", toggleFolder)
window.addCleanup(() => button.removeEventListener("click", toggleFolder))
} }
} }
@ -244,15 +241,15 @@ async function setupExplorer(currentSlug: FullSlug) {
"folder-icon", "folder-icon",
) as HTMLCollectionOf<HTMLElement> ) as HTMLCollectionOf<HTMLElement>
for (const icon of folderIcons) { for (const icon of folderIcons) {
window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
icon.addEventListener("click", toggleFolder) icon.addEventListener("click", toggleFolder)
window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
} }
} }
} }
document.addEventListener("prenav", async (e: CustomEventMap["prenav"]) => { document.addEventListener("prenav", async () => {
// save explorer scrollTop position // save explorer scrollTop position
const explorer = document.getElementById("explorer-ul") const explorer = document.querySelector(".explorer-ul")
if (!explorer) return if (!explorer) return
sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString()) sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
}) })
@ -262,9 +259,8 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
await setupExplorer(currentSlug) await setupExplorer(currentSlug)
// if mobile hamburger is visible, collapse by default // if mobile hamburger is visible, collapse by default
const mobileExplorer = document.getElementById("mobile-explorer") for (const explorer of document.getElementsByClassName("mobile-explorer")) {
if (mobileExplorer && mobileExplorer.checkVisibility()) { if (explorer.checkVisibility()) {
for (const explorer of document.querySelectorAll(".explorer")) {
explorer.classList.add("collapsed") explorer.classList.add("collapsed")
explorer.setAttribute("aria-expanded", "false") explorer.setAttribute("aria-expanded", "false")
} }

View file

@ -20,7 +20,7 @@
margin: 0; margin: 0;
} }
.hide-until-loaded ~ #explorer-content { .hide-until-loaded ~ .explorer-content {
display: none; display: none;
} }
} }
@ -30,6 +30,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: hidden; overflow-y: hidden;
min-height: 1.2rem;
flex: 0 1 auto; flex: 0 1 auto;
&.collapsed { &.collapsed {
flex: 0 1 1.2rem; flex: 0 1 1.2rem;
@ -52,20 +54,20 @@
align-self: flex-start; align-self: flex-start;
} }
button#mobile-explorer { button.mobile-explorer {
display: none; display: none;
} }
button#desktop-explorer { button.desktop-explorer {
display: flex; display: flex;
} }
@media all and ($mobile) { @media all and ($mobile) {
button#mobile-explorer { button.mobile-explorer {
display: flex; display: flex;
} }
button#desktop-explorer { button.desktop-explorer {
display: none; display: none;
} }
} }
@ -86,8 +88,8 @@
} }
} }
button#mobile-explorer, button.mobile-explorer,
button#desktop-explorer { button.desktop-explorer {
background-color: transparent; background-color: transparent;
border: none; border: none;
text-align: left; text-align: left;
@ -104,7 +106,7 @@ button#desktop-explorer {
} }
} }
#explorer-content { .explorer-content {
list-style: none; list-style: none;
overflow: hidden; overflow: hidden;
overflow-y: auto; overflow-y: auto;
@ -209,7 +211,7 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
&.collapsed { &.collapsed {
flex: 0 0 34px; flex: 0 0 34px;
& > #explorer-content { & > .explorer-content {
transform: translateX(-100vw); transform: translateX(-100vw);
visibility: hidden; visibility: hidden;
} }
@ -218,13 +220,13 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
&:not(.collapsed) { &:not(.collapsed) {
flex: 0 0 34px; flex: 0 0 34px;
& > #explorer-content { & > .explorer-content {
transform: translateX(0); transform: translateX(0);
visibility: visible; visibility: visible;
} }
} }
#explorer-content { .explorer-content {
box-sizing: border-box; box-sizing: border-box;
z-index: 100; z-index: 100;
position: absolute; position: absolute;
@ -245,7 +247,7 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
visibility: hidden; visibility: hidden;
} }
#mobile-explorer { .mobile-explorer {
margin: 0; margin: 0;
padding: 5px; padding: 5px;
z-index: 101; z-index: 101;

View file

@ -5,6 +5,7 @@
flex-direction: column; flex-direction: column;
overflow-y: hidden; overflow-y: hidden;
min-height: 4rem;
flex: 0 1 auto; flex: 0 1 auto;
&:has(button.toc-header.collapsed) { &:has(button.toc-header.collapsed) {
flex: 0 1 1.2rem; flex: 0 1 1.2rem;

View file

@ -1,5 +1,5 @@
import { ComponentType, JSX } from "preact" import { ComponentType, JSX } from "preact"
import { StaticResources } from "../util/resources" import { StaticResources, StringResource } from "../util/resources"
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { GlobalConfiguration } from "../cfg" import { GlobalConfiguration } from "../cfg"
import { Node } from "hast" import { Node } from "hast"
@ -19,9 +19,9 @@ export type QuartzComponentProps = {
} }
export type QuartzComponent = ComponentType<QuartzComponentProps> & { export type QuartzComponent = ComponentType<QuartzComponentProps> & {
css?: string css?: StringResource
beforeDOMLoaded?: string beforeDOMLoaded?: StringResource
afterDOMLoaded?: string afterDOMLoaded?: StringResource
} }
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = ( export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (

View file

@ -36,17 +36,21 @@ function getComponentResources(ctx: BuildCtx): ComponentResources {
afterDOMLoaded: new Set<string>(), afterDOMLoaded: new Set<string>(),
} }
function normalizeResource(resource: string | string[] | undefined): string[] {
if (!resource) return []
if (Array.isArray(resource)) return resource
return [resource]
}
for (const component of allComponents) { for (const component of allComponents) {
const { css, beforeDOMLoaded, afterDOMLoaded } = component const { css, beforeDOMLoaded, afterDOMLoaded } = component
if (css) { const normalizedCss = normalizeResource(css)
componentResources.css.add(css) const normalizedBeforeDOMLoaded = normalizeResource(beforeDOMLoaded)
} const normalizedAfterDOMLoaded = normalizeResource(afterDOMLoaded)
if (beforeDOMLoaded) {
componentResources.beforeDOMLoaded.add(beforeDOMLoaded) normalizedCss.forEach((c) => componentResources.css.add(c))
} normalizedBeforeDOMLoaded.forEach((b) => componentResources.beforeDOMLoaded.add(b))
if (afterDOMLoaded) { normalizedAfterDOMLoaded.forEach((a) => componentResources.afterDOMLoaded.add(a))
componentResources.afterDOMLoaded.add(afterDOMLoaded)
}
} }
return { return {

View file

@ -542,7 +542,7 @@ video {
} }
.spacer { .spacer {
flex: 1 1 auto; flex: 2 1 auto;
} }
div:has(> .overflow) { div:has(> .overflow) {
@ -555,17 +555,14 @@ ol.overflow {
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;
width: 100%; width: 100%;
margin-bottom: 0;
// clearfix // clearfix
content: ""; content: "";
clear: both; clear: both;
& > li:last-of-type {
margin-bottom: 30px;
}
& > li.overflow-end { & > li.overflow-end {
height: 4px; height: 1rem;
margin: 0; margin: 0;
} }

3
quartz/util/random.ts Normal file
View file

@ -0,0 +1,3 @@
export function randomIdNonSecure() {
return Math.random().toString(36).substring(2, 8)
}

View file

@ -65,3 +65,10 @@ export interface StaticResources {
js: JSResource[] js: JSResource[]
additionalHead: (JSX.Element | ((pageData: QuartzPluginData) => JSX.Element))[] additionalHead: (JSX.Element | ((pageData: QuartzPluginData) => JSX.Element))[]
} }
export type StringResource = string | string[] | undefined
export function concatenateResources(...resources: StringResource[]): StringResource {
return resources
.filter((resource): resource is string | string[] => resource !== undefined)
.flat()
}