From a8001e9554a319782d8557acb8f19358996b5828 Mon Sep 17 00:00:00 2001
From: Jacky Zhao <j.zhao2k19@gmail.com>
Date: Mon, 10 Mar 2025 22:13:22 +0000
Subject: [PATCH] feat: support non-singleton explorer
---
quartz/util/resources.tsx | 7 +
quartz/components/Backlinks.tsx | 7
quartz/styles/base.scss | 9 -
quartz/components/styles/explorer.scss | 26 ++--
quartz/components/scripts/explorer.inline.ts | 46 +++----
quartz/util/random.ts | 3
quartz/build.ts | 11 -
quartz/plugins/emitters/componentResources.ts | 22 ++-
quartz/components/Explorer.tsx | 16 +-
quartz/components/OverflowList.tsx | 19 ++
quartz/components/types.ts | 8
quartz/components/TableOfContents.tsx | 133 +++++++++++----------
quartz/components/styles/toc.scss | 1
quartz/components/pages/TagContent.tsx | 3
quartz/components/pages/FolderContent.tsx | 3
15 files changed, 168 insertions(+), 146 deletions(-)
diff --git a/quartz/build.ts b/quartz/build.ts
index 64c462b..81558f9 100644
--- a/quartz/build.ts
+++ b/quartz/build.ts
@@ -19,6 +19,7 @@
import { Mutex } from "async-mutex"
import DepGraph from "./depgraph"
import { getStaticResourcesFromPlugins } from "./plugins"
+import { randomIdNonSecure } from "./util/random"
type Dependencies = Record<string, DepGraph<FilePath> | null>
@@ -38,13 +39,9 @@
type FileEvent = "add" | "change" | "delete"
-function newBuildId() {
- return Math.random().toString(36).substring(2, 8)
-}
-
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const ctx: BuildCtx = {
- buildId: newBuildId(),
+ buildId: randomIdNonSecure(),
argv,
cfg,
allSlugs: [],
@@ -162,7 +159,7 @@
return
}
- const buildId = newBuildId()
+ const buildId = randomIdNonSecure()
ctx.buildId = buildId
buildData.lastBuildMs = new Date().getTime()
const release = await mut.acquire()
@@ -359,7 +356,7 @@
toRemove.add(filePath)
}
- const buildId = newBuildId()
+ const buildId = randomIdNonSecure()
ctx.buildId = buildId
buildData.lastBuildMs = new Date().getTime()
const release = await mut.acquire()
diff --git a/quartz/components/Backlinks.tsx b/quartz/components/Backlinks.tsx
index 735afe7..0d34457 100644
--- a/quartz/components/Backlinks.tsx
+++ b/quartz/components/Backlinks.tsx
@@ -3,7 +3,7 @@
import { resolveRelative, simplifySlug } from "../util/path"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
-import OverflowList from "./OverflowList"
+import OverflowListFactory from "./OverflowList"
interface BacklinksOptions {
hideWhenEmpty: boolean
@@ -15,6 +15,7 @@
export default ((opts?: Partial<BacklinksOptions>) => {
const options: BacklinksOptions = { ...defaultOptions, ...opts }
+ const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
const Backlinks: QuartzComponent = ({
fileData,
@@ -30,7 +31,7 @@
return (
<div class={classNames(displayClass, "backlinks")}>
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
- <OverflowList id="backlinks-ul">
+ <OverflowList>
{backlinkFiles.length > 0 ? (
backlinkFiles.map((f) => (
<li>
@@ -48,7 +49,7 @@
}
Backlinks.css = style
- Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul")
+ Backlinks.afterDOMLoaded = overflowListAfterDOMLoaded
return Backlinks
}) satisfies QuartzComponentConstructor
diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx
index 9c6319a..56784f1 100644
--- a/quartz/components/Explorer.tsx
+++ b/quartz/components/Explorer.tsx
@@ -6,7 +6,8 @@
import { classNames } from "../util/lang"
import { i18n } from "../i18n"
import { FileTrieNode } from "../util/fileTrie"
-import OverflowList from "./OverflowList"
+import OverflowListFactory from "./OverflowList"
+import { concatenateResources } from "../util/resources"
type OrderEntries = "sort" | "filter" | "map"
@@ -56,6 +57,7 @@
export default ((userOpts?: Partial<Options>) => {
const opts: Options = { ...defaultOptions, ...userOpts }
+ const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
return (
@@ -73,8 +75,7 @@
>
<button
type="button"
- id="mobile-explorer"
- class="explorer-toggle hide-until-loaded"
+ class="explorer-toggle mobile-explorer hide-until-loaded"
data-mobile={true}
aria-controls="explorer-content"
>
@@ -95,8 +96,7 @@
</button>
<button
type="button"
- id="desktop-explorer"
- class="title-button explorer-toggle"
+ class="title-button explorer-toggle desktop-explorer"
data-mobile={false}
aria-expanded={true}
>
@@ -116,8 +116,8 @@
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
- <div id="explorer-content" aria-expanded={false}>
- <OverflowList id="explorer-ul" />
+ <div class="explorer-content" aria-expanded={false}>
+ <OverflowList class="explorer-ul" />
</div>
<template id="template-file">
<li>
@@ -157,6 +157,6 @@
}
Explorer.css = style
- Explorer.afterDOMLoaded = script + OverflowList.afterDOMLoaded("explorer-ul")
+ Explorer.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
return Explorer
}) satisfies QuartzComponentConstructor
diff --git a/quartz/components/OverflowList.tsx b/quartz/components/OverflowList.tsx
index d74c5c2..18aa9a6 100644
--- a/quartz/components/OverflowList.tsx
+++ b/quartz/components/OverflowList.tsx
@@ -1,22 +1,31 @@
import { JSX } from "preact"
+import { randomIdNonSecure } from "../util/random"
const OverflowList = ({
children,
...props
}: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => {
return (
- <ul class="overflow" {...props}>
+ <ul {...props} class={[props.class, "overflow"].filter(Boolean).join(" ")} id={props.id}>
{children}
<li class="overflow-end" />
</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) => {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const parentUl = entry.target.parentElement
+ if (!parentUl) return
if (entry.isIntersecting) {
parentUl.classList.remove("gradient-active")
} else {
@@ -34,6 +43,6 @@
observer.observe(end)
window.addCleanup(() => observer.disconnect())
})
-`
-
-export default OverflowList
+`,
+ }
+}
diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx
index da6eece..d376200 100644
--- a/quartz/components/TableOfContents.tsx
+++ b/quartz/components/TableOfContents.tsx
@@ -6,7 +6,8 @@
// @ts-ignore
import script from "./scripts/toc.inline"
import { i18n } from "../i18n"
-import OverflowList from "./OverflowList"
+import OverflowListFactory from "./OverflowList"
+import { concatenateResources } from "../util/resources"
interface Options {
layout: "modern" | "legacy"
@@ -16,41 +17,70 @@
layout: "modern",
}
-const TableOfContents: QuartzComponent = ({
- fileData,
- displayClass,
- cfg,
-}: QuartzComponentProps) => {
- if (!fileData.toc) {
- return null
+export default ((opts?: Partial<Options>) => {
+ const layout = opts?.layout ?? defaultOptions.layout
+ const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
+ const TableOfContents: QuartzComponent = ({
+ fileData,
+ displayClass,
+ cfg,
+ }: QuartzComponentProps) => {
+ if (!fileData.toc) {
+ return null
+ }
+
+ return (
+ <div class={classNames(displayClass, "toc")}>
+ <button
+ type="button"
+ class={fileData.collapseToc ? "collapsed toc-header" : "toc-header"}
+ aria-controls="toc-content"
+ aria-expanded={!fileData.collapseToc}
+ >
+ <h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="fold"
+ >
+ <polyline points="6 9 12 15 18 9"></polyline>
+ </svg>
+ </button>
+ <div class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}>
+ <OverflowList>
+ {fileData.toc.map((tocEntry) => (
+ <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
+ <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
+ {tocEntry.text}
+ </a>
+ </li>
+ ))}
+ </OverflowList>
+ </div>
+ </div>
+ )
}
- return (
- <div class={classNames(displayClass, "toc")}>
- <button
- type="button"
- class={fileData.collapseToc ? "collapsed toc-header" : "toc-header"}
- aria-controls="toc-content"
- aria-expanded={!fileData.collapseToc}
- >
- <h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
- <svg
- xmlns="http://www.w3.org/2000/svg"
- width="24"
- height="24"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="fold"
- >
- <polyline points="6 9 12 15 18 9"></polyline>
- </svg>
- </button>
- <div class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}>
- <OverflowList id="toc-ul">
+ TableOfContents.css = modernStyle
+ TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
+
+ const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
+ if (!fileData.toc) {
+ return null
+ }
+ return (
+ <details class="toc" open={!fileData.collapseToc}>
+ <summary>
+ <h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
+ </summary>
+ <ul>
{fileData.toc.map((tocEntry) => (
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
@@ -58,38 +88,11 @@
</a>
</li>
))}
- </OverflowList>
- </div>
- </div>
- )
-}
-TableOfContents.css = modernStyle
-TableOfContents.afterDOMLoaded = script + OverflowList.afterDOMLoaded("toc-ul")
-
-const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
- if (!fileData.toc) {
- return null
+ </ul>
+ </details>
+ )
}
- return (
- <details class="toc" open={!fileData.collapseToc}>
- <summary>
- <h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
- </summary>
- <ul>
- {fileData.toc.map((tocEntry) => (
- <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
- <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
- {tocEntry.text}
- </a>
- </li>
- ))}
- </ul>
- </details>
- )
-}
-LegacyTableOfContents.css = legacyStyle
+ LegacyTableOfContents.css = legacyStyle
-export default ((opts?: Partial<Options>) => {
- const layout = opts?.layout ?? defaultOptions.layout
return layout === "modern" ? TableOfContents : LegacyTableOfContents
}) satisfies QuartzComponentConstructor
diff --git a/quartz/components/pages/FolderContent.tsx b/quartz/components/pages/FolderContent.tsx
index 977da5e..2a727c0 100644
--- a/quartz/components/pages/FolderContent.tsx
+++ b/quartz/components/pages/FolderContent.tsx
@@ -9,6 +9,7 @@
import { i18n } from "../../i18n"
import { QuartzPluginData } from "../../plugins/vfile"
import { ComponentChildren } from "preact"
+import { concatenateResources } from "../../util/resources"
interface FolderContentOptions {
/**
@@ -104,6 +105,6 @@
)
}
- FolderContent.css = style + PageList.css
+ FolderContent.css = concatenateResources(style, PageList.css)
return FolderContent
}) satisfies QuartzComponentConstructor
diff --git a/quartz/components/pages/TagContent.tsx b/quartz/components/pages/TagContent.tsx
index 087daf1..03051d3 100644
--- a/quartz/components/pages/TagContent.tsx
+++ b/quartz/components/pages/TagContent.tsx
@@ -7,6 +7,7 @@
import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n"
import { ComponentChildren } from "preact"
+import { concatenateResources } from "../../util/resources"
interface TagContentOptions {
sort?: SortFn
@@ -124,6 +125,6 @@
}
}
- TagContent.css = style + PageList.css
+ TagContent.css = concatenateResources(style, PageList.css)
return TagContent
}) satisfies QuartzComponentConstructor
diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts
index 68a20bb..9e41af9 100644
--- a/quartz/components/scripts/explorer.inline.ts
+++ b/quartz/components/scripts/explorer.inline.ts
@@ -21,14 +21,13 @@
let currentExplorerState: Array<FolderState>
function toggleExplorer(this: HTMLElement) {
- const explorers = document.querySelectorAll(".explorer")
- for (const explorer of explorers) {
- explorer.classList.toggle("collapsed")
- explorer.setAttribute(
- "aria-expanded",
- explorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
- )
- }
+ const nearestExplorer = this.closest(".explorer") as HTMLElement
+ if (!nearestExplorer) return
+ nearestExplorer.classList.toggle("collapsed")
+ nearestExplorer.setAttribute(
+ "aria-expanded",
+ nearestExplorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
+ )
}
function toggleFolder(evt: MouseEvent) {
@@ -145,7 +144,7 @@
}
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) {
const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
@@ -192,7 +191,7 @@
collapsed: oldIndex.get(path) === true,
}))
- const explorerUl = document.getElementById("explorer-ul")
+ const explorerUl = explorer.querySelector(".explorer-ul")
if (!explorerUl) continue
// Create and insert new content
@@ -219,14 +218,12 @@
}
// Set up event handlers
- const explorerButtons = explorer.querySelectorAll(
- "button.explorer-toggle",
- ) as NodeListOf<HTMLElement>
- if (explorerButtons) {
- window.addCleanup(() =>
- explorerButtons.forEach((button) => button.removeEventListener("click", toggleExplorer)),
- )
- explorerButtons.forEach((button) => button.addEventListener("click", toggleExplorer))
+ const explorerButtons = explorer.getElementsByClassName(
+ "explorer-toggle",
+ ) as HTMLCollectionOf<HTMLElement>
+ for (const button of explorerButtons) {
+ button.addEventListener("click", toggleExplorer)
+ window.addCleanup(() => button.removeEventListener("click", toggleExplorer))
}
// Set up folder click handlers
@@ -235,8 +232,8 @@
"folder-button",
) as HTMLCollectionOf<HTMLElement>
for (const button of folderButtons) {
- window.addCleanup(() => button.removeEventListener("click", toggleFolder))
button.addEventListener("click", toggleFolder)
+ window.addCleanup(() => button.removeEventListener("click", toggleFolder))
}
}
@@ -244,15 +241,15 @@
"folder-icon",
) as HTMLCollectionOf<HTMLElement>
for (const icon of folderIcons) {
- window.addCleanup(() => icon.removeEventListener("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
- const explorer = document.getElementById("explorer-ul")
+ const explorer = document.querySelector(".explorer-ul")
if (!explorer) return
sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
})
@@ -262,9 +259,8 @@
await setupExplorer(currentSlug)
// if mobile hamburger is visible, collapse by default
- const mobileExplorer = document.getElementById("mobile-explorer")
- if (mobileExplorer && mobileExplorer.checkVisibility()) {
- for (const explorer of document.querySelectorAll(".explorer")) {
+ for (const explorer of document.getElementsByClassName("mobile-explorer")) {
+ if (explorer.checkVisibility()) {
explorer.classList.add("collapsed")
explorer.setAttribute("aria-expanded", "false")
}
diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss
index b769726..c284cb2 100644
--- a/quartz/components/styles/explorer.scss
+++ b/quartz/components/styles/explorer.scss
@@ -20,7 +20,7 @@
margin: 0;
}
- .hide-until-loaded ~ #explorer-content {
+ .hide-until-loaded ~ .explorer-content {
display: none;
}
}
@@ -30,6 +30,8 @@
display: flex;
flex-direction: column;
overflow-y: hidden;
+
+ min-height: 1.2rem;
flex: 0 1 auto;
&.collapsed {
flex: 0 1 1.2rem;
@@ -52,20 +54,20 @@
align-self: flex-start;
}
- button#mobile-explorer {
+ button.mobile-explorer {
display: none;
}
- button#desktop-explorer {
+ button.desktop-explorer {
display: flex;
}
@media all and ($mobile) {
- button#mobile-explorer {
+ button.mobile-explorer {
display: flex;
}
- button#desktop-explorer {
+ button.desktop-explorer {
display: none;
}
}
@@ -86,8 +88,8 @@
}
}
-button#mobile-explorer,
-button#desktop-explorer {
+button.mobile-explorer,
+button.desktop-explorer {
background-color: transparent;
border: none;
text-align: left;
@@ -104,7 +106,7 @@
}
}
-#explorer-content {
+.explorer-content {
list-style: none;
overflow: hidden;
overflow-y: auto;
@@ -209,7 +211,7 @@
&.collapsed {
flex: 0 0 34px;
- & > #explorer-content {
+ & > .explorer-content {
transform: translateX(-100vw);
visibility: hidden;
}
@@ -218,13 +220,13 @@
&:not(.collapsed) {
flex: 0 0 34px;
- & > #explorer-content {
+ & > .explorer-content {
transform: translateX(0);
visibility: visible;
}
}
- #explorer-content {
+ .explorer-content {
box-sizing: border-box;
z-index: 100;
position: absolute;
@@ -245,7 +247,7 @@
visibility: hidden;
}
- #mobile-explorer {
+ .mobile-explorer {
margin: 0;
padding: 5px;
z-index: 101;
diff --git a/quartz/components/styles/toc.scss b/quartz/components/styles/toc.scss
index 42aa35c..bc0c749 100644
--- a/quartz/components/styles/toc.scss
+++ b/quartz/components/styles/toc.scss
@@ -5,6 +5,7 @@
flex-direction: column;
overflow-y: hidden;
+ min-height: 4rem;
flex: 0 1 auto;
&:has(button.toc-header.collapsed) {
flex: 0 1 1.2rem;
diff --git a/quartz/components/types.ts b/quartz/components/types.ts
index a6b90d3..a07601a 100644
--- a/quartz/components/types.ts
+++ b/quartz/components/types.ts
@@ -1,5 +1,5 @@
import { ComponentType, JSX } from "preact"
-import { StaticResources } from "../util/resources"
+import { StaticResources, StringResource } from "../util/resources"
import { QuartzPluginData } from "../plugins/vfile"
import { GlobalConfiguration } from "../cfg"
import { Node } from "hast"
@@ -19,9 +19,9 @@
}
export type QuartzComponent = ComponentType<QuartzComponentProps> & {
- css?: string
- beforeDOMLoaded?: string
- afterDOMLoaded?: string
+ css?: StringResource
+ beforeDOMLoaded?: StringResource
+ afterDOMLoaded?: StringResource
}
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts
index 6c1e3d0..7584fdd 100644
--- a/quartz/plugins/emitters/componentResources.ts
+++ b/quartz/plugins/emitters/componentResources.ts
@@ -36,17 +36,21 @@
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) {
const { css, beforeDOMLoaded, afterDOMLoaded } = component
- if (css) {
- componentResources.css.add(css)
- }
- if (beforeDOMLoaded) {
- componentResources.beforeDOMLoaded.add(beforeDOMLoaded)
- }
- if (afterDOMLoaded) {
- componentResources.afterDOMLoaded.add(afterDOMLoaded)
- }
+ const normalizedCss = normalizeResource(css)
+ const normalizedBeforeDOMLoaded = normalizeResource(beforeDOMLoaded)
+ const normalizedAfterDOMLoaded = normalizeResource(afterDOMLoaded)
+
+ normalizedCss.forEach((c) => componentResources.css.add(c))
+ normalizedBeforeDOMLoaded.forEach((b) => componentResources.beforeDOMLoaded.add(b))
+ normalizedAfterDOMLoaded.forEach((a) => componentResources.afterDOMLoaded.add(a))
}
return {
diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss
index 9d9d638..25de448 100644
--- a/quartz/styles/base.scss
+++ b/quartz/styles/base.scss
@@ -542,7 +542,7 @@
}
.spacer {
- flex: 1 1 auto;
+ flex: 2 1 auto;
}
div:has(> .overflow) {
@@ -555,17 +555,14 @@
max-height: 100%;
overflow-y: auto;
width: 100%;
+ margin-bottom: 0;
// clearfix
content: "";
clear: both;
- & > li:last-of-type {
- margin-bottom: 30px;
- }
-
& > li.overflow-end {
- height: 4px;
+ height: 1rem;
margin: 0;
}
diff --git a/quartz/util/random.ts b/quartz/util/random.ts
new file mode 100644
index 0000000..df18022
--- /dev/null
+++ b/quartz/util/random.ts
@@ -0,0 +1,3 @@
+export function randomIdNonSecure() {
+ return Math.random().toString(36).substring(2, 8)
+}
diff --git a/quartz/util/resources.tsx b/quartz/util/resources.tsx
index 2ec8561..d95333e 100644
--- a/quartz/util/resources.tsx
+++ b/quartz/util/resources.tsx
@@ -65,3 +65,10 @@
js: JSResource[]
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()
+}
--
Gitblit v1.10.0