Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 651692f5e4 | |||
| 075afd3f71 | |||
| 74b3fc9efd | |||
| 7076a897e1 | |||
| 507ad7f3d4 | |||
| 41864a0eba | |||
| 508f73a33f | |||
| 6f27207fbf | |||
| 89fb2afe6f | |||
| a121fd2e96 |
@@ -11,3 +11,4 @@ private/
|
|||||||
replit.nix
|
replit.nix
|
||||||
.quartz/
|
.quartz/
|
||||||
quartz/cli/tui/dist/
|
quartz/cli/tui/dist/
|
||||||
|
.turbo/
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Define the directory to search in
|
||||||
|
BUILD_DIR="$HOME/quartz/"
|
||||||
|
CONTENT_DIR="$HOME/quartz/content"
|
||||||
|
|
||||||
|
# Find all .md files in the directory and remove all instances of `Blog/` to fix pathing
|
||||||
|
# Needed since all my articles are in the /Blog folder and it's subolfers in Obsidian instread of the base directory
|
||||||
|
find "$CONTENT_DIR" -type f -name "*.md" -print0 | while IFS= read -r -d $'\0' file; do
|
||||||
|
# Replace 'Blog/' with nothing in each file
|
||||||
|
sed -i 's|Blog/||g' "$file"
|
||||||
|
echo "Processed: $file"
|
||||||
|
done
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Find all markdown files in the content directory
|
||||||
|
find "$CONTENT_DIR" -name "*.md" -type f | while read -r file; do
|
||||||
|
echo "Processing: $file"
|
||||||
|
|
||||||
|
# Check if the file contains the Private section
|
||||||
|
if grep -q "^# Private$" "$file"; then
|
||||||
|
# Create a temporary file for processing
|
||||||
|
temp_file=$(mktemp)
|
||||||
|
|
||||||
|
# Use sed to remove content between # Private and # /Private inclusive
|
||||||
|
sed '/^# Private$/,/^# \/Private$/d' "$file" > "$temp_file"
|
||||||
|
|
||||||
|
# Replace original file with processed content
|
||||||
|
mv "$temp_file" "$file"
|
||||||
|
|
||||||
|
echo "Removed Private section from: $file"
|
||||||
|
else
|
||||||
|
echo "No Private section found in: $file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# echo "Processing complete!"
|
||||||
|
echo "All .md files have been processed."
|
||||||
|
|
||||||
|
cd "${BUILD_DIR}"
|
||||||
|
|
||||||
|
# build the html files
|
||||||
|
npx quartz build
|
||||||
|
|
||||||
|
# copy the files to the apache serve directory using a script
|
||||||
|
sudo /usr/local/bin/sync-quartz-site
|
||||||
@@ -412,13 +412,13 @@ export const MyFrame: PageFrame = {
|
|||||||
grid-template-areas: "center";
|
grid-template-areas: "center";
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
render({ componentData, pageBody: Content, footer: Footer }: PageFrameProps): unknown {
|
render({ componentData, pageBody: Content, footer: Footers }: PageFrameProps): unknown {
|
||||||
const renderSlot = (C: (props: typeof componentData) => unknown): ComponentChildren =>
|
const renderSlot = (C: (props: typeof componentData) => unknown): ComponentChildren =>
|
||||||
C(componentData) as ComponentChildren
|
C(componentData) as ComponentChildren
|
||||||
return (
|
return (
|
||||||
<div class="center">
|
<div class="center">
|
||||||
{(Content as any)(componentData)}
|
{(Content as any)(componentData)}
|
||||||
{(Footer as any)(componentData)}
|
{Footers.map((Footer) => (Footer as any)(componentData))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -428,7 +428,7 @@ export const MyFrame: PageFrame = {
|
|||||||
Key requirements:
|
Key requirements:
|
||||||
|
|
||||||
- `name`: A unique string identifier. This is what page types and YAML config reference.
|
- `name`: A unique string identifier. This is what page types and YAML config reference.
|
||||||
- `render()`: Receives all layout slots (header, sidebars, content, footer) and returns JSX for the inner page structure.
|
- `render()`: Receives all layout slots (header, sidebars, content, footer) and returns JSX for the inner page structure. Note that `footer` is a `QuartzComponent[]` (an array) — frames should iterate over it with `.map()` to render all footer components.
|
||||||
- `css` (optional): Frame-specific CSS. Scope it with `.page[data-frame="my-frame"]` selectors to avoid conflicts.
|
- `css` (optional): Frame-specific CSS. Scope it with `.page[data-frame="my-frame"]` selectors to avoid conflicts.
|
||||||
|
|
||||||
**2. Re-export the frame:**
|
**2. Re-export the frame:**
|
||||||
|
|||||||
@@ -28,6 +28,42 @@ npx quartz plugin install --latest
|
|||||||
|
|
||||||
See the [[upgrade|CLI reference for upgrade]] for more details on available flags.
|
See the [[upgrade|CLI reference for upgrade]] for more details on available flags.
|
||||||
|
|
||||||
|
### Layout System Changes
|
||||||
|
|
||||||
|
The `footer` layout slot is now an array of components, consistent with other layout slots like `header`, `left`, and `right`. Additionally, `header` and `footer` are now configurable layout positions — plugins can declare `layout: { position: header }` or `layout: { position: footer }` in their YAML config.
|
||||||
|
|
||||||
|
**If you override layouts in `quartz.ts`**, update any `footer` assignments to use arrays:
|
||||||
|
|
||||||
|
```ts title="quartz.ts"
|
||||||
|
// Before
|
||||||
|
export const layout = await loadQuartzLayout({
|
||||||
|
defaults: { footer: MyFooterComponent },
|
||||||
|
})
|
||||||
|
|
||||||
|
// After
|
||||||
|
export const layout = await loadQuartzLayout({
|
||||||
|
defaults: { footer: [MyFooterComponent] },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**If you have a custom page frame**, update the `render` function to iterate the footer array:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before
|
||||||
|
render({ footer: Footer, ...rest }: PageFrameProps) {
|
||||||
|
return <Footer {...componentData} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// After
|
||||||
|
render({ footer, ...rest }: PageFrameProps) {
|
||||||
|
return footer.map((F) => <F {...componentData} />)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both changes are caught by TypeScript at compile time — running `npx quartz build` will show the error.
|
||||||
|
|
||||||
|
No changes are needed for `quartz.config.yaml` — the YAML format is unchanged.
|
||||||
|
|
||||||
### Cleaning Up Unused Plugins
|
### Cleaning Up Unused Plugins
|
||||||
|
|
||||||
If you've removed plugins from your configuration during an upgrade, you can clean up the leftover files:
|
If you've removed plugins from your configuration during an upgrade, you can clean up the leftover files:
|
||||||
|
|||||||
+10
-2
@@ -20,7 +20,7 @@ export interface FullPageLayout {
|
|||||||
afterBody: QuartzComponent[] // laid out vertically
|
afterBody: QuartzComponent[] // laid out vertically
|
||||||
left: QuartzComponent[] // vertical on desktop and tablet, horizontal on mobile
|
left: QuartzComponent[] // vertical on desktop and tablet, horizontal on mobile
|
||||||
right: QuartzComponent[] // vertical on desktop, horizontal on tablet and mobile
|
right: QuartzComponent[] // vertical on desktop, horizontal on tablet and mobile
|
||||||
footer: QuartzComponent // single component
|
footer: QuartzComponent[] // laid out vertically
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ These correspond to following parts of the page:
|
|||||||
> There are two additional layout fields that are _not_ shown in the above diagram.
|
> There are two additional layout fields that are _not_ shown in the above diagram.
|
||||||
>
|
>
|
||||||
> 1. `head` is a single component that renders the `<head>` [tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/head) in the HTML. This doesn't appear visually on the page and is only is responsible for metadata about the document like the tab title, scripts, and styles.
|
> 1. `head` is a single component that renders the `<head>` [tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/head) in the HTML. This doesn't appear visually on the page and is only is responsible for metadata about the document like the tab title, scripts, and styles.
|
||||||
> 2. `header` is a set of components that are laid out horizontally and appears _before_ the `beforeBody` section. This enables you to replicate the old Quartz 3 header bar where the title, search bar, and dark mode toggle. By default, Quartz doesn't place any components in the `header`.
|
> 2. `header` is a set of components that are laid out horizontally and appears _before_ the `beforeBody` section. You can place components in the header by setting `layout.position: header` in your plugin configuration. This enables layouts similar to Quartz 3's header bar with title, search bar, and dark mode toggle.
|
||||||
|
|
||||||
Layout components are configured in the `layout` section of `quartz.config.yaml`. Plugins declare their position and priority, and the layout system arranges them automatically:
|
Layout components are configured in the `layout` section of `quartz.config.yaml`. Plugins declare their position and priority, and the layout system arranges them automatically:
|
||||||
|
|
||||||
@@ -77,12 +77,20 @@ plugins:
|
|||||||
layout:
|
layout:
|
||||||
position: beforeBody
|
position: beforeBody
|
||||||
priority: 30
|
priority: 30
|
||||||
|
- source: github:quartz-community/darkmode
|
||||||
|
enabled: true
|
||||||
|
layout:
|
||||||
|
position: header
|
||||||
|
priority: 10
|
||||||
- source: github:quartz-community/footer
|
- source: github:quartz-community/footer
|
||||||
enabled: true
|
enabled: true
|
||||||
options:
|
options:
|
||||||
links:
|
links:
|
||||||
GitHub: https://github.com/jackyzha0/quartz
|
GitHub: https://github.com/jackyzha0/quartz
|
||||||
Discord Community: https://discord.gg/cRFFHYye7t
|
Discord Community: https://discord.gg/cRFFHYye7t
|
||||||
|
layout:
|
||||||
|
position: footer
|
||||||
|
priority: 50
|
||||||
|
|
||||||
layout:
|
layout:
|
||||||
groups:
|
groups:
|
||||||
|
|||||||
Generated
+1567
-8
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -118,7 +118,7 @@
|
|||||||
"yargs": "^18.0.0"
|
"yargs": "^18.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@quartz-community/types": "^0.2.1",
|
"@quartz-community/types": "^0.3.0",
|
||||||
"@quartz-community/utils": "^0.1.0",
|
"@quartz-community/utils": "^0.1.0",
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
|
|||||||
@@ -205,6 +205,9 @@ plugins:
|
|||||||
links:
|
links:
|
||||||
GitHub: https://github.com/jackyzha0/quartz
|
GitHub: https://github.com/jackyzha0/quartz
|
||||||
Discord Community: https://discord.gg/cRFFHYye7t
|
Discord Community: https://discord.gg/cRFFHYye7t
|
||||||
|
layout:
|
||||||
|
position: footer
|
||||||
|
priority: 50
|
||||||
- source: "@quartz-community/recent-notes"
|
- source: "@quartz-community/recent-notes"
|
||||||
enabled: false
|
enabled: false
|
||||||
- source: "@quartz-community/spacer"
|
- source: "@quartz-community/spacer"
|
||||||
@@ -248,10 +251,6 @@ plugins:
|
|||||||
unlistWhenEncrypted: false
|
unlistWhenEncrypted: false
|
||||||
outputPath: static/encryptedContentIndex.json
|
outputPath: static/encryptedContentIndex.json
|
||||||
order: 900
|
order: 900
|
||||||
layout:
|
|
||||||
position: body
|
|
||||||
priority: 100
|
|
||||||
display: all
|
|
||||||
- source: "@quartz-community/stacked-pages"
|
- source: "@quartz-community/stacked-pages"
|
||||||
enabled: false
|
enabled: false
|
||||||
layout:
|
layout:
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# yaml-language-server: $schema=./quartz/plugins/quartz-plugins.schema.json
|
||||||
|
configuration:
|
||||||
|
pageTitle: "Yuriy's Notes"
|
||||||
|
pageTitleSuffix: ""
|
||||||
|
enableSPA: true
|
||||||
|
enablePopovers: true
|
||||||
|
analytics:
|
||||||
|
provider: plausible
|
||||||
|
locale: en-US
|
||||||
|
baseUrl: zinchuk.xyz
|
||||||
|
ignorePatterns:
|
||||||
|
- private
|
||||||
|
- templates
|
||||||
|
- .obsidian
|
||||||
|
theme:
|
||||||
|
fontOrigin: googleFonts
|
||||||
|
cdnCaching: false
|
||||||
|
typography:
|
||||||
|
header: Schibsted Grotesk
|
||||||
|
body: Source Sans Pro
|
||||||
|
code: IBM Plex Mono
|
||||||
|
colors:
|
||||||
|
lightMode:
|
||||||
|
light: "#faf8f8"
|
||||||
|
lightgray: "#e5e5e5"
|
||||||
|
gray: "#b8b8b8"
|
||||||
|
darkgray: "#4e4e4e"
|
||||||
|
dark: "#2b2b2b"
|
||||||
|
secondary: "#284b63"
|
||||||
|
tertiary: "#84a59d"
|
||||||
|
highlight: rgba(143, 159, 169, 0.15)
|
||||||
|
textHighlight: "#fff23688"
|
||||||
|
darkMode:
|
||||||
|
light: "#141618"
|
||||||
|
lightgray: "#43515f"
|
||||||
|
gray: "#646464"
|
||||||
|
darkgray: "#d4d4d4"
|
||||||
|
dark: "#ebebec"
|
||||||
|
secondary: "#7b97aa"
|
||||||
|
tertiary: "#84a59d"
|
||||||
|
highlight: rgba(143, 159, 169, 0.15)
|
||||||
|
textHighlight: "#b3aa0288"
|
||||||
|
plugins:
|
||||||
|
- source: "@quartz-community/created-modified-date"
|
||||||
|
enabled: true
|
||||||
|
options:
|
||||||
|
defaultDateType: modified
|
||||||
|
priority:
|
||||||
|
- frontmatter
|
||||||
|
- git
|
||||||
|
- filesystem
|
||||||
|
order: 10
|
||||||
|
- source: "@quartz-community/syntax-highlighting"
|
||||||
|
enabled: true
|
||||||
|
options:
|
||||||
|
theme:
|
||||||
|
light: github-light
|
||||||
|
dark: github-dark
|
||||||
|
keepBackground: false
|
||||||
|
order: 20
|
||||||
|
- source: "@quartz-community/obsidian-flavored-markdown"
|
||||||
|
enabled: true
|
||||||
|
options:
|
||||||
|
enableInHtmlEmbed: false
|
||||||
|
enableCheckbox: true
|
||||||
|
order: 30
|
||||||
|
- source: "@quartz-community/github-flavored-markdown"
|
||||||
|
enabled: true
|
||||||
|
order: 40
|
||||||
|
- source: "@quartz-community/table-of-contents"
|
||||||
|
enabled: true
|
||||||
|
order: 50
|
||||||
|
layout:
|
||||||
|
position: right
|
||||||
|
priority: 30
|
||||||
|
- source: "@quartz-community/crawl-links"
|
||||||
|
enabled: true
|
||||||
|
options:
|
||||||
|
markdownLinkResolution: shortest
|
||||||
|
order: 60
|
||||||
|
- source: "@quartz-community/description"
|
||||||
|
enabled: true
|
||||||
|
order: 70
|
||||||
|
- source: "@quartz-community/latex"
|
||||||
|
enabled: true
|
||||||
|
options:
|
||||||
|
renderEngine: katex
|
||||||
|
order: 80
|
||||||
|
- source: "@quartz-community/citations"
|
||||||
|
enabled: false
|
||||||
|
order: 85
|
||||||
|
- source: "@quartz-community/hard-line-breaks"
|
||||||
|
enabled: false
|
||||||
|
order: 90
|
||||||
|
- source: "@quartz-community/ox-hugo"
|
||||||
|
enabled: false
|
||||||
|
order: 91
|
||||||
|
- source: "@quartz-community/roam"
|
||||||
|
enabled: false
|
||||||
|
order: 92
|
||||||
|
- source: "@quartz-community/quartz-fonts"
|
||||||
|
enabled: true
|
||||||
|
- source: "@quartz-themes/core"
|
||||||
|
enabled: false
|
||||||
|
options:
|
||||||
|
theme: tokyo-night
|
||||||
|
mode: both
|
||||||
|
- source: "@quartz-community/remove-draft"
|
||||||
|
enabled: true
|
||||||
|
- source: "@quartz-community/explicit-publish"
|
||||||
|
enabled: false
|
||||||
|
- source: "@quartz-community/alias-redirects"
|
||||||
|
enabled: true
|
||||||
|
- source: "@quartz-community/content-index"
|
||||||
|
enabled: true
|
||||||
|
options:
|
||||||
|
enableSiteMap: true
|
||||||
|
enableRSS: true
|
||||||
|
- source: "@quartz-community/favicon"
|
||||||
|
enabled: true
|
||||||
|
- source: "@quartz-community/og-image"
|
||||||
|
enabled: true
|
||||||
|
- source: "@quartz-community/cname"
|
||||||
|
enabled: true
|
||||||
|
- source: "@quartz-community/canvas-page"
|
||||||
|
enabled: true
|
||||||
|
- source: "@quartz-community/content-page"
|
||||||
|
enabled: true
|
||||||
|
- source: "@quartz-community/folder-page"
|
||||||
|
enabled: true
|
||||||
|
- source: "@quartz-community/tag-page"
|
||||||
|
enabled: true
|
||||||
|
- source: "@quartz-community/explorer"
|
||||||
|
enabled: true
|
||||||
|
layout:
|
||||||
|
position: left
|
||||||
|
priority: 50
|
||||||
|
- source: "@quartz-community/graph"
|
||||||
|
enabled: true
|
||||||
|
layout:
|
||||||
|
position: right
|
||||||
|
priority: 10
|
||||||
|
- source: "@quartz-community/search"
|
||||||
|
enabled: true
|
||||||
|
layout:
|
||||||
|
position: left
|
||||||
|
priority: 20
|
||||||
|
group: toolbar
|
||||||
|
groupOptions:
|
||||||
|
grow: true
|
||||||
|
- source: "@quartz-community/backlinks"
|
||||||
|
enabled: true
|
||||||
|
layout:
|
||||||
|
position: right
|
||||||
|
priority: 50
|
||||||
|
- source: "@quartz-community/article-title"
|
||||||
|
enabled: true
|
||||||
|
layout:
|
||||||
|
position: beforeBody
|
||||||
|
priority: 10
|
||||||
|
- source: "@quartz-community/content-meta"
|
||||||
|
enabled: true
|
||||||
|
layout:
|
||||||
|
position: beforeBody
|
||||||
|
priority: 20
|
||||||
|
- source: "@quartz-community/tag-list"
|
||||||
|
enabled: false
|
||||||
|
layout:
|
||||||
|
position: beforeBody
|
||||||
|
priority: 30
|
||||||
|
- source: "@quartz-community/page-title"
|
||||||
|
enabled: true
|
||||||
|
layout:
|
||||||
|
position: left
|
||||||
|
priority: 10
|
||||||
|
- source: "@quartz-community/darkmode"
|
||||||
|
enabled: true
|
||||||
|
layout:
|
||||||
|
position: left
|
||||||
|
priority: 30
|
||||||
|
group: toolbar
|
||||||
|
- source: "@quartz-community/reader-mode"
|
||||||
|
enabled: true
|
||||||
|
layout:
|
||||||
|
position: left
|
||||||
|
priority: 35
|
||||||
|
group: toolbar
|
||||||
|
- source: "@quartz-community/breadcrumbs"
|
||||||
|
enabled: true
|
||||||
|
layout:
|
||||||
|
position: beforeBody
|
||||||
|
priority: 5
|
||||||
|
condition: not-index
|
||||||
|
- source: "@quartz-community/comments"
|
||||||
|
enabled: false
|
||||||
|
options:
|
||||||
|
provider: giscus
|
||||||
|
options: {}
|
||||||
|
layout:
|
||||||
|
position: afterBody
|
||||||
|
priority: 10
|
||||||
|
- source: "@quartz-community/footer"
|
||||||
|
enabled: true
|
||||||
|
options:
|
||||||
|
links: {}
|
||||||
|
layout:
|
||||||
|
position: footer
|
||||||
|
priority: 50
|
||||||
|
- source: "@quartz-community/recent-notes"
|
||||||
|
enabled: false
|
||||||
|
- source: "@quartz-community/spacer"
|
||||||
|
enabled: true
|
||||||
|
options: {}
|
||||||
|
order: 25
|
||||||
|
layout:
|
||||||
|
position: left
|
||||||
|
priority: 25
|
||||||
|
display: mobile-only
|
||||||
|
- source: "@quartz-community/bases-page"
|
||||||
|
enabled: true
|
||||||
|
options: {}
|
||||||
|
order: 50
|
||||||
|
- source: "@quartz-community/note-properties"
|
||||||
|
enabled: true
|
||||||
|
options:
|
||||||
|
includeAll: false
|
||||||
|
includedProperties:
|
||||||
|
- description
|
||||||
|
- tags
|
||||||
|
- aliases
|
||||||
|
excludedProperties: []
|
||||||
|
hidePropertiesView: false
|
||||||
|
delimiters: ---
|
||||||
|
language: yaml
|
||||||
|
order: 5
|
||||||
|
layout:
|
||||||
|
position: beforeBody
|
||||||
|
priority: 15
|
||||||
|
display: all
|
||||||
|
- source: "@quartz-community/unlisted-pages"
|
||||||
|
enabled: true
|
||||||
|
options: {}
|
||||||
|
order: 45
|
||||||
|
- source: "@quartz-community/encrypted-pages"
|
||||||
|
enabled: true
|
||||||
|
options:
|
||||||
|
iterations: 600000
|
||||||
|
passwordField: password
|
||||||
|
unlistWhenEncrypted: false
|
||||||
|
outputPath: static/encryptedContentIndex.json
|
||||||
|
order: 900
|
||||||
|
- source: "@quartz-community/stacked-pages"
|
||||||
|
enabled: false
|
||||||
|
layout:
|
||||||
|
position: afterBody
|
||||||
|
priority: 50
|
||||||
|
display: all
|
||||||
|
layout:
|
||||||
|
groups:
|
||||||
|
toolbar:
|
||||||
|
priority: 35
|
||||||
|
direction: row
|
||||||
|
gap: 0.5rem
|
||||||
|
byPageType:
|
||||||
|
"404":
|
||||||
|
positions:
|
||||||
|
beforeBody: []
|
||||||
|
left: []
|
||||||
|
right: []
|
||||||
|
content: {}
|
||||||
|
folder:
|
||||||
|
exclude:
|
||||||
|
- reader-mode
|
||||||
|
positions:
|
||||||
|
right: []
|
||||||
|
tag:
|
||||||
|
exclude:
|
||||||
|
- reader-mode
|
||||||
|
positions:
|
||||||
|
right: []
|
||||||
|
canvas: {}
|
||||||
|
bases: {}
|
||||||
+1
-1
@@ -97,7 +97,7 @@ export interface FullPageLayout {
|
|||||||
afterBody: QuartzComponent[]
|
afterBody: QuartzComponent[]
|
||||||
left: QuartzComponent[]
|
left: QuartzComponent[]
|
||||||
right: QuartzComponent[]
|
right: QuartzComponent[]
|
||||||
footer: QuartzComponent
|
footer: QuartzComponent[]
|
||||||
/** Page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */
|
/** Page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */
|
||||||
frame?: string
|
frame?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,9 @@ plugins:
|
|||||||
links:
|
links:
|
||||||
GitHub: https://github.com/jackyzha0/quartz
|
GitHub: https://github.com/jackyzha0/quartz
|
||||||
Discord Community: https://discord.gg/cRFFHYye7t
|
Discord Community: https://discord.gg/cRFFHYye7t
|
||||||
|
layout:
|
||||||
|
position: footer
|
||||||
|
priority: 50
|
||||||
- source: "@quartz-community/recent-notes"
|
- source: "@quartz-community/recent-notes"
|
||||||
enabled: true
|
enabled: true
|
||||||
options:
|
options:
|
||||||
|
|||||||
@@ -224,6 +224,9 @@ plugins:
|
|||||||
links:
|
links:
|
||||||
GitHub: https://github.com/jackyzha0/quartz
|
GitHub: https://github.com/jackyzha0/quartz
|
||||||
Discord Community: https://discord.gg/cRFFHYye7t
|
Discord Community: https://discord.gg/cRFFHYye7t
|
||||||
|
layout:
|
||||||
|
position: footer
|
||||||
|
priority: 50
|
||||||
- source: "@quartz-community/recent-notes"
|
- source: "@quartz-community/recent-notes"
|
||||||
enabled: false
|
enabled: false
|
||||||
- source: "@quartz-community/spacer"
|
- source: "@quartz-community/spacer"
|
||||||
|
|||||||
@@ -235,6 +235,9 @@ plugins:
|
|||||||
links:
|
links:
|
||||||
GitHub: https://github.com/jackyzha0/quartz
|
GitHub: https://github.com/jackyzha0/quartz
|
||||||
Discord Community: https://discord.gg/cRFFHYye7t
|
Discord Community: https://discord.gg/cRFFHYye7t
|
||||||
|
layout:
|
||||||
|
position: footer
|
||||||
|
priority: 50
|
||||||
- source: "@quartz-community/recent-notes"
|
- source: "@quartz-community/recent-notes"
|
||||||
enabled: false
|
enabled: false
|
||||||
- source: "@quartz-community/spacer"
|
- source: "@quartz-community/spacer"
|
||||||
|
|||||||
@@ -236,6 +236,9 @@ plugins:
|
|||||||
links:
|
links:
|
||||||
GitHub: https://github.com/jackyzha0/quartz
|
GitHub: https://github.com/jackyzha0/quartz
|
||||||
Discord Community: https://discord.gg/cRFFHYye7t
|
Discord Community: https://discord.gg/cRFFHYye7t
|
||||||
|
layout:
|
||||||
|
position: footer
|
||||||
|
priority: 50
|
||||||
- source: "@quartz-community/recent-notes"
|
- source: "@quartz-community/recent-notes"
|
||||||
enabled: false
|
enabled: false
|
||||||
- source: "@quartz-community/spacer"
|
- source: "@quartz-community/spacer"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const DefaultFrame: PageFrame = {
|
|||||||
afterBody,
|
afterBody,
|
||||||
left,
|
left,
|
||||||
right,
|
right,
|
||||||
footer: Footer,
|
footer,
|
||||||
}: PageFrameProps) {
|
}: PageFrameProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -54,7 +54,9 @@ export const DefaultFrame: PageFrame = {
|
|||||||
<BodyComponent {...componentData} />
|
<BodyComponent {...componentData} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Footer {...componentData} />
|
{footer.map((FooterComponent) => (
|
||||||
|
<FooterComponent {...componentData} />
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const FullWidthFrame: PageFrame = {
|
|||||||
beforeBody,
|
beforeBody,
|
||||||
pageBody: Content,
|
pageBody: Content,
|
||||||
afterBody,
|
afterBody,
|
||||||
footer: Footer,
|
footer,
|
||||||
}: PageFrameProps) {
|
}: PageFrameProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -44,7 +44,9 @@ export const FullWidthFrame: PageFrame = {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Footer {...componentData} />
|
{footer.map((FooterComponent) => (
|
||||||
|
<FooterComponent {...componentData} />
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ import { PageFrame, PageFrameProps } from "./types"
|
|||||||
*/
|
*/
|
||||||
export const MinimalFrame: PageFrame = {
|
export const MinimalFrame: PageFrame = {
|
||||||
name: "minimal",
|
name: "minimal",
|
||||||
render({ componentData, pageBody: Content, footer: Footer }: PageFrameProps) {
|
render({ componentData, pageBody: Content, footer }: PageFrameProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="center minimal">
|
<div class="center minimal">
|
||||||
<Content {...componentData} />
|
<Content {...componentData} />
|
||||||
</div>
|
</div>
|
||||||
<Footer {...componentData} />
|
{footer.map((FooterComponent) => (
|
||||||
|
<FooterComponent {...componentData} />
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import test, { describe } from "node:test"
|
||||||
|
import assert from "node:assert"
|
||||||
|
import { resolveFrame, frameRegistry } from "./index"
|
||||||
|
import { DefaultFrame } from "./DefaultFrame"
|
||||||
|
import { FullWidthFrame } from "./FullWidthFrame"
|
||||||
|
import type { PageFrame } from "./types"
|
||||||
|
|
||||||
|
const customFrame: PageFrame = {
|
||||||
|
name: "custom-test-frame",
|
||||||
|
render: () => null as any,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveFrame", () => {
|
||||||
|
test("returns DefaultFrame for undefined", () => {
|
||||||
|
assert.strictEqual(resolveFrame(undefined), DefaultFrame)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns DefaultFrame for 'default'", () => {
|
||||||
|
assert.strictEqual(resolveFrame("default"), DefaultFrame)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns named built-in frame", () => {
|
||||||
|
assert.strictEqual(resolveFrame("full-width"), FullWidthFrame)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns DefaultFrame for unknown frame name", () => {
|
||||||
|
assert.strictEqual(resolveFrame("nonexistent"), DefaultFrame)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("plugin-registered frame takes priority", () => {
|
||||||
|
frameRegistry.register("custom-test-frame", customFrame, "test-plugin")
|
||||||
|
const result = resolveFrame("custom-test-frame")
|
||||||
|
assert.strictEqual(result, customFrame)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns DefaultFrame for unknown name even with plugin frames registered", () => {
|
||||||
|
frameRegistry.register("custom-test-frame", customFrame, "test-plugin")
|
||||||
|
const result = resolveFrame("totally-unknown")
|
||||||
|
assert.strictEqual(result, DefaultFrame)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -22,8 +22,8 @@ export interface PageFrameProps {
|
|||||||
left: QuartzComponent[]
|
left: QuartzComponent[]
|
||||||
/** Right sidebar components */
|
/** Right sidebar components */
|
||||||
right: QuartzComponent[]
|
right: QuartzComponent[]
|
||||||
/** Footer component */
|
/** Footer components */
|
||||||
footer: QuartzComponent
|
footer: QuartzComponent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import test, { describe, afterEach } from "node:test"
|
||||||
|
import assert from "node:assert"
|
||||||
|
import { ComponentRegistry } from "./registry"
|
||||||
|
import { QuartzComponent, QuartzComponentConstructor } from "./types"
|
||||||
|
|
||||||
|
const StubA = (() => null) as unknown as QuartzComponent
|
||||||
|
const StubB = (() => null) as unknown as QuartzComponent
|
||||||
|
StubA.displayName = "StubA"
|
||||||
|
StubB.displayName = "StubB"
|
||||||
|
|
||||||
|
const StubConstructor = ((opts?: any) => {
|
||||||
|
const c = (() => null) as unknown as QuartzComponent
|
||||||
|
c.displayName = opts?.name ?? "stub"
|
||||||
|
return c
|
||||||
|
}) as unknown as QuartzComponentConstructor
|
||||||
|
|
||||||
|
let registry: ComponentRegistry | null = null
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
registry?.clear()
|
||||||
|
registry = null
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("register and get", () => {
|
||||||
|
test("registers a component and retrieves it by name", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
registry.register("foo", StubA, "source")
|
||||||
|
|
||||||
|
const result = registry.get("foo")
|
||||||
|
assert.strictEqual(result?.component, StubA)
|
||||||
|
assert.strictEqual(result?.source, "source")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns undefined for unregistered names", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
const result = registry.get("nonexistent")
|
||||||
|
assert.strictEqual(result, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("overwrites component from different source", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
registry.register("foo", StubA, "src1")
|
||||||
|
registry.register("foo", StubB, "src2")
|
||||||
|
|
||||||
|
const result = registry.get("foo")
|
||||||
|
assert.strictEqual(result?.component, StubB)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("instantiate", () => {
|
||||||
|
test("returns a component instance from a constructor", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
const instance = registry.instantiate(StubConstructor)
|
||||||
|
assert.ok(instance)
|
||||||
|
assert.strictEqual(typeof instance, "function")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("caches instances by constructor + options", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
const first = registry.instantiate(StubConstructor)
|
||||||
|
const second = registry.instantiate(StubConstructor)
|
||||||
|
assert.strictEqual(first, second)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("different options produce different instances", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
const first = registry.instantiate(StubConstructor, { a: 1 })
|
||||||
|
const second = registry.instantiate(StubConstructor, { a: 2 })
|
||||||
|
assert.notStrictEqual(first, second)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("undefined options and no-arg call produce same cache key", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
const first = registry.instantiate(StubConstructor)
|
||||||
|
const second = registry.instantiate(StubConstructor, undefined)
|
||||||
|
assert.strictEqual(first, second)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getAllComponents", () => {
|
||||||
|
test("deduplicates components registered under multiple names", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
registry.register("foo", StubConstructor, "source")
|
||||||
|
registry.register("bar", StubConstructor, "source")
|
||||||
|
|
||||||
|
const result = registry.getAllComponents()
|
||||||
|
assert.strictEqual(result.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reuses cached instance from prior instantiate call", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
registry.register("x", StubConstructor, "source")
|
||||||
|
const instance = registry.instantiate(StubConstructor, { opt: 1 })
|
||||||
|
|
||||||
|
const result = registry.getAllComponents()
|
||||||
|
assert.strictEqual(result[0], instance)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips components that fail to instantiate", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
const ThrowingConstructor = (() => {
|
||||||
|
throw new Error("boom")
|
||||||
|
}) as unknown as QuartzComponentConstructor
|
||||||
|
|
||||||
|
registry.register("bad", ThrowingConstructor, "source")
|
||||||
|
const result = registry.getAllComponents()
|
||||||
|
assert.deepStrictEqual(result, [])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("setOptionOverrides and cache invalidation", () => {
|
||||||
|
test("stores and retrieves option overrides", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
registry.setOptionOverrides("plugin", { key: "val" })
|
||||||
|
assert.deepStrictEqual(registry.getOptionOverrides("plugin"), { key: "val" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("merges with existing overrides", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
registry.setOptionOverrides("plugin", { a: 1 })
|
||||||
|
registry.setOptionOverrides("plugin", { b: 2 })
|
||||||
|
assert.deepStrictEqual(registry.getOptionOverrides("plugin"), { a: 1, b: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears instance cache when overrides change", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
const first = registry.instantiate(StubConstructor, { name: "cached" })
|
||||||
|
registry.setOptionOverrides("anything", { trigger: true })
|
||||||
|
const second = registry.instantiate(StubConstructor, { name: "cached" })
|
||||||
|
assert.notStrictEqual(first, second)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ignores empty or undefined overrides", () => {
|
||||||
|
registry = new ComponentRegistry()
|
||||||
|
registry.setOptionOverrides("plugin", {})
|
||||||
|
assert.strictEqual(registry.getOptionOverrides("plugin"), undefined)
|
||||||
|
registry.setOptionOverrides("plugin", undefined)
|
||||||
|
assert.strictEqual(registry.getOptionOverrides("plugin"), undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,8 @@ export interface ComponentManifest {
|
|||||||
quartzVersion?: string
|
quartzVersion?: string
|
||||||
author?: string
|
author?: string
|
||||||
homepage?: string
|
homepage?: string
|
||||||
|
defaultPosition?: string
|
||||||
|
defaultPriority?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisteredComponent {
|
export interface RegisteredComponent {
|
||||||
@@ -16,7 +18,8 @@ export interface RegisteredComponent {
|
|||||||
manifest?: ComponentManifest
|
manifest?: ComponentManifest
|
||||||
}
|
}
|
||||||
|
|
||||||
class ComponentRegistry {
|
/** @internal Exported for testing only. */
|
||||||
|
export class ComponentRegistry {
|
||||||
private components = new Map<string, RegisteredComponent>()
|
private components = new Map<string, RegisteredComponent>()
|
||||||
private instanceCache = new Map<string, QuartzComponent>()
|
private instanceCache = new Map<string, QuartzComponent>()
|
||||||
private optionOverrides = new Map<string, Record<string, unknown>>()
|
private optionOverrides = new Map<string, Record<string, unknown>>()
|
||||||
@@ -109,6 +112,13 @@ class ComponentRegistry {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal For testing only — resets all registry state. */
|
||||||
|
clear(): void {
|
||||||
|
this.components.clear()
|
||||||
|
this.instanceCache.clear()
|
||||||
|
this.optionOverrides.clear()
|
||||||
|
}
|
||||||
|
|
||||||
private findCachedInstance(
|
private findCachedInstance(
|
||||||
constructor: QuartzComponentConstructor<any>,
|
constructor: QuartzComponentConstructor<any>,
|
||||||
): QuartzComponent | undefined {
|
): QuartzComponent | undefined {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ interface RenderComponents {
|
|||||||
afterBody: QuartzComponent[]
|
afterBody: QuartzComponent[]
|
||||||
left: QuartzComponent[]
|
left: QuartzComponent[]
|
||||||
right: QuartzComponent[]
|
right: QuartzComponent[]
|
||||||
footer: QuartzComponent
|
footer: QuartzComponent[]
|
||||||
frame?: string
|
frame?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +329,7 @@ export function renderPage(
|
|||||||
afterBody,
|
afterBody,
|
||||||
left,
|
left,
|
||||||
right,
|
right,
|
||||||
footer: Footer,
|
footer,
|
||||||
frame: frameName,
|
frame: frameName,
|
||||||
} = components
|
} = components
|
||||||
const Body = BodyConstructor()
|
const Body = BodyConstructor()
|
||||||
@@ -360,7 +360,7 @@ export function renderPage(
|
|||||||
afterBody,
|
afterBody,
|
||||||
left,
|
left,
|
||||||
right,
|
right,
|
||||||
footer: Footer,
|
footer,
|
||||||
}),
|
}),
|
||||||
]}
|
]}
|
||||||
</Body>
|
</Body>
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import test, { describe, afterEach } from "node:test"
|
||||||
|
import assert from "node:assert"
|
||||||
|
import { buildLayoutForEntries, resolveGroups } from "./config-loader"
|
||||||
|
import { componentRegistry } from "../../components/registry"
|
||||||
|
import type { QuartzComponent, QuartzComponentConstructor } from "../../components/types"
|
||||||
|
import { PluginJsonEntry, LayoutPosition } from "./types"
|
||||||
|
|
||||||
|
const makeComponent = (name: string): QuartzComponent => {
|
||||||
|
const c = (() => null) as unknown as QuartzComponent
|
||||||
|
c.displayName = name
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeConstructor = (name: string): QuartzComponentConstructor => {
|
||||||
|
return () => makeComponent(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEntry(
|
||||||
|
source: string,
|
||||||
|
layout?: { position: LayoutPosition; priority: number },
|
||||||
|
): PluginJsonEntry {
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
enabled: true,
|
||||||
|
options: {},
|
||||||
|
...(layout ? { layout: { position: layout.position, priority: layout.priority } } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
componentRegistry.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("position assignment", () => {
|
||||||
|
test("places component in correct position from layout.position", () => {
|
||||||
|
const component = makeComponent("MyPlugin")
|
||||||
|
componentRegistry.register("my-plugin", component, "test-source")
|
||||||
|
|
||||||
|
const result = buildLayoutForEntries(
|
||||||
|
[makeEntry("my-plugin", { position: "left", priority: 10 })],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert.deepStrictEqual(result.left, [component])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("places component in footer position", () => {
|
||||||
|
const component = makeComponent("FooterComp")
|
||||||
|
componentRegistry.register("footer-comp", component, "test-source")
|
||||||
|
|
||||||
|
const result = buildLayoutForEntries(
|
||||||
|
[makeEntry("footer-comp", { position: "footer", priority: 20 })],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert.deepStrictEqual(result.footer, [component])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("places component in header position", () => {
|
||||||
|
const component = makeComponent("HeaderComp")
|
||||||
|
componentRegistry.register("header-comp", component, "test-source")
|
||||||
|
|
||||||
|
const result = buildLayoutForEntries(
|
||||||
|
[makeEntry("header-comp", { position: "header", priority: 5 })],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert.deepStrictEqual(result.header, [component])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns empty arrays when no entries have layout", () => {
|
||||||
|
const component = makeComponent("NoLayout")
|
||||||
|
componentRegistry.register("no-layout", component, "test-source")
|
||||||
|
|
||||||
|
const result = buildLayoutForEntries([makeEntry("no-layout")], {})
|
||||||
|
assert.deepStrictEqual(result.header, [])
|
||||||
|
assert.deepStrictEqual(result.left, [])
|
||||||
|
assert.deepStrictEqual(result.right, [])
|
||||||
|
assert.deepStrictEqual(result.beforeBody, [])
|
||||||
|
assert.deepStrictEqual(result.afterBody, [])
|
||||||
|
assert.deepStrictEqual(result.footer, [])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("defaultPosition fallback", () => {
|
||||||
|
test("uses manifest defaultPosition when no explicit layout", () => {
|
||||||
|
const component = makeComponent("F")
|
||||||
|
componentRegistry.register("f", component, "test-source", {
|
||||||
|
name: "f",
|
||||||
|
displayName: "F",
|
||||||
|
description: "",
|
||||||
|
version: "1",
|
||||||
|
defaultPosition: "footer",
|
||||||
|
defaultPriority: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = buildLayoutForEntries([makeEntry("f")], {})
|
||||||
|
assert.deepStrictEqual(result.footer, [component])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("explicit layout takes precedence over defaultPosition", () => {
|
||||||
|
const component = makeComponent("P")
|
||||||
|
componentRegistry.register("p", component, "test-source", {
|
||||||
|
name: "p",
|
||||||
|
displayName: "P",
|
||||||
|
description: "",
|
||||||
|
version: "1",
|
||||||
|
defaultPosition: "right",
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = buildLayoutForEntries([makeEntry("p", { position: "left", priority: 10 })], {})
|
||||||
|
assert.deepStrictEqual(result.left, [component])
|
||||||
|
assert.deepStrictEqual(result.right, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("silently skips invalid defaultPosition", () => {
|
||||||
|
const component = makeComponent("Bad")
|
||||||
|
componentRegistry.register("bad", component, "test-source", {
|
||||||
|
name: "bad",
|
||||||
|
displayName: "Bad",
|
||||||
|
description: "",
|
||||||
|
version: "1",
|
||||||
|
defaultPosition: "body",
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = buildLayoutForEntries([makeEntry("bad")], {})
|
||||||
|
assert.deepStrictEqual(result.header, [])
|
||||||
|
assert.deepStrictEqual(result.left, [])
|
||||||
|
assert.deepStrictEqual(result.right, [])
|
||||||
|
assert.deepStrictEqual(result.beforeBody, [])
|
||||||
|
assert.deepStrictEqual(result.afterBody, [])
|
||||||
|
assert.deepStrictEqual(result.footer, [])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("priority sorting", () => {
|
||||||
|
test("sorts components within a position by priority", () => {
|
||||||
|
const compA = makeComponent("A")
|
||||||
|
const compB = makeComponent("B")
|
||||||
|
const compC = makeComponent("C")
|
||||||
|
componentRegistry.register("a", compA, "test-source")
|
||||||
|
componentRegistry.register("b", compB, "test-source")
|
||||||
|
componentRegistry.register("c", compC, "test-source")
|
||||||
|
|
||||||
|
const result = buildLayoutForEntries(
|
||||||
|
[
|
||||||
|
makeEntry("a", { position: "left", priority: 30 }),
|
||||||
|
makeEntry("b", { position: "left", priority: 10 }),
|
||||||
|
makeEntry("c", { position: "left", priority: 20 }),
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
const names = result.left?.map((component) => component.displayName)
|
||||||
|
assert.deepStrictEqual(names, ["B", "C", "A"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("defaults to priority 50 for defaultPosition without defaultPriority", () => {
|
||||||
|
const explicit = makeComponent("Explicit")
|
||||||
|
const ctor = makeConstructor("Default")
|
||||||
|
const defaulted = ctor(undefined)
|
||||||
|
|
||||||
|
componentRegistry.register("explicit", explicit, "test-source")
|
||||||
|
componentRegistry.register("defaulted", defaulted, "test-source", {
|
||||||
|
name: "defaulted",
|
||||||
|
displayName: "Default",
|
||||||
|
description: "",
|
||||||
|
version: "1",
|
||||||
|
defaultPosition: "left",
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = buildLayoutForEntries(
|
||||||
|
[makeEntry("explicit", { position: "left", priority: 40 }), makeEntry("defaulted")],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
const names = result.left?.map((component) => component.displayName)
|
||||||
|
assert.deepStrictEqual(names, ["Explicit", "Default"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveGroups", () => {
|
||||||
|
test("returns ungrouped items in priority order", () => {
|
||||||
|
const a = makeComponent("A")
|
||||||
|
const b = makeComponent("B")
|
||||||
|
const c = makeComponent("C")
|
||||||
|
const items = [
|
||||||
|
{ component: a, priority: 30 },
|
||||||
|
{ component: b, priority: 10 },
|
||||||
|
{ component: c, priority: 20 },
|
||||||
|
]
|
||||||
|
const result = resolveGroups(items, {})
|
||||||
|
assert.strictEqual(result.length, 3)
|
||||||
|
assert.strictEqual(result[0], b)
|
||||||
|
assert.strictEqual(result[1], c)
|
||||||
|
assert.strictEqual(result[2], a)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns ungrouped items unchanged", () => {
|
||||||
|
const a = makeComponent("A")
|
||||||
|
const items = [{ component: a, priority: 10 }]
|
||||||
|
const result = resolveGroups(items, {})
|
||||||
|
assert.strictEqual(result.length, 1)
|
||||||
|
assert.strictEqual(result[0], a)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns empty array for empty input", () => {
|
||||||
|
const result = resolveGroups([], {})
|
||||||
|
assert.deepStrictEqual(result, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("wraps grouped items in a Flex component", () => {
|
||||||
|
const a = makeComponent("A")
|
||||||
|
const b = makeComponent("B")
|
||||||
|
const items = [
|
||||||
|
{ component: a, priority: 10, group: "toolbar" },
|
||||||
|
{ component: b, priority: 20, group: "toolbar" },
|
||||||
|
]
|
||||||
|
const result = resolveGroups(items, {})
|
||||||
|
assert.strictEqual(result.length, 1)
|
||||||
|
assert.notStrictEqual(result[0], a)
|
||||||
|
assert.notStrictEqual(result[0], b)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses explicit group priority from config", () => {
|
||||||
|
const grouped = makeComponent("Grouped")
|
||||||
|
const ungrouped = makeComponent("Ungrouped")
|
||||||
|
const items = [
|
||||||
|
{ component: grouped, priority: 50, group: "nav" },
|
||||||
|
{ component: ungrouped, priority: 10 },
|
||||||
|
]
|
||||||
|
const result = resolveGroups(items, { nav: { priority: 5 } })
|
||||||
|
assert.strictEqual(result.length, 2)
|
||||||
|
assert.strictEqual(result[1], ungrouped)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to first member priority when no group config", () => {
|
||||||
|
const a = makeComponent("A")
|
||||||
|
const b = makeComponent("B")
|
||||||
|
const solo = makeComponent("Solo")
|
||||||
|
const items = [
|
||||||
|
{ component: a, priority: 20, group: "nav" },
|
||||||
|
{ component: b, priority: 40, group: "nav" },
|
||||||
|
{ component: solo, priority: 30 },
|
||||||
|
]
|
||||||
|
const result = resolveGroups(items, {})
|
||||||
|
assert.strictEqual(result.length, 2)
|
||||||
|
assert.strictEqual(result[1], solo)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("single-member group still wraps in Flex", () => {
|
||||||
|
const a = makeComponent("A")
|
||||||
|
const items = [{ component: a, priority: 10, group: "solo" }]
|
||||||
|
const result = resolveGroups(items, {})
|
||||||
|
assert.strictEqual(result.length, 1)
|
||||||
|
assert.notStrictEqual(result[0], a)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("buildLayoutForEntries with display wrappers", () => {
|
||||||
|
test("applies display wrapper for mobile-only", () => {
|
||||||
|
const component = makeComponent("Wrapped")
|
||||||
|
componentRegistry.register("wrapped-plugin", component, "test-source")
|
||||||
|
|
||||||
|
const entry: PluginJsonEntry = {
|
||||||
|
source: "wrapped-plugin",
|
||||||
|
enabled: true,
|
||||||
|
options: {},
|
||||||
|
layout: { position: "left" as LayoutPosition, priority: 10, display: "mobile-only" },
|
||||||
|
}
|
||||||
|
const result = buildLayoutForEntries([entry], {})
|
||||||
|
assert.strictEqual(result.left?.length, 1)
|
||||||
|
assert.notStrictEqual(result.left?.[0], component)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("buildLayoutForEntries with constructors", () => {
|
||||||
|
test("instantiates constructor components via registry", () => {
|
||||||
|
const ctor = makeConstructor("Instantiated")
|
||||||
|
componentRegistry.register("ctor-plugin", ctor, "test-source")
|
||||||
|
|
||||||
|
const result = buildLayoutForEntries(
|
||||||
|
[makeEntry("ctor-plugin", { position: "left", priority: 10 })],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert.strictEqual(result.left?.length, 1)
|
||||||
|
assert.strictEqual(result.left?.[0].displayName, "Instantiated")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("merges entry options with TS overrides for constructors", () => {
|
||||||
|
const ctor = makeConstructor("Merged")
|
||||||
|
componentRegistry.register("merge-plugin", ctor, "test-source")
|
||||||
|
componentRegistry.setOptionOverrides("merge-plugin", { extra: true })
|
||||||
|
|
||||||
|
const result = buildLayoutForEntries(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
source: "merge-plugin",
|
||||||
|
enabled: true,
|
||||||
|
options: { base: 1 },
|
||||||
|
layout: { position: "right" as LayoutPosition, priority: 10 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert.strictEqual(result.right?.length, 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,7 +2,7 @@ import fs from "fs"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import YAML from "yaml"
|
import YAML from "yaml"
|
||||||
import { styleText } from "util"
|
import { styleText } from "util"
|
||||||
import { createRequire } from "node:module"
|
import { fileURLToPath } from "node:url"
|
||||||
import { QuartzConfig, GlobalConfiguration, FullPageLayout } from "../../cfg"
|
import { QuartzConfig, GlobalConfiguration, FullPageLayout } from "../../cfg"
|
||||||
import { QuartzComponent, QuartzComponentConstructor } from "../../components/types"
|
import { QuartzComponent, QuartzComponentConstructor } from "../../components/types"
|
||||||
import { PluginTypes } from "../types"
|
import { PluginTypes } from "../types"
|
||||||
@@ -27,6 +27,10 @@ import { loadComponentsFromPackage } from "./componentLoader"
|
|||||||
import { loadFramesFromPackage } from "./frameLoader"
|
import { loadFramesFromPackage } from "./frameLoader"
|
||||||
import { componentRegistry } from "../../components/registry"
|
import { componentRegistry } from "../../components/registry"
|
||||||
import { getCondition } from "./conditions"
|
import { getCondition } from "./conditions"
|
||||||
|
import Flex from "../../components/Flex"
|
||||||
|
import MobileOnly from "../../components/MobileOnly"
|
||||||
|
import DesktopOnly from "../../components/DesktopOnly"
|
||||||
|
import ConditionalRender from "../../components/ConditionalRender"
|
||||||
|
|
||||||
const CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.yaml")
|
const CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.yaml")
|
||||||
const DEFAULT_CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.default.yaml")
|
const DEFAULT_CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.default.yaml")
|
||||||
@@ -201,10 +205,9 @@ async function resolvePluginManifest(source: PluginSource): Promise<PluginManife
|
|||||||
async function readManifestFromPackageJson(source: PluginSource): Promise<PluginManifest | null> {
|
async function readManifestFromPackageJson(source: PluginSource): Promise<PluginManifest | null> {
|
||||||
try {
|
try {
|
||||||
const gitSpec = parsePluginSource(source)
|
const gitSpec = parsePluginSource(source)
|
||||||
const require = createRequire(import.meta.url)
|
|
||||||
let pkgPath: string
|
let pkgPath: string
|
||||||
if (gitSpec.npmPackage) {
|
if (gitSpec.npmPackage) {
|
||||||
pkgPath = require.resolve(`${gitSpec.name}/package.json`, { paths: [process.cwd()] })
|
pkgPath = fileURLToPath(import.meta.resolve(`${gitSpec.name}/package.json`))
|
||||||
} else {
|
} else {
|
||||||
const pluginDir = path.join(process.cwd(), ".quartz", "plugins", gitSpec.name)
|
const pluginDir = path.join(process.cwd(), ".quartz", "plugins", gitSpec.name)
|
||||||
pkgPath = path.join(pluginDir, "package.json")
|
pkgPath = path.join(pluginDir, "package.json")
|
||||||
@@ -644,7 +647,7 @@ export async function loadQuartzLayout(layoutOverrides?: {
|
|||||||
return oldLayout.layout
|
return oldLayout.layout
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledWithLayout = json.plugins.filter((e) => e.enabled && e.layout)
|
const enabledWithLayout = json.plugins.filter((e) => e.enabled)
|
||||||
const layoutConfig = json.layout ?? {}
|
const layoutConfig = json.layout ?? {}
|
||||||
|
|
||||||
// Build default layout for all page types
|
// Build default layout for all page types
|
||||||
@@ -672,7 +675,7 @@ export async function loadQuartzLayout(layoutOverrides?: {
|
|||||||
if (Array.isArray(components) && components.length === 0) {
|
if (Array.isArray(components) && components.length === 0) {
|
||||||
const key = pos as keyof Pick<
|
const key = pos as keyof Pick<
|
||||||
FullPageLayout,
|
FullPageLayout,
|
||||||
"left" | "right" | "beforeBody" | "afterBody"
|
"header" | "left" | "right" | "beforeBody" | "afterBody" | "footer"
|
||||||
>
|
>
|
||||||
if (key in ptLayout) {
|
if (key in ptLayout) {
|
||||||
;(ptLayout as Record<string, unknown>)[key] = []
|
;(ptLayout as Record<string, unknown>)[key] = []
|
||||||
@@ -690,46 +693,20 @@ export async function loadQuartzLayout(layoutOverrides?: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Head (built-in) and Footer (plugin)
|
|
||||||
const HeadModule = await import("../../components/Head")
|
const HeadModule = await import("../../components/Head")
|
||||||
const head = HeadModule.default()
|
const head = HeadModule.default()
|
||||||
|
|
||||||
// Find footer from component registry (loaded during plugin instantiation)
|
|
||||||
const footerEntry = json.plugins.find(
|
|
||||||
(e) => e.enabled && extractPluginName(e.source) === "footer",
|
|
||||||
)
|
|
||||||
let footer: QuartzComponent | undefined
|
|
||||||
if (footerEntry) {
|
|
||||||
// Try registry lookup: plugin name ("footer") or export name ("Footer")
|
|
||||||
const footerReg = componentRegistry.get("footer") ?? componentRegistry.get("Footer")
|
|
||||||
if (footerReg) {
|
|
||||||
if (typeof footerReg.component === "function" && !("displayName" in footerReg.component)) {
|
|
||||||
// It's a constructor — use registry cache for consistent instances
|
|
||||||
const footerOverrides = componentRegistry.getOptionOverrides("footer")
|
|
||||||
const opts = { ...footerEntry.options, ...footerOverrides }
|
|
||||||
footer = componentRegistry.instantiate(
|
|
||||||
footerReg.component as QuartzComponentConstructor,
|
|
||||||
Object.keys(opts).length > 0 ? opts : undefined,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
footer = footerReg.component as QuartzComponent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply structural defaults
|
// Apply structural defaults
|
||||||
defaultLayout.head = head
|
defaultLayout.head = head
|
||||||
defaultLayout.header = defaultLayout.header ?? []
|
defaultLayout.header = defaultLayout.header ?? []
|
||||||
if (footer) {
|
defaultLayout.footer = defaultLayout.footer ?? []
|
||||||
defaultLayout.footer = footer
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure all byPageType entries inherit structural slots
|
// Ensure all byPageType entries inherit structural slots
|
||||||
for (const pageType of Object.keys(byPageType)) {
|
for (const pageType of Object.keys(byPageType)) {
|
||||||
const pt = byPageType[pageType]
|
const pt = byPageType[pageType]
|
||||||
if (!pt.head) pt.head = head
|
if (!pt.head) pt.head = head
|
||||||
if (!pt.header) pt.header = []
|
if (!pt.header) pt.header = defaultLayout.header
|
||||||
if (footer && !pt.footer) pt.footer = footer
|
if (!pt.footer) pt.footer = defaultLayout.footer
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedDefaults = { ...defaultLayout, ...layoutOverrides?.defaults }
|
const mergedDefaults = { ...defaultLayout, ...layoutOverrides?.defaults }
|
||||||
@@ -743,7 +720,8 @@ export async function loadQuartzLayout(layoutOverrides?: {
|
|||||||
return { defaults: mergedDefaults, byPageType: mergedByPageType }
|
return { defaults: mergedDefaults, byPageType: mergedByPageType }
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLayoutForEntries(
|
/** @internal Exported for testing only. */
|
||||||
|
export function buildLayoutForEntries(
|
||||||
entries: PluginJsonEntry[],
|
entries: PluginJsonEntry[],
|
||||||
layoutConfig: LayoutConfig,
|
layoutConfig: LayoutConfig,
|
||||||
): Partial<FullPageLayout> {
|
): Partial<FullPageLayout> {
|
||||||
@@ -756,10 +734,12 @@ function buildLayoutForEntries(
|
|||||||
groupOptions?: PluginLayoutDeclaration["groupOptions"]
|
groupOptions?: PluginLayoutDeclaration["groupOptions"]
|
||||||
}[]
|
}[]
|
||||||
> = {
|
> = {
|
||||||
|
header: [],
|
||||||
left: [],
|
left: [],
|
||||||
right: [],
|
right: [],
|
||||||
beforeBody: [],
|
beforeBody: [],
|
||||||
afterBody: [],
|
afterBody: [],
|
||||||
|
footer: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -828,6 +808,48 @@ function buildLayoutForEntries(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.enabled || entry.layout) continue
|
||||||
|
|
||||||
|
const name = extractPluginName(entry.source)
|
||||||
|
const registered =
|
||||||
|
componentRegistry.get(name) ??
|
||||||
|
componentRegistry.get(`${formatSourceDisplay(entry.source)}/${name}`)
|
||||||
|
const pascalName = name
|
||||||
|
.split("-")
|
||||||
|
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||||
|
.join("")
|
||||||
|
const reg = registered ?? componentRegistry.get(pascalName)
|
||||||
|
if (!reg) continue
|
||||||
|
|
||||||
|
const layoutDefaults = reg.manifest
|
||||||
|
const defaultPosition = layoutDefaults?.defaultPosition
|
||||||
|
if (!defaultPosition) continue
|
||||||
|
|
||||||
|
const posArray = positions[defaultPosition]
|
||||||
|
if (!posArray) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let component: QuartzComponent
|
||||||
|
if (typeof reg.component === "function" && !("displayName" in reg.component)) {
|
||||||
|
const tsOverrides = componentRegistry.getOptionOverrides(name)
|
||||||
|
const opts = { ...entry.options, ...tsOverrides }
|
||||||
|
const optsArg = Object.keys(opts).length > 0 ? opts : undefined
|
||||||
|
component = componentRegistry.instantiate(
|
||||||
|
reg.component as QuartzComponentConstructor,
|
||||||
|
optsArg,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
component = reg.component as QuartzComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
posArray.push({
|
||||||
|
component,
|
||||||
|
priority: layoutDefaults?.defaultPriority ?? 50,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Sort by priority and resolve groups
|
// Sort by priority and resolve groups
|
||||||
const result: Partial<FullPageLayout> = {}
|
const result: Partial<FullPageLayout> = {}
|
||||||
|
|
||||||
@@ -837,7 +859,7 @@ function buildLayoutForEntries(
|
|||||||
const resolved = resolveGroups(items, layoutConfig.groups ?? {})
|
const resolved = resolveGroups(items, layoutConfig.groups ?? {})
|
||||||
const key = position as keyof Pick<
|
const key = position as keyof Pick<
|
||||||
FullPageLayout,
|
FullPageLayout,
|
||||||
"left" | "right" | "beforeBody" | "afterBody"
|
"header" | "left" | "right" | "beforeBody" | "afterBody" | "footer"
|
||||||
>
|
>
|
||||||
;(result as Record<string, QuartzComponent[]>)[key] = resolved
|
;(result as Record<string, QuartzComponent[]>)[key] = resolved
|
||||||
}
|
}
|
||||||
@@ -845,7 +867,8 @@ function buildLayoutForEntries(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveGroups(
|
/** @internal Exported for testing only. */
|
||||||
|
export function resolveGroups(
|
||||||
items: {
|
items: {
|
||||||
component: QuartzComponent
|
component: QuartzComponent
|
||||||
priority: number
|
priority: number
|
||||||
@@ -906,9 +929,6 @@ function resolveGroups(
|
|||||||
justify: m.groupOptions?.justify,
|
justify: m.groupOptions?.justify,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Dynamically import Flex to avoid circular dependencies
|
|
||||||
const FlexModule = require("../../components/Flex")
|
|
||||||
const Flex = FlexModule.default as Function
|
|
||||||
const flexComponent = Flex({
|
const flexComponent = Flex({
|
||||||
components: flexComponents,
|
components: flexComponents,
|
||||||
direction: groupConfig.direction ?? "row",
|
direction: groupConfig.direction ?? "row",
|
||||||
@@ -933,10 +953,8 @@ function applyDisplayWrapper(
|
|||||||
display: "mobile-only" | "desktop-only",
|
display: "mobile-only" | "desktop-only",
|
||||||
): QuartzComponent {
|
): QuartzComponent {
|
||||||
if (display === "mobile-only") {
|
if (display === "mobile-only") {
|
||||||
const MobileOnly = require("../../components/MobileOnly").default as Function
|
|
||||||
return MobileOnly(component) as QuartzComponent
|
return MobileOnly(component) as QuartzComponent
|
||||||
} else {
|
} else {
|
||||||
const DesktopOnly = require("../../components/DesktopOnly").default as Function
|
|
||||||
return DesktopOnly(component) as QuartzComponent
|
return DesktopOnly(component) as QuartzComponent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -951,7 +969,6 @@ function applyConditionWrapper(component: QuartzComponent, conditionName: string
|
|||||||
return component
|
return component
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConditionalRender = require("../../components/ConditionalRender").default as Function
|
|
||||||
return ConditionalRender({
|
return ConditionalRender({
|
||||||
component,
|
component,
|
||||||
condition: predicate,
|
condition: predicate,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { execSync } from "child_process"
|
|||||||
import git from "isomorphic-git"
|
import git from "isomorphic-git"
|
||||||
import http from "isomorphic-git/http/node"
|
import http from "isomorphic-git/http/node"
|
||||||
import { styleText } from "util"
|
import { styleText } from "util"
|
||||||
import { createRequire } from "module"
|
import { fileURLToPath } from "node:url"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { PluginSource } from "./types"
|
import { PluginSource } from "./types"
|
||||||
|
|
||||||
@@ -990,8 +990,7 @@ export async function regeneratePluginIndex(
|
|||||||
for (const npmPkg of options.npmPackages ?? []) {
|
for (const npmPkg of options.npmPackages ?? []) {
|
||||||
let distIndex: string | undefined
|
let distIndex: string | undefined
|
||||||
try {
|
try {
|
||||||
const esmRequire = createRequire(import.meta.url)
|
const pkgJsonPath = fileURLToPath(import.meta.resolve(`${npmPkg}/package.json`))
|
||||||
const pkgJsonPath = esmRequire.resolve(`${npmPkg}/package.json`)
|
|
||||||
distIndex = path.join(path.dirname(pkgJsonPath), "dist", "index.d.ts")
|
distIndex = path.join(path.dirname(pkgJsonPath), "dist", "index.d.ts")
|
||||||
} catch {
|
} catch {
|
||||||
if (options.verbose) {
|
if (options.verbose) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { BuildCtx } from "../../util/ctx"
|
|||||||
|
|
||||||
export type PluginCategory = "transformer" | "filter" | "emitter" | "pageType" | "component"
|
export type PluginCategory = "transformer" | "filter" | "emitter" | "pageType" | "component"
|
||||||
|
|
||||||
export type LayoutPosition = "left" | "right" | "beforeBody" | "afterBody"
|
export type LayoutPosition = "left" | "right" | "beforeBody" | "afterBody" | "header" | "footer"
|
||||||
|
|
||||||
export type LayoutDisplay = "all" | "mobile-only" | "desktop-only"
|
export type LayoutDisplay = "all" | "mobile-only" | "desktop-only"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import test, { describe } from "node:test"
|
||||||
|
import assert from "node:assert"
|
||||||
|
import { collectComponents, resolveLayout } from "./dispatcher"
|
||||||
|
import { QuartzPageTypePluginInstance } from "../types"
|
||||||
|
import { QuartzComponent } from "../../components/types"
|
||||||
|
|
||||||
|
const StubA: QuartzComponent = (() => null) as unknown as QuartzComponent
|
||||||
|
const StubB: QuartzComponent = (() => null) as unknown as QuartzComponent
|
||||||
|
const StubHead: QuartzComponent = (() => null) as unknown as QuartzComponent
|
||||||
|
|
||||||
|
function makePageType(
|
||||||
|
overrides: Partial<QuartzPageTypePluginInstance> = {},
|
||||||
|
): QuartzPageTypePluginInstance {
|
||||||
|
return {
|
||||||
|
name: "test-page-type",
|
||||||
|
layout: "content",
|
||||||
|
match: () => true,
|
||||||
|
body: () => (() => null) as unknown as QuartzComponent,
|
||||||
|
...overrides,
|
||||||
|
} as QuartzPageTypePluginInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveLayout", () => {
|
||||||
|
test("footer defaults to [] when sharedDefaults omits footer", () => {
|
||||||
|
const result = resolveLayout(makePageType(), { head: StubHead }, {})
|
||||||
|
assert.deepStrictEqual(result.footer, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("header defaults to [] when sharedDefaults omits header", () => {
|
||||||
|
const result = resolveLayout(makePageType(), { head: StubHead }, {})
|
||||||
|
assert.deepStrictEqual(result.header, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("footer from sharedDefaults is used when no override", () => {
|
||||||
|
const result = resolveLayout(makePageType(), { head: StubHead, footer: [StubA] }, {})
|
||||||
|
assert.deepStrictEqual(result.footer, [StubA])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("byPageType override replaces footer", () => {
|
||||||
|
const result = resolveLayout(
|
||||||
|
makePageType(),
|
||||||
|
{ head: StubHead, footer: [StubA] },
|
||||||
|
{ content: { footer: [StubB] } },
|
||||||
|
)
|
||||||
|
assert.deepStrictEqual(result.footer, [StubB])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("byPageType override clears footer with []", () => {
|
||||||
|
const result = resolveLayout(
|
||||||
|
makePageType(),
|
||||||
|
{ head: StubHead, footer: [StubA] },
|
||||||
|
{ content: { footer: [] } },
|
||||||
|
)
|
||||||
|
assert.deepStrictEqual(result.footer, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("byPageType override clears header with []", () => {
|
||||||
|
const result = resolveLayout(
|
||||||
|
makePageType(),
|
||||||
|
{ head: StubHead, header: [StubA] },
|
||||||
|
{ content: { header: [] } },
|
||||||
|
)
|
||||||
|
assert.deepStrictEqual(result.header, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("all array slots default to [] when sharedDefaults only has head", () => {
|
||||||
|
const result = resolveLayout(makePageType(), { head: StubHead }, {})
|
||||||
|
assert.deepStrictEqual(result.header, [])
|
||||||
|
assert.deepStrictEqual(result.left, [])
|
||||||
|
assert.deepStrictEqual(result.right, [])
|
||||||
|
assert.deepStrictEqual(result.beforeBody, [])
|
||||||
|
assert.deepStrictEqual(result.afterBody, [])
|
||||||
|
assert.deepStrictEqual(result.footer, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves component references through override", () => {
|
||||||
|
const result = resolveLayout(makePageType(), { head: StubHead, footer: [StubA, StubB] }, {})
|
||||||
|
assert.strictEqual(result.footer[0], StubA)
|
||||||
|
assert.strictEqual(result.footer[1], StubB)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveLayout frame resolution", () => {
|
||||||
|
test("config override frame wins over page type frame", () => {
|
||||||
|
const result = resolveLayout(
|
||||||
|
makePageType({ frame: "minimal" }),
|
||||||
|
{ head: StubHead },
|
||||||
|
{ content: { frame: "full-width" } },
|
||||||
|
)
|
||||||
|
assert.strictEqual(result.frame, "full-width")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("page type frame wins when no config override", () => {
|
||||||
|
const result = resolveLayout(makePageType({ frame: "minimal" }), { head: StubHead }, {})
|
||||||
|
assert.strictEqual(result.frame, "minimal")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("defaults to 'default' when no frame specified", () => {
|
||||||
|
const result = resolveLayout(makePageType(), { head: StubHead }, {})
|
||||||
|
assert.strictEqual(result.frame, "default")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("defaults to 'default' when byPageType entry exists but has no frame", () => {
|
||||||
|
const result = resolveLayout(makePageType(), { head: StubHead }, { content: { left: [StubA] } })
|
||||||
|
assert.strictEqual(result.frame, "default")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("collectComponents", () => {
|
||||||
|
test("collects all unique components across page types", () => {
|
||||||
|
const pageTypes = [makePageType(), makePageType({ layout: "landing" })]
|
||||||
|
const sharedDefaults = { head: StubHead }
|
||||||
|
const byPageType = {
|
||||||
|
content: { footer: [StubA] },
|
||||||
|
landing: { footer: [StubB] },
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = collectComponents(pageTypes, sharedDefaults, byPageType)
|
||||||
|
assert.ok(result.includes(StubA))
|
||||||
|
assert.ok(result.includes(StubB))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deduplicates shared components", () => {
|
||||||
|
const pageTypes = [makePageType(), makePageType({ layout: "landing" })]
|
||||||
|
const sharedDefaults = { head: StubHead }
|
||||||
|
const byPageType = {
|
||||||
|
content: { left: [StubA] },
|
||||||
|
landing: { left: [StubA] },
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = collectComponents(pageTypes, sharedDefaults, byPageType)
|
||||||
|
const matches = result.filter((component) => component === StubA)
|
||||||
|
assert.strictEqual(matches.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles empty footer and header arrays", () => {
|
||||||
|
const pageTypes = [makePageType({ layout: "empty" })]
|
||||||
|
const sharedDefaults = { head: StubHead }
|
||||||
|
const byPageType = { empty: { footer: [], header: [] } }
|
||||||
|
|
||||||
|
const result = collectComponents(pageTypes, sharedDefaults, byPageType)
|
||||||
|
assert.ok(result.every((component) => component))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,7 +15,8 @@ function getPageTypes(ctx: BuildCtx): QuartzPageTypePluginInstance[] {
|
|||||||
return (ctx.cfg.plugins.pageTypes ?? []) as unknown as QuartzPageTypePluginInstance[]
|
return (ctx.cfg.plugins.pageTypes ?? []) as unknown as QuartzPageTypePluginInstance[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLayout(
|
/** @internal Exported for testing only. */
|
||||||
|
export function resolveLayout(
|
||||||
pageType: QuartzPageTypePluginInstance,
|
pageType: QuartzPageTypePluginInstance,
|
||||||
sharedDefaults: Partial<FullPageLayout>,
|
sharedDefaults: Partial<FullPageLayout>,
|
||||||
byPageType: Record<string, Partial<FullPageLayout>>,
|
byPageType: Record<string, Partial<FullPageLayout>>,
|
||||||
@@ -31,12 +32,13 @@ function resolveLayout(
|
|||||||
afterBody: overrides.afterBody ?? sharedDefaults.afterBody ?? [],
|
afterBody: overrides.afterBody ?? sharedDefaults.afterBody ?? [],
|
||||||
left: overrides.left ?? sharedDefaults.left ?? [],
|
left: overrides.left ?? sharedDefaults.left ?? [],
|
||||||
right: overrides.right ?? sharedDefaults.right ?? [],
|
right: overrides.right ?? sharedDefaults.right ?? [],
|
||||||
footer: overrides.footer ?? sharedDefaults.footer!,
|
footer: overrides.footer ?? sharedDefaults.footer ?? [],
|
||||||
frame,
|
frame,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectComponents(
|
/** @internal Exported for testing only. */
|
||||||
|
export function collectComponents(
|
||||||
pageTypes: QuartzPageTypePluginInstance[],
|
pageTypes: QuartzPageTypePluginInstance[],
|
||||||
sharedDefaults: Partial<FullPageLayout>,
|
sharedDefaults: Partial<FullPageLayout>,
|
||||||
byPageType: Record<string, Partial<FullPageLayout>>,
|
byPageType: Record<string, Partial<FullPageLayout>>,
|
||||||
@@ -52,7 +54,7 @@ function collectComponents(
|
|||||||
...layout.afterBody,
|
...layout.afterBody,
|
||||||
...layout.left,
|
...layout.left,
|
||||||
...layout.right,
|
...layout.right,
|
||||||
layout.footer,
|
...layout.footer,
|
||||||
]
|
]
|
||||||
for (const c of all) {
|
for (const c of all) {
|
||||||
if (c) seen.add(c)
|
if (c) seen.add(c)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ContentDetails } from "../../.quartz/plugins"
|
import type { ContentDetails } from "@quartz-community/content-index"
|
||||||
import { FullSlug, joinSegments } from "./path"
|
import { FullSlug, joinSegments } from "./path"
|
||||||
|
|
||||||
interface FileTrieData {
|
interface FileTrieData {
|
||||||
|
|||||||
Reference in New Issue
Block a user