Switch site build to shared GameCult-Quartz
This commit is contained in:
parent
e64455a3c5
commit
69ad22885d
196 changed files with 81 additions and 33057 deletions
47
.github/workflows/deploy-quartz.yml
vendored
47
.github/workflows/deploy-quartz.yml
vendored
|
|
@ -16,46 +16,9 @@ concurrency:
|
|||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: quartz-site/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: quartz-site
|
||||
run: npm ci
|
||||
|
||||
- name: Restore Quartz launcher permissions
|
||||
working-directory: quartz-site
|
||||
run: chmod +x quartz/bootstrap-cli.mjs quartz/bootstrap-worker.mjs
|
||||
|
||||
- name: Build Quartz site
|
||||
working-directory: quartz-site
|
||||
run: npm run build
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: quartz-site/public
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: GameCult/GameCult-Quartz/.github/workflows/quartz-pages.yml@main
|
||||
with:
|
||||
content-dir: Aetheria
|
||||
overlay-dir: site
|
||||
output-dir: quartz-site/public
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -20,5 +20,6 @@ Thumbs.db
|
|||
/Aetheria/Inspiration
|
||||
/.tools/
|
||||
/.npm-cache/
|
||||
/.quartz-build/
|
||||
/quartz-site/node_modules/
|
||||
/quartz-site/public/
|
||||
|
|
|
|||
217
README.md
217
README.md
|
|
@ -1,212 +1,65 @@
|
|||
# AetheriaLore
|
||||
|
||||
AetheriaLore is an Obsidian vault for the Aetheria setting and the source for the live Quartz-powered site at `https://aetheria.gamecult.org`.
|
||||
`AetheriaLore` is the source vault for `https://aetheria.gamecult.org`: lore, design notes, stories, and the sort of worldbuilding that thrives when nobody forces it into a CMS shaped like office software.
|
||||
|
||||
The setting is a politically charged science-fantasy / science-fiction world shaped by corporate power, technological escalation, ideological struggle, and the long consequences of social collapse. The repository is both:
|
||||
|
||||
- a readable worldbuilding vault under `Aetheria/`
|
||||
- a customized static site implementation under `quartz-site/`
|
||||
The site now builds against the shared `GameCult-Quartz` engine instead of carrying its own private Quartz clone. This repo owns the vault content and the Aetheria-specific overlay; the engine lives next door.
|
||||
|
||||
## Repository Map
|
||||
|
||||
The most important top-level paths are:
|
||||
|
||||
- `Aetheria/`: the actual vault content
|
||||
- `quartz-site/`: the vendored Quartz site source and custom UI code
|
||||
- `scripts/quartz/quartz.ps1`: beginner-friendly helper for local builds and the dev server
|
||||
- `.github/workflows/deploy-quartz.yml`: GitHub Pages deployment workflow
|
||||
- `AGENTS.md`: project writing and editing conventions
|
||||
- `site/`: Aetheria-specific Quartz overlay
|
||||
- `quartz.config.ts`
|
||||
- `quartz.layout.ts`
|
||||
- custom components
|
||||
- custom styles
|
||||
- static assets
|
||||
- `quartz-site/public/`: generated static output
|
||||
- `scripts/quartz/quartz.ps1`: local build/dev launcher
|
||||
- `.github/workflows/deploy-quartz.yml`: Pages deploy workflow, delegated to `GameCult-Quartz`
|
||||
- `AGENTS.md`: writing and editing conventions
|
||||
|
||||
Within the vault, most published setting material lives under `Aetheria/Worldbuilding/`:
|
||||
## Shared Engine Dependency
|
||||
|
||||
- `Pre-Elysium`: solar-system-era history, factions, technologies, movements, and timeline notes
|
||||
- `Post-Elysium`: transformed-era concepts, species, and technologies
|
||||
- `Politics`: strategic, legal, and institutional notes about power and warfare
|
||||
Local builds expect the shared engine repo to exist either:
|
||||
|
||||
If you want to read the setting in sequence, start with:
|
||||
- as a sibling checkout at `E:\\Projects\\GameCult-Quartz`, or
|
||||
- at the path provided through `GAMECULT_QUARTZ_ROOT`
|
||||
|
||||
1. `Aetheria/Introduction.md`
|
||||
2. `Aetheria/Narrative Themes.md`
|
||||
3. `Aetheria/Worldbuilding/Pre-Elysium/Timeline.md`
|
||||
Install dependencies in `GameCult-Quartz` first:
|
||||
|
||||
## Local Development Quick Start
|
||||
```powershell
|
||||
cd E:\Projects\GameCult-Quartz
|
||||
npm ci
|
||||
```
|
||||
|
||||
### The easiest path on Windows
|
||||
## Local Development
|
||||
|
||||
From the repository root, run:
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
.\scripts\quartz\quartz.ps1 dev
|
||||
```
|
||||
|
||||
That command:
|
||||
|
||||
- starts the local Quartz development server
|
||||
- watches the vault for changes
|
||||
- rebuilds automatically as you edit notes or site files
|
||||
- uses the bundled portable Node.js runtime in `.tools/` if it exists
|
||||
|
||||
Quartz will print the local address when it starts. In this repo it is usually `http://localhost:8080`.
|
||||
|
||||
When you are done, stop it with `Ctrl+C`.
|
||||
|
||||
### Build a static output locally
|
||||
|
||||
To do a one-off production-style build:
|
||||
For a one-off production build:
|
||||
|
||||
```powershell
|
||||
.\scripts\quartz\quartz.ps1 build
|
||||
```
|
||||
|
||||
The generated site goes to:
|
||||
The shared engine stages a runtime under `.quartz-build/engine` and writes the finished static site to `quartz-site/public`.
|
||||
|
||||
```text
|
||||
quartz-site/public
|
||||
```
|
||||
## Vault Structure
|
||||
|
||||
That folder is build output, not source of truth.
|
||||
Most published material lives under `Aetheria/`, especially these branches:
|
||||
|
||||
### Important beginner note
|
||||
- `Lore/`
|
||||
- `Game Design/`
|
||||
- `Stories/`
|
||||
|
||||
Do **not** test the site by double-clicking `quartz-site/public/index.html` and opening it as a `file://` page. Quartz features such as search, explorer behavior, and some client-side routing expect a real HTTP server. Use the dev host instead.
|
||||
If you want the setting in sequence, start with:
|
||||
|
||||
## Direct npm Workflow
|
||||
1. `Aetheria/index.md`
|
||||
2. `Aetheria/Lore/index.md`
|
||||
3. `Aetheria/Stories/index.md`
|
||||
|
||||
If you prefer to work directly inside `quartz-site/` instead of using the helper script:
|
||||
|
||||
```powershell
|
||||
cd quartz-site
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
For a one-off build:
|
||||
|
||||
```powershell
|
||||
cd quartz-site
|
||||
npm run build
|
||||
```
|
||||
|
||||
This is the more portable path on non-Windows systems. The PowerShell helper is mainly there to make local development smoother on Windows and to hide the bundled-runtime setup.
|
||||
|
||||
## Testing And Verification
|
||||
|
||||
The fastest useful smoke test is a full site build:
|
||||
|
||||
```powershell
|
||||
.\scripts\quartz\quartz.ps1 build
|
||||
```
|
||||
|
||||
If you are changing Quartz TypeScript logic, you can also run the vendored Quartz test suite from `quartz-site/`:
|
||||
|
||||
```powershell
|
||||
cd quartz-site
|
||||
npm test
|
||||
```
|
||||
|
||||
Useful additional checks:
|
||||
|
||||
```powershell
|
||||
cd quartz-site
|
||||
npm run check
|
||||
```
|
||||
|
||||
## Where To Edit Things
|
||||
|
||||
### Setting content
|
||||
|
||||
- `Aetheria/**/*.md`
|
||||
|
||||
This is where the worldbuilding actually lives. If you are changing lore, faction descriptions, timelines, concepts, or overview notes, you are probably editing here.
|
||||
|
||||
### Site-wide Quartz settings
|
||||
|
||||
- `quartz-site/quartz.config.ts`
|
||||
|
||||
Use this for:
|
||||
|
||||
- site title
|
||||
- base URL
|
||||
- ignored vault paths
|
||||
- theme colors and fonts
|
||||
- Quartz plugins and emitters
|
||||
|
||||
### Page layout and which UI pieces appear where
|
||||
|
||||
- `quartz-site/quartz.layout.ts`
|
||||
|
||||
Use this when you need to change the overall shell for note pages: header, breadcrumbs, sidebars, footer, and which components are rendered before or after the note body.
|
||||
|
||||
### Main site-specific styling
|
||||
|
||||
- `quartz-site/quartz/styles/custom.scss`
|
||||
|
||||
This is the primary place to adjust Aetheria-specific look and feel:
|
||||
|
||||
- dark-only theme behavior
|
||||
- layout spacing and panel treatment
|
||||
- title bar styling
|
||||
- sidebar styling
|
||||
- breadcrumb appearance
|
||||
- note chrome and atmosphere
|
||||
|
||||
Quartz base styles also live nearby in `quartz-site/quartz/styles/`, but `custom.scss` is the intended first stop for project-specific visual changes.
|
||||
|
||||
### Aetheria-specific custom components
|
||||
|
||||
These are the most important custom UI files:
|
||||
|
||||
- `quartz-site/quartz/components/AetheriaMasthead.tsx`: the top title bar and major section navigation
|
||||
- `quartz-site/quartz/components/AetheriaOverviewSidebar.tsx`: the left navigation sidebar
|
||||
- `quartz-site/quartz/components/AetheriaThemeLock.tsx`: dark-mode lock
|
||||
- `quartz-site/quartz/components/aetheria.ts`: note/tagline/overview parsing logic used by the custom shell
|
||||
|
||||
### Custom behavior worth knowing about
|
||||
|
||||
- `quartz-site/quartz/components/Breadcrumbs.tsx`: breadcrumb rendering
|
||||
- `quartz-site/quartz/util/fileTrie.ts`: path and ancestry logic used by breadcrumbs and folder-like navigation
|
||||
- `quartz-site/quartz/components/Head.tsx`: page titles and metadata behavior
|
||||
- `quartz-site/quartz/components/renderPage.tsx`: page assembly
|
||||
|
||||
### Deployment
|
||||
|
||||
- `.github/workflows/deploy-quartz.yml`
|
||||
|
||||
Pushes to `main` trigger the GitHub Actions build and deploy the static site to GitHub Pages.
|
||||
|
||||
## Working With The Vault
|
||||
|
||||
If you open the repository in Obsidian, internal wiki links such as `[[Megas]]`, `[[Aether]]`, or `[[Pan-Solar Consortium]]` give the best reading and editing experience.
|
||||
|
||||
Published overview/navigation notes are usually `index.md` files inside their branch folders. In this repo they are not throwaway placeholders: they drive navigation, taglines, and sidebar behavior in the generated site.
|
||||
|
||||
Some vault paths are intentionally excluded from the published site, including:
|
||||
|
||||
- `Brainstorming/`
|
||||
- `Inspiration/`
|
||||
|
||||
They can still exist in the vault for internal work.
|
||||
|
||||
## Contributor Notes
|
||||
|
||||
Start with `AGENTS.md` before making broad writing changes. It documents the tone, structural conventions, navigation expectations, and thematic lenses used across the setting.
|
||||
|
||||
If you need semantic vault navigation support while editing locally, the repo also includes a local RAG helper under `scripts/rag/`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `npm` was not found
|
||||
|
||||
The helper script will use the bundled Windows runtime in `.tools/node-v24.15.0-win-x64` if present. If that folder is missing, install Node.js and npm locally.
|
||||
|
||||
### Build warnings about inaccurate dates
|
||||
|
||||
Quartz can warn that dates are inaccurate for notes that are not yet tracked by Git. That usually means you need to commit the new file before Git-backed modified dates become meaningful.
|
||||
|
||||
### The dev site looks broken but the build succeeded
|
||||
|
||||
Make sure you opened the served URL from `npm run dev` or `.\scripts\quartz\quartz.ps1 dev`, not a raw file from `quartz-site/public`.
|
||||
|
||||
## License
|
||||
|
||||
See `LICENSE` for repository licensing details.
|
||||
Some vault paths remain intentionally unpublished, including internal brainstorming and inspiration dumps. They can stay messy in private instead of oozing onto the public site.
|
||||
|
|
|
|||
1
quartz-site/.gitattributes
vendored
1
quartz-site/.gitattributes
vendored
|
|
@ -1 +0,0 @@
|
|||
* text=auto eol=lf
|
||||
|
|
@ -1 +0,0 @@
|
|||
v22.16.0
|
||||
|
|
@ -1 +0,0 @@
|
|||
engine-strict=true
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
public
|
||||
node_modules
|
||||
.quartz-cache
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"printWidth": 100,
|
||||
"quoteProps": "as-needed",
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": false
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2021 jackyzha0
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# Quartz v4
|
||||
|
||||
> “[One] who works with the door open gets all kinds of interruptions, but [they] also occasionally gets clues as to what the world is and what might be important.” — Richard Hamming
|
||||
|
||||
Quartz is a set of tools that helps you publish your [digital garden](https://jzhao.xyz/posts/networked-thought) and notes as a website for free.
|
||||
|
||||
🔗 Read the documentation and get started: https://quartz.jzhao.xyz/
|
||||
|
||||
[Join the Discord Community](https://discord.gg/cRFFHYye7t)
|
||||
|
||||
## Sponsors
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/sponsors/jackyzha0">
|
||||
<img src="https://cdn.jsdelivr.net/gh/jackyzha0/jackyzha0/sponsorkit/sponsors.svg" />
|
||||
</a>
|
||||
</p>
|
||||
17
quartz-site/globals.d.ts
vendored
17
quartz-site/globals.d.ts
vendored
|
|
@ -1,17 +0,0 @@
|
|||
export declare global {
|
||||
interface Document {
|
||||
addEventListener<K extends keyof CustomEventMap>(
|
||||
type: K,
|
||||
listener: (this: Document, ev: CustomEventMap[K]) => void,
|
||||
): void
|
||||
removeEventListener<K extends keyof CustomEventMap>(
|
||||
type: K,
|
||||
listener: (this: Document, ev: CustomEventMap[K]) => void,
|
||||
): void
|
||||
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K] | UIEvent): void
|
||||
}
|
||||
interface Window {
|
||||
spaNavigate(url: URL, isBack: boolean = false)
|
||||
addCleanup(fn: (...args: any[]) => void)
|
||||
}
|
||||
}
|
||||
15
quartz-site/index.d.ts
vendored
15
quartz-site/index.d.ts
vendored
|
|
@ -1,15 +0,0 @@
|
|||
declare module "*.scss" {
|
||||
const content: string
|
||||
export = content
|
||||
}
|
||||
|
||||
// dom custom event
|
||||
interface CustomEventMap {
|
||||
prenav: CustomEvent<{}>
|
||||
nav: CustomEvent<{ url: FullSlug }>
|
||||
themechange: CustomEvent<{ theme: "light" | "dark" }>
|
||||
readermodechange: CustomEvent<{ mode: "on" | "off" }>
|
||||
}
|
||||
|
||||
type ContentIndex = Record<FullSlug, ContentDetails>
|
||||
declare const fetchData: Promise<ContentIndex>
|
||||
7675
quartz-site/package-lock.json
generated
7675
quartz-site/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,115 +0,0 @@
|
|||
{
|
||||
"name": "@jackyzha0/quartz",
|
||||
"description": "🌱 publish your digital garden and notes as a website",
|
||||
"private": true,
|
||||
"version": "4.5.2",
|
||||
"type": "module",
|
||||
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
|
||||
"license": "MIT",
|
||||
"homepage": "https://quartz.jzhao.xyz",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jackyzha0/quartz.git"
|
||||
},
|
||||
"scripts": {
|
||||
"quartz": "./quartz/bootstrap-cli.mjs",
|
||||
"dev": "npx quartz build --serve -d ../Aetheria",
|
||||
"build": "npx quartz build -d ../Aetheria",
|
||||
"check": "tsc --noEmit && npx prettier . --check",
|
||||
"format": "npx prettier . --write",
|
||||
"test": "tsx --test",
|
||||
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=10.9.2",
|
||||
"node": ">=22"
|
||||
},
|
||||
"keywords": [
|
||||
"site generator",
|
||||
"ssg",
|
||||
"digital-garden",
|
||||
"markdown",
|
||||
"blog",
|
||||
"quartz"
|
||||
],
|
||||
"bin": {
|
||||
"quartz": "./quartz/bootstrap-cli.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@floating-ui/dom": "^1.7.4",
|
||||
"@myriaddreamin/rehype-typst": "^0.6.0",
|
||||
"@napi-rs/simple-git": "0.1.22",
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"ansi-truncate": "^1.4.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"cli-spinner": "^0.2.10",
|
||||
"d3": "^7.9.0",
|
||||
"esbuild-sass-plugin": "^3.6.0",
|
||||
"flexsearch": "^0.8.205",
|
||||
"github-slugger": "^2.0.0",
|
||||
"globby": "^16.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hast-util-to-html": "^9.0.5",
|
||||
"hast-util-to-jsx-runtime": "^2.3.6",
|
||||
"hast-util-to-string": "^3.0.1",
|
||||
"is-absolute-url": "^5.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lightningcss": "^1.31.1",
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"mdast-util-to-hast": "^13.2.1",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromorph": "^0.4.5",
|
||||
"minimatch": "^10.1.1",
|
||||
"pixi.js": "^8.15.0",
|
||||
"preact": "^10.28.2",
|
||||
"preact-render-to-string": "^6.6.5",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"pretty-time": "^1.1.0",
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-citation": "^2.3.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-mathjax": "^7.1.0",
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"remark-smartypants": "^3.0.2",
|
||||
"rfdc": "^1.4.1",
|
||||
"satori": "^0.19.1",
|
||||
"serve-handler": "^6.1.6",
|
||||
"sharp": "^0.34.5",
|
||||
"shiki": "^1.26.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"to-vfile": "^8.0.0",
|
||||
"toml": "^3.0.0",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"vfile": "^6.0.3",
|
||||
"workerpool": "^10.0.1",
|
||||
"ws": "^8.19.0",
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/pretty-time": "^1.1.5",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/yargs": "^17.0.35",
|
||||
"esbuild": "^0.27.2",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
#!/usr/bin/env -S node --no-deprecation
|
||||
import yargs from "yargs"
|
||||
import { hideBin } from "yargs/helpers"
|
||||
import {
|
||||
handleBuild,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
handleRestore,
|
||||
handleSync,
|
||||
} from "./cli/handlers.js"
|
||||
import { CommonArgv, BuildArgv, CreateArgv, SyncArgv } from "./cli/args.js"
|
||||
import { version } from "./cli/constants.js"
|
||||
|
||||
yargs(hideBin(process.argv))
|
||||
.scriptName("quartz")
|
||||
.version(version)
|
||||
.usage("$0 <cmd> [args]")
|
||||
.command("create", "Initialize Quartz", CreateArgv, async (argv) => {
|
||||
await handleCreate(argv)
|
||||
})
|
||||
.command("update", "Get the latest Quartz updates", CommonArgv, async (argv) => {
|
||||
await handleUpdate(argv)
|
||||
})
|
||||
.command(
|
||||
"restore",
|
||||
"Try to restore your content folder from the cache",
|
||||
CommonArgv,
|
||||
async (argv) => {
|
||||
await handleRestore(argv)
|
||||
},
|
||||
)
|
||||
.command("sync", "Sync your Quartz to and from GitHub.", SyncArgv, async (argv) => {
|
||||
await handleSync(argv)
|
||||
})
|
||||
.command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => {
|
||||
await handleBuild(argv)
|
||||
})
|
||||
.showHelpOnFail(false)
|
||||
.help()
|
||||
.strict()
|
||||
.demandCommand().argv
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
import workerpool from "workerpool"
|
||||
const cacheFile = "./.quartz-cache/transpiled-worker.mjs"
|
||||
const { parseMarkdown, processHtml } = await import(cacheFile)
|
||||
workerpool.worker({
|
||||
parseMarkdown,
|
||||
processHtml,
|
||||
})
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
import sourceMapSupport from "source-map-support"
|
||||
sourceMapSupport.install(options)
|
||||
import path from "path"
|
||||
import { PerfTimer } from "./util/perf"
|
||||
import { rm } from "fs/promises"
|
||||
import { GlobbyFilterFunction, isGitIgnored } from "globby"
|
||||
import { styleText } from "util"
|
||||
import { parseMarkdown } from "./processors/parse"
|
||||
import { filterContent } from "./processors/filter"
|
||||
import { emitContent } from "./processors/emit"
|
||||
import cfg from "../quartz.config"
|
||||
import { FilePath, joinSegments, slugifyFilePath } from "./util/path"
|
||||
import chokidar from "chokidar"
|
||||
import { ProcessedContent } from "./plugins/vfile"
|
||||
import { Argv, BuildCtx } from "./util/ctx"
|
||||
import { glob, toPosixPath } from "./util/glob"
|
||||
import { trace } from "./util/trace"
|
||||
import { options } from "./util/sourcemap"
|
||||
import { Mutex } from "async-mutex"
|
||||
import { getStaticResourcesFromPlugins } from "./plugins"
|
||||
import { randomIdNonSecure } from "./util/random"
|
||||
import { ChangeEvent } from "./plugins/types"
|
||||
import { minimatch } from "minimatch"
|
||||
|
||||
type ContentMap = Map<
|
||||
FilePath,
|
||||
| {
|
||||
type: "markdown"
|
||||
content: ProcessedContent
|
||||
}
|
||||
| {
|
||||
type: "other"
|
||||
}
|
||||
>
|
||||
|
||||
type BuildData = {
|
||||
ctx: BuildCtx
|
||||
ignored: GlobbyFilterFunction
|
||||
mut: Mutex
|
||||
contentMap: ContentMap
|
||||
changesSinceLastBuild: Record<FilePath, ChangeEvent["type"]>
|
||||
lastBuildMs: number
|
||||
}
|
||||
|
||||
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
|
||||
const ctx: BuildCtx = {
|
||||
buildId: randomIdNonSecure(),
|
||||
argv,
|
||||
cfg,
|
||||
allSlugs: [],
|
||||
allFiles: [],
|
||||
incremental: false,
|
||||
}
|
||||
|
||||
const perf = new PerfTimer()
|
||||
const output = argv.output
|
||||
|
||||
const pluginCount = Object.values(cfg.plugins).flat().length
|
||||
const pluginNames = (key: "transformers" | "filters" | "emitters") =>
|
||||
cfg.plugins[key].map((plugin) => plugin.name)
|
||||
if (argv.verbose) {
|
||||
console.log(`Loaded ${pluginCount} plugins`)
|
||||
console.log(` Transformers: ${pluginNames("transformers").join(", ")}`)
|
||||
console.log(` Filters: ${pluginNames("filters").join(", ")}`)
|
||||
console.log(` Emitters: ${pluginNames("emitters").join(", ")}`)
|
||||
}
|
||||
|
||||
const release = await mut.acquire()
|
||||
perf.addEvent("clean")
|
||||
await rm(output, { recursive: true, force: true })
|
||||
console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`)
|
||||
|
||||
perf.addEvent("glob")
|
||||
const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns)
|
||||
const markdownPaths = allFiles.filter((fp) => fp.endsWith(".md")).sort()
|
||||
console.log(
|
||||
`Found ${markdownPaths.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
|
||||
)
|
||||
|
||||
const filePaths = markdownPaths.map((fp) => joinSegments(argv.directory, fp) as FilePath)
|
||||
ctx.allFiles = allFiles
|
||||
ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath))
|
||||
|
||||
const parsedFiles = await parseMarkdown(ctx, filePaths)
|
||||
const filteredContent = filterContent(ctx, parsedFiles)
|
||||
|
||||
await emitContent(ctx, filteredContent)
|
||||
console.log(
|
||||
styleText("green", `Done processing ${markdownPaths.length} files in ${perf.timeSince()}`),
|
||||
)
|
||||
release()
|
||||
|
||||
if (argv.watch) {
|
||||
ctx.incremental = true
|
||||
return startWatching(ctx, mut, parsedFiles, clientRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
// setup watcher for rebuilds
|
||||
async function startWatching(
|
||||
ctx: BuildCtx,
|
||||
mut: Mutex,
|
||||
initialContent: ProcessedContent[],
|
||||
clientRefresh: () => void,
|
||||
) {
|
||||
const { argv, allFiles } = ctx
|
||||
|
||||
const contentMap: ContentMap = new Map()
|
||||
for (const filePath of allFiles) {
|
||||
contentMap.set(filePath, {
|
||||
type: "other",
|
||||
})
|
||||
}
|
||||
|
||||
for (const content of initialContent) {
|
||||
const [_tree, vfile] = content
|
||||
contentMap.set(vfile.data.relativePath!, {
|
||||
type: "markdown",
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
const gitIgnoredMatcher = await isGitIgnored()
|
||||
const buildData: BuildData = {
|
||||
ctx,
|
||||
mut,
|
||||
contentMap,
|
||||
ignored: (fp) => {
|
||||
const pathStr = toPosixPath(fp.toString())
|
||||
if (pathStr.startsWith(".git/")) return true
|
||||
if (gitIgnoredMatcher(pathStr)) return true
|
||||
for (const pattern of cfg.configuration.ignorePatterns) {
|
||||
if (minimatch(pathStr, pattern)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
changesSinceLastBuild: {},
|
||||
lastBuildMs: 0,
|
||||
}
|
||||
|
||||
const watcher = chokidar.watch(".", {
|
||||
awaitWriteFinish: { stabilityThreshold: 250 },
|
||||
persistent: true,
|
||||
cwd: argv.directory,
|
||||
ignoreInitial: true,
|
||||
})
|
||||
|
||||
const changes: ChangeEvent[] = []
|
||||
watcher
|
||||
.on("add", (fp) => {
|
||||
fp = toPosixPath(fp)
|
||||
if (buildData.ignored(fp)) return
|
||||
changes.push({ path: fp as FilePath, type: "add" })
|
||||
void rebuild(changes, clientRefresh, buildData)
|
||||
})
|
||||
.on("change", (fp) => {
|
||||
fp = toPosixPath(fp)
|
||||
if (buildData.ignored(fp)) return
|
||||
changes.push({ path: fp as FilePath, type: "change" })
|
||||
void rebuild(changes, clientRefresh, buildData)
|
||||
})
|
||||
.on("unlink", (fp) => {
|
||||
fp = toPosixPath(fp)
|
||||
if (buildData.ignored(fp)) return
|
||||
changes.push({ path: fp as FilePath, type: "delete" })
|
||||
void rebuild(changes, clientRefresh, buildData)
|
||||
})
|
||||
|
||||
return async () => {
|
||||
await watcher.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildData: BuildData) {
|
||||
const { ctx, contentMap, mut, changesSinceLastBuild } = buildData
|
||||
const { argv, cfg } = ctx
|
||||
|
||||
const buildId = randomIdNonSecure()
|
||||
ctx.buildId = buildId
|
||||
buildData.lastBuildMs = new Date().getTime()
|
||||
const numChangesInBuild = changes.length
|
||||
const release = await mut.acquire()
|
||||
|
||||
// if there's another build after us, release and let them do it
|
||||
if (ctx.buildId !== buildId) {
|
||||
release()
|
||||
return
|
||||
}
|
||||
|
||||
const perf = new PerfTimer()
|
||||
perf.addEvent("rebuild")
|
||||
console.log(styleText("yellow", "Detected change, rebuilding..."))
|
||||
|
||||
// update changesSinceLastBuild
|
||||
for (const change of changes) {
|
||||
changesSinceLastBuild[change.path] = change.type
|
||||
}
|
||||
|
||||
const staticResources = getStaticResourcesFromPlugins(ctx)
|
||||
const pathsToParse: FilePath[] = []
|
||||
for (const [fp, type] of Object.entries(changesSinceLastBuild)) {
|
||||
if (type === "delete" || path.extname(fp) !== ".md") continue
|
||||
const fullPath = joinSegments(argv.directory, toPosixPath(fp)) as FilePath
|
||||
pathsToParse.push(fullPath)
|
||||
}
|
||||
|
||||
const parsed = await parseMarkdown(ctx, pathsToParse)
|
||||
for (const content of parsed) {
|
||||
contentMap.set(content[1].data.relativePath!, {
|
||||
type: "markdown",
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
// update state using changesSinceLastBuild
|
||||
// we do this weird play of add => compute change events => remove
|
||||
// so that partialEmitters can do appropriate cleanup based on the content of deleted files
|
||||
for (const [file, change] of Object.entries(changesSinceLastBuild)) {
|
||||
if (change === "delete") {
|
||||
// universal delete case
|
||||
contentMap.delete(file as FilePath)
|
||||
}
|
||||
|
||||
// manually track non-markdown files as processed files only
|
||||
// contains markdown files
|
||||
if (change === "add" && path.extname(file) !== ".md") {
|
||||
contentMap.set(file as FilePath, {
|
||||
type: "other",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const changeEvents: ChangeEvent[] = Object.entries(changesSinceLastBuild).map(([fp, type]) => {
|
||||
const path = fp as FilePath
|
||||
const processedContent = contentMap.get(path)
|
||||
if (processedContent?.type === "markdown") {
|
||||
const [_tree, file] = processedContent.content
|
||||
return {
|
||||
type,
|
||||
path,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
path,
|
||||
}
|
||||
})
|
||||
|
||||
// update allFiles and then allSlugs with the consistent view of content map
|
||||
ctx.allFiles = Array.from(contentMap.keys())
|
||||
ctx.allSlugs = ctx.allFiles.map((fp) => slugifyFilePath(fp as FilePath))
|
||||
let processedFiles = filterContent(
|
||||
ctx,
|
||||
Array.from(contentMap.values())
|
||||
.filter((file) => file.type === "markdown")
|
||||
.map((file) => file.content),
|
||||
)
|
||||
|
||||
let emittedFiles = 0
|
||||
for (const emitter of cfg.plugins.emitters) {
|
||||
// Try to use partialEmit if available, otherwise assume the output is static
|
||||
const emitFn = emitter.partialEmit ?? emitter.emit
|
||||
const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents)
|
||||
if (emitted === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (Symbol.asyncIterator in emitted) {
|
||||
// Async generator case
|
||||
for await (const file of emitted) {
|
||||
emittedFiles++
|
||||
if (ctx.argv.verbose) {
|
||||
console.log(`[emit:${emitter.name}] ${file}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Array case
|
||||
emittedFiles += emitted.length
|
||||
if (ctx.argv.verbose) {
|
||||
for (const file of emitted) {
|
||||
console.log(`[emit:${emitter.name}] ${file}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`)
|
||||
console.log(styleText("green", `Done rebuilding in ${perf.timeSince()}`))
|
||||
changes.splice(0, numChangesInBuild)
|
||||
clientRefresh()
|
||||
release()
|
||||
}
|
||||
|
||||
export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => {
|
||||
try {
|
||||
return await buildQuartz(argv, mut, clientRefresh)
|
||||
} catch (err) {
|
||||
trace("\nExiting Quartz due to a fatal error", err as Error)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { ValidDateType } from "./components/Date"
|
||||
import { QuartzComponent } from "./components/types"
|
||||
import { ValidLocale } from "./i18n"
|
||||
import { PluginTypes } from "./plugins/types"
|
||||
import { Theme } from "./util/theme"
|
||||
|
||||
export type Analytics =
|
||||
| null
|
||||
| {
|
||||
provider: "plausible"
|
||||
host?: string
|
||||
}
|
||||
| {
|
||||
provider: "google"
|
||||
tagId: string
|
||||
}
|
||||
| {
|
||||
provider: "umami"
|
||||
websiteId: string
|
||||
host?: string
|
||||
}
|
||||
| {
|
||||
provider: "goatcounter"
|
||||
websiteId: string
|
||||
host?: string
|
||||
scriptSrc?: string
|
||||
}
|
||||
| {
|
||||
provider: "posthog"
|
||||
apiKey: string
|
||||
host?: string
|
||||
}
|
||||
| {
|
||||
provider: "tinylytics"
|
||||
siteId: string
|
||||
}
|
||||
| {
|
||||
provider: "cabin"
|
||||
host?: string
|
||||
}
|
||||
| {
|
||||
provider: "clarity"
|
||||
projectId?: string
|
||||
}
|
||||
| {
|
||||
provider: "matomo"
|
||||
host: string
|
||||
siteId: string
|
||||
}
|
||||
| {
|
||||
provider: "vercel"
|
||||
}
|
||||
| {
|
||||
provider: "rybbit"
|
||||
siteId: string
|
||||
host?: string
|
||||
}
|
||||
|
||||
export interface GlobalConfiguration {
|
||||
pageTitle: string
|
||||
pageTitleSuffix?: string
|
||||
/** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */
|
||||
enableSPA: boolean
|
||||
/** Whether to display Wikipedia-style popovers when hovering over links */
|
||||
enablePopovers: boolean
|
||||
/** Analytics mode */
|
||||
analytics: Analytics
|
||||
/** Glob patterns to not search */
|
||||
ignorePatterns: string[]
|
||||
/** Whether to use created, modified, or published as the default type of date */
|
||||
defaultDateType: ValidDateType
|
||||
/** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL.
|
||||
* Quartz will avoid using this as much as possible and use relative URLs most of the time
|
||||
*/
|
||||
baseUrl?: string
|
||||
theme: Theme
|
||||
/**
|
||||
* Allow to translate the date in the language of your choice.
|
||||
* Also used for UI translation (default: en-US)
|
||||
* Need to be formatted following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag
|
||||
* The first part is the language (en) and the second part is the script/region (US)
|
||||
* Language Codes: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
|
||||
* Region Codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
|
||||
*/
|
||||
locale: ValidLocale
|
||||
}
|
||||
|
||||
export interface QuartzConfig {
|
||||
configuration: GlobalConfiguration
|
||||
plugins: PluginTypes
|
||||
}
|
||||
|
||||
export interface FullPageLayout {
|
||||
head: QuartzComponent
|
||||
header: QuartzComponent[]
|
||||
beforeBody: QuartzComponent[]
|
||||
pageBody: QuartzComponent
|
||||
afterBody: QuartzComponent[]
|
||||
left: QuartzComponent[]
|
||||
right: QuartzComponent[]
|
||||
footer: QuartzComponent
|
||||
}
|
||||
|
||||
export type PageLayout = Pick<FullPageLayout, "beforeBody" | "left" | "right">
|
||||
export type SharedLayout = Pick<FullPageLayout, "head" | "header" | "footer" | "afterBody">
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
export const CommonArgv = {
|
||||
directory: {
|
||||
string: true,
|
||||
alias: ["d"],
|
||||
default: "content",
|
||||
describe: "directory to look for content files",
|
||||
},
|
||||
verbose: {
|
||||
boolean: true,
|
||||
alias: ["v"],
|
||||
default: false,
|
||||
describe: "print out extra logging information",
|
||||
},
|
||||
}
|
||||
|
||||
export const CreateArgv = {
|
||||
...CommonArgv,
|
||||
source: {
|
||||
string: true,
|
||||
alias: ["s"],
|
||||
describe: "source directory to copy/create symlink from",
|
||||
},
|
||||
strategy: {
|
||||
string: true,
|
||||
alias: ["X"],
|
||||
choices: ["new", "copy", "symlink"],
|
||||
describe: "strategy for content folder setup",
|
||||
},
|
||||
links: {
|
||||
string: true,
|
||||
alias: ["l"],
|
||||
choices: ["absolute", "shortest", "relative"],
|
||||
describe: "strategy to resolve links",
|
||||
},
|
||||
}
|
||||
|
||||
export const SyncArgv = {
|
||||
...CommonArgv,
|
||||
commit: {
|
||||
boolean: true,
|
||||
default: true,
|
||||
describe: "create a git commit for your unsaved changes",
|
||||
},
|
||||
message: {
|
||||
string: true,
|
||||
alias: ["m"],
|
||||
describe: "option to override the default Quartz commit message",
|
||||
},
|
||||
push: {
|
||||
boolean: true,
|
||||
default: true,
|
||||
describe: "push updates to your Quartz fork",
|
||||
},
|
||||
pull: {
|
||||
boolean: true,
|
||||
default: true,
|
||||
describe: "pull updates from your Quartz fork",
|
||||
},
|
||||
}
|
||||
|
||||
export const BuildArgv = {
|
||||
...CommonArgv,
|
||||
output: {
|
||||
string: true,
|
||||
alias: ["o"],
|
||||
default: "public",
|
||||
describe: "output folder for files",
|
||||
},
|
||||
serve: {
|
||||
boolean: true,
|
||||
default: false,
|
||||
describe: "run a local server to live-preview your Quartz",
|
||||
},
|
||||
watch: {
|
||||
boolean: true,
|
||||
default: false,
|
||||
describe: "watch for changes and rebuild automatically",
|
||||
},
|
||||
baseDir: {
|
||||
string: true,
|
||||
default: "",
|
||||
describe: "base path to serve your local server on",
|
||||
},
|
||||
port: {
|
||||
number: true,
|
||||
default: 8080,
|
||||
describe: "port to serve Quartz on",
|
||||
},
|
||||
wsPort: {
|
||||
number: true,
|
||||
default: 3001,
|
||||
describe: "port to use for WebSocket-based hot-reload notifications",
|
||||
},
|
||||
remoteDevHost: {
|
||||
string: true,
|
||||
default: "",
|
||||
describe: "A URL override for the websocket connection if you are not developing on localhost",
|
||||
},
|
||||
bundleInfo: {
|
||||
boolean: true,
|
||||
default: false,
|
||||
describe: "show detailed bundle information",
|
||||
},
|
||||
concurrency: {
|
||||
number: true,
|
||||
describe: "how many threads to use to parse notes",
|
||||
},
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import path from "path"
|
||||
import { readFileSync } from "fs"
|
||||
|
||||
/**
|
||||
* All constants relating to helpers or handlers
|
||||
*/
|
||||
export const ORIGIN_NAME = "origin"
|
||||
export const UPSTREAM_NAME = "upstream"
|
||||
export const QUARTZ_SOURCE_BRANCH = "v4"
|
||||
export const cwd = process.cwd()
|
||||
export const cacheDir = path.join(cwd, ".quartz-cache")
|
||||
export const cacheFile = "./quartz/.quartz-cache/transpiled-build.mjs"
|
||||
export const fp = "./quartz/build.ts"
|
||||
export const { version } = JSON.parse(readFileSync("./package.json").toString())
|
||||
export const contentCacheFolder = path.join(cacheDir, "content-cache")
|
||||
|
|
@ -1,620 +0,0 @@
|
|||
import { promises } from "fs"
|
||||
import path from "path"
|
||||
import esbuild from "esbuild"
|
||||
import { styleText } from "util"
|
||||
import { sassPlugin } from "esbuild-sass-plugin"
|
||||
import fs from "fs"
|
||||
import { intro, outro, select, text } from "@clack/prompts"
|
||||
import { rm } from "fs/promises"
|
||||
import chokidar from "chokidar"
|
||||
import prettyBytes from "pretty-bytes"
|
||||
import { execSync, spawnSync } from "child_process"
|
||||
import http from "http"
|
||||
import serveHandler from "serve-handler"
|
||||
import { WebSocketServer } from "ws"
|
||||
import { randomUUID } from "crypto"
|
||||
import { Mutex } from "async-mutex"
|
||||
import { CreateArgv } from "./args.js"
|
||||
import { globby } from "globby"
|
||||
import {
|
||||
exitIfCancel,
|
||||
escapePath,
|
||||
gitPull,
|
||||
popContentFolder,
|
||||
stashContentFolder,
|
||||
} from "./helpers.js"
|
||||
import {
|
||||
UPSTREAM_NAME,
|
||||
QUARTZ_SOURCE_BRANCH,
|
||||
ORIGIN_NAME,
|
||||
version,
|
||||
fp,
|
||||
cacheFile,
|
||||
cwd,
|
||||
} from "./constants.js"
|
||||
|
||||
/**
|
||||
* Resolve content directory path
|
||||
* @param contentPath path to resolve
|
||||
*/
|
||||
function resolveContentPath(contentPath) {
|
||||
if (path.isAbsolute(contentPath)) return path.relative(cwd, contentPath)
|
||||
return path.join(cwd, contentPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `npx quartz create`
|
||||
* @param {*} argv arguments for `create`
|
||||
*/
|
||||
export async function handleCreate(argv) {
|
||||
console.log()
|
||||
intro(styleText(["bgGreen", "black"], ` Quartz v${version} `))
|
||||
const contentFolder = resolveContentPath(argv.directory)
|
||||
let setupStrategy = argv.strategy?.toLowerCase()
|
||||
let linkResolutionStrategy = argv.links?.toLowerCase()
|
||||
const sourceDirectory = argv.source
|
||||
|
||||
// If all cmd arguments were provided, check if they're valid
|
||||
if (setupStrategy && linkResolutionStrategy) {
|
||||
// If setup isn't, "new", source argument is required
|
||||
if (setupStrategy !== "new") {
|
||||
// Error handling
|
||||
if (!sourceDirectory) {
|
||||
outro(
|
||||
styleText(
|
||||
"red",
|
||||
`Setup strategies (arg '${styleText(
|
||||
"yellow",
|
||||
`-${CreateArgv.strategy.alias[0]}`,
|
||||
)}') other than '${styleText(
|
||||
"yellow",
|
||||
"new",
|
||||
)}' require content folder argument ('${styleText(
|
||||
"yellow",
|
||||
`-${CreateArgv.source.alias[0]}`,
|
||||
)}') to be set`,
|
||||
),
|
||||
)
|
||||
process.exit(1)
|
||||
} else {
|
||||
if (!fs.existsSync(sourceDirectory)) {
|
||||
outro(
|
||||
styleText(
|
||||
"red",
|
||||
`Input directory to copy/symlink 'content' from not found ('${styleText(
|
||||
"yellow",
|
||||
sourceDirectory,
|
||||
)}', invalid argument "${styleText("yellow", `-${CreateArgv.source.alias[0]}`)})`,
|
||||
),
|
||||
)
|
||||
process.exit(1)
|
||||
} else if (!fs.lstatSync(sourceDirectory).isDirectory()) {
|
||||
outro(
|
||||
styleText(
|
||||
"red",
|
||||
`Source directory to copy/symlink 'content' from is not a directory (found file at '${styleText(
|
||||
"yellow",
|
||||
sourceDirectory,
|
||||
)}', invalid argument ${styleText("yellow", `-${CreateArgv.source.alias[0]}`)}")`,
|
||||
),
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use cli process if cmd args werent provided
|
||||
if (!setupStrategy) {
|
||||
setupStrategy = exitIfCancel(
|
||||
await select({
|
||||
message: `Choose how to initialize the content in \`${contentFolder}\``,
|
||||
options: [
|
||||
{ value: "new", label: "Empty Quartz" },
|
||||
{ value: "copy", label: "Copy an existing folder", hint: "overwrites `content`" },
|
||||
{
|
||||
value: "symlink",
|
||||
label: "Symlink an existing folder",
|
||||
hint: "don't select this unless you know what you are doing!",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function rmContentFolder() {
|
||||
const contentStat = await fs.promises.lstat(contentFolder)
|
||||
if (contentStat.isSymbolicLink()) {
|
||||
await fs.promises.unlink(contentFolder)
|
||||
} else {
|
||||
await rm(contentFolder, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const gitkeepPath = path.join(contentFolder, ".gitkeep")
|
||||
if (fs.existsSync(gitkeepPath)) {
|
||||
await fs.promises.unlink(gitkeepPath)
|
||||
}
|
||||
if (setupStrategy === "copy" || setupStrategy === "symlink") {
|
||||
let originalFolder = sourceDirectory
|
||||
|
||||
// If input directory was not passed, use cli
|
||||
if (!sourceDirectory) {
|
||||
originalFolder = escapePath(
|
||||
exitIfCancel(
|
||||
await text({
|
||||
message: "Enter the full path to existing content folder",
|
||||
placeholder:
|
||||
"On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path",
|
||||
validate(fp) {
|
||||
const fullPath = escapePath(fp)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return "The given path doesn't exist"
|
||||
} else if (!fs.lstatSync(fullPath).isDirectory()) {
|
||||
return "The given path is not a folder"
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
await rmContentFolder()
|
||||
if (setupStrategy === "copy") {
|
||||
await fs.promises.cp(originalFolder, contentFolder, {
|
||||
recursive: true,
|
||||
preserveTimestamps: true,
|
||||
})
|
||||
} else if (setupStrategy === "symlink") {
|
||||
await fs.promises.symlink(originalFolder, contentFolder, "dir")
|
||||
}
|
||||
} else if (setupStrategy === "new") {
|
||||
await fs.promises.writeFile(
|
||||
path.join(contentFolder, "index.md"),
|
||||
`---
|
||||
title: Welcome to Quartz
|
||||
---
|
||||
|
||||
This is a blank Quartz installation.
|
||||
See the [documentation](https://quartz.jzhao.xyz) for how to get started.
|
||||
`,
|
||||
)
|
||||
}
|
||||
|
||||
// Use cli process if cmd args werent provided
|
||||
if (!linkResolutionStrategy) {
|
||||
// get a preferred link resolution strategy
|
||||
linkResolutionStrategy = exitIfCancel(
|
||||
await select({
|
||||
message: `Choose how Quartz should resolve links in your content. This should match Obsidian's link format. You can change this later in \`quartz.config.ts\`.`,
|
||||
options: [
|
||||
{
|
||||
value: "shortest",
|
||||
label: "Treat links as shortest path",
|
||||
hint: "(default)",
|
||||
},
|
||||
{
|
||||
value: "absolute",
|
||||
label: "Treat links as absolute path",
|
||||
},
|
||||
{
|
||||
value: "relative",
|
||||
label: "Treat links as relative paths",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// now, do config changes
|
||||
const configFilePath = path.join(cwd, "quartz.config.ts")
|
||||
let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" })
|
||||
configContent = configContent.replace(
|
||||
/markdownLinkResolution: '(.+)'/,
|
||||
`markdownLinkResolution: '${linkResolutionStrategy}'`,
|
||||
)
|
||||
await fs.promises.writeFile(configFilePath, configContent)
|
||||
|
||||
// setup remote
|
||||
execSync(
|
||||
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
|
||||
{ stdio: "ignore" },
|
||||
)
|
||||
|
||||
outro(`You're all set! Not sure what to do next? Try:
|
||||
• Customizing Quartz a bit more by editing \`quartz.config.ts\`
|
||||
• Running \`npx quartz build --serve\` to preview your Quartz locally
|
||||
• Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting)
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `npx quartz build`
|
||||
* @param {*} argv arguments for `build`
|
||||
*/
|
||||
export async function handleBuild(argv) {
|
||||
if (argv.serve) {
|
||||
argv.watch = true
|
||||
}
|
||||
|
||||
console.log(`\n${styleText(["bgGreen", "black"], ` Quartz v${version} `)} \n`)
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: [fp],
|
||||
outfile: cacheFile,
|
||||
bundle: true,
|
||||
keepNames: true,
|
||||
minifyWhitespace: true,
|
||||
minifySyntax: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
jsx: "automatic",
|
||||
jsxImportSource: "preact",
|
||||
packages: "external",
|
||||
metafile: true,
|
||||
sourcemap: true,
|
||||
sourcesContent: false,
|
||||
plugins: [
|
||||
sassPlugin({
|
||||
type: "css-text",
|
||||
cssImports: true,
|
||||
}),
|
||||
sassPlugin({
|
||||
filter: /\.inline\.scss$/,
|
||||
type: "css",
|
||||
cssImports: true,
|
||||
}),
|
||||
{
|
||||
name: "inline-script-loader",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => {
|
||||
let text = await promises.readFile(args.path, "utf8")
|
||||
|
||||
// remove default exports that we manually inserted
|
||||
text = text.replace("export default", "")
|
||||
text = text.replace("export", "")
|
||||
|
||||
const sourcefile = path.relative(path.resolve("."), args.path)
|
||||
const resolveDir = path.dirname(sourcefile)
|
||||
const transpiled = await esbuild.build({
|
||||
stdin: {
|
||||
contents: text,
|
||||
loader: "ts",
|
||||
resolveDir,
|
||||
sourcefile,
|
||||
},
|
||||
write: false,
|
||||
bundle: true,
|
||||
minify: true,
|
||||
platform: "browser",
|
||||
format: "esm",
|
||||
})
|
||||
const rawMod = transpiled.outputFiles[0].text
|
||||
return {
|
||||
contents: rawMod,
|
||||
loader: "text",
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const buildMutex = new Mutex()
|
||||
let lastBuildMs = 0
|
||||
let cleanupBuild = null
|
||||
const build = async (clientRefresh) => {
|
||||
const buildStart = new Date().getTime()
|
||||
lastBuildMs = buildStart
|
||||
const release = await buildMutex.acquire()
|
||||
if (lastBuildMs > buildStart) {
|
||||
release()
|
||||
return
|
||||
}
|
||||
|
||||
if (cleanupBuild) {
|
||||
console.log(styleText("yellow", "Detected a source code change, doing a hard rebuild..."))
|
||||
await cleanupBuild()
|
||||
}
|
||||
|
||||
const result = await ctx.rebuild().catch((err) => {
|
||||
console.error(`${styleText("red", "Couldn't parse Quartz configuration:")} ${fp}`)
|
||||
console.log(`Reason: ${styleText("gray", err)}`)
|
||||
process.exit(1)
|
||||
})
|
||||
release()
|
||||
|
||||
if (argv.bundleInfo) {
|
||||
const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs"
|
||||
const meta = result.metafile.outputs[outputFileName]
|
||||
console.log(
|
||||
`Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes(
|
||||
meta.bytes,
|
||||
)})`,
|
||||
)
|
||||
console.log(await esbuild.analyzeMetafile(result.metafile, { color: true }))
|
||||
}
|
||||
|
||||
// bypass module cache
|
||||
// https://github.com/nodejs/modules/issues/307
|
||||
const { default: buildQuartz } = await import(`../../${cacheFile}?update=${randomUUID()}`)
|
||||
// ^ this import is relative, so base "cacheFile" path can't be used
|
||||
|
||||
cleanupBuild = await buildQuartz(argv, buildMutex, clientRefresh)
|
||||
clientRefresh()
|
||||
}
|
||||
|
||||
let clientRefresh = () => {}
|
||||
if (argv.serve) {
|
||||
const connections = []
|
||||
clientRefresh = () => connections.forEach((conn) => conn.send("rebuild"))
|
||||
|
||||
if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) {
|
||||
argv.baseDir = "/" + argv.baseDir
|
||||
}
|
||||
|
||||
await build(clientRefresh)
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (argv.baseDir && !req.url?.startsWith(argv.baseDir)) {
|
||||
console.log(
|
||||
styleText(
|
||||
"red",
|
||||
`[404] ${req.url} (warning: link outside of site, this is likely a Quartz bug)`,
|
||||
),
|
||||
)
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// strip baseDir prefix
|
||||
req.url = req.url?.slice(argv.baseDir.length)
|
||||
|
||||
const serve = async () => {
|
||||
const release = await buildMutex.acquire()
|
||||
await serveHandler(req, res, {
|
||||
public: argv.output,
|
||||
directoryListing: false,
|
||||
headers: [
|
||||
{
|
||||
source: "**/*.*",
|
||||
headers: [{ key: "Content-Disposition", value: "inline" }],
|
||||
},
|
||||
{
|
||||
source: "**/*.webp",
|
||||
headers: [{ key: "Content-Type", value: "image/webp" }],
|
||||
},
|
||||
// fixes bug where avif images are displayed as text instead of images (future proof)
|
||||
{
|
||||
source: "**/*.avif",
|
||||
headers: [{ key: "Content-Type", value: "image/avif" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
const status = res.statusCode
|
||||
const statusString =
|
||||
status >= 200 && status < 300
|
||||
? styleText("green", `[${status}]`)
|
||||
: styleText("red", `[${status}]`)
|
||||
console.log(statusString + styleText("gray", ` ${argv.baseDir}${req.url}`))
|
||||
release()
|
||||
}
|
||||
|
||||
const redirect = (newFp) => {
|
||||
newFp = argv.baseDir + newFp
|
||||
res.writeHead(302, {
|
||||
Location: newFp,
|
||||
})
|
||||
console.log(
|
||||
styleText("yellow", "[302]") +
|
||||
styleText("gray", ` ${argv.baseDir}${req.url} -> ${newFp}`),
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
|
||||
let fp = req.url?.split("?")[0] ?? "/"
|
||||
|
||||
// handle redirects
|
||||
if (fp.endsWith("/")) {
|
||||
// /trailing/
|
||||
// does /trailing/index.html exist? if so, serve it
|
||||
const indexFp = path.posix.join(fp, "index.html")
|
||||
if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
|
||||
req.url = fp
|
||||
return serve()
|
||||
}
|
||||
|
||||
// does /trailing.html exist? if so, redirect to /trailing
|
||||
let base = fp.slice(0, -1)
|
||||
if (path.extname(base) === "") {
|
||||
base += ".html"
|
||||
}
|
||||
if (fs.existsSync(path.posix.join(argv.output, base))) {
|
||||
return redirect(fp.slice(0, -1))
|
||||
}
|
||||
} else {
|
||||
// /regular
|
||||
// does /regular.html exist? if so, serve it
|
||||
let base = fp
|
||||
if (path.extname(base) === "") {
|
||||
base += ".html"
|
||||
}
|
||||
if (fs.existsSync(path.posix.join(argv.output, base))) {
|
||||
req.url = fp
|
||||
return serve()
|
||||
}
|
||||
|
||||
// does /regular/index.html exist? if so, redirect to /regular/
|
||||
let indexFp = path.posix.join(fp, "index.html")
|
||||
if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
|
||||
return redirect(fp + "/")
|
||||
}
|
||||
}
|
||||
|
||||
return serve()
|
||||
})
|
||||
|
||||
server.listen(argv.port)
|
||||
const wss = new WebSocketServer({ port: argv.wsPort })
|
||||
wss.on("connection", (ws) => connections.push(ws))
|
||||
console.log(
|
||||
styleText(
|
||||
"cyan",
|
||||
`Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
await build(clientRefresh)
|
||||
ctx.dispose()
|
||||
}
|
||||
|
||||
if (argv.watch) {
|
||||
const paths = await globby([
|
||||
"**/*.ts",
|
||||
"quartz/cli/*.js",
|
||||
"quartz/static/**/*",
|
||||
"**/*.tsx",
|
||||
"**/*.scss",
|
||||
"package.json",
|
||||
])
|
||||
chokidar
|
||||
.watch(paths, { ignoreInitial: true })
|
||||
.on("add", () => build(clientRefresh))
|
||||
.on("change", () => build(clientRefresh))
|
||||
.on("unlink", () => build(clientRefresh))
|
||||
|
||||
console.log(styleText("gray", "hint: exit with ctrl+c"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `npx quartz update`
|
||||
* @param {*} argv arguments for `update`
|
||||
*/
|
||||
export async function handleUpdate(argv) {
|
||||
const contentFolder = resolveContentPath(argv.directory)
|
||||
console.log(`\n${styleText(["bgGreen", "black"], ` Quartz v${version} `)} \n`)
|
||||
console.log("Backing up your content")
|
||||
execSync(
|
||||
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
|
||||
)
|
||||
await stashContentFolder(contentFolder)
|
||||
console.log(
|
||||
"Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
|
||||
)
|
||||
|
||||
try {
|
||||
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
|
||||
} catch {
|
||||
console.log(styleText("red", "An error occurred above while pulling updates."))
|
||||
await popContentFolder(contentFolder)
|
||||
return
|
||||
}
|
||||
|
||||
await popContentFolder(contentFolder)
|
||||
console.log("Ensuring dependencies are up to date")
|
||||
|
||||
/*
|
||||
On Windows, if the command `npm` is really `npm.cmd', this call fails
|
||||
as it will be unable to find `npm`. This is often the case on systems
|
||||
where `npm` is installed via a package manager.
|
||||
|
||||
This means `npx quartz update` will not actually update dependencies
|
||||
on Windows, without a manual `npm i` from the caller.
|
||||
|
||||
However, by spawning a shell, we are able to call `npm.cmd`.
|
||||
See: https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
|
||||
*/
|
||||
|
||||
const opts = { stdio: "inherit" }
|
||||
if (process.platform === "win32") {
|
||||
opts.shell = true
|
||||
}
|
||||
|
||||
const res = spawnSync("npm", ["i"], opts)
|
||||
if (res.status === 0) {
|
||||
console.log(styleText("green", "Done!"))
|
||||
} else {
|
||||
console.log(styleText("red", "An error occurred above while installing dependencies."))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `npx quartz restore`
|
||||
* @param {*} argv arguments for `restore`
|
||||
*/
|
||||
export async function handleRestore(argv) {
|
||||
const contentFolder = resolveContentPath(argv.directory)
|
||||
await popContentFolder(contentFolder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `npx quartz sync`
|
||||
* @param {*} argv arguments for `sync`
|
||||
*/
|
||||
export async function handleSync(argv) {
|
||||
const contentFolder = resolveContentPath(argv.directory)
|
||||
console.log(`\n${styleText(["bgGreen", "black"], ` Quartz v${version} `)}\n`)
|
||||
console.log("Backing up your content")
|
||||
|
||||
if (argv.commit) {
|
||||
const contentStat = await fs.promises.lstat(contentFolder)
|
||||
if (contentStat.isSymbolicLink()) {
|
||||
const linkTarg = await fs.promises.readlink(contentFolder)
|
||||
console.log(styleText("yellow", "Detected symlink, trying to dereference before committing"))
|
||||
|
||||
// stash symlink file
|
||||
await stashContentFolder(contentFolder)
|
||||
|
||||
// follow symlink and copy content
|
||||
await fs.promises.cp(linkTarg, contentFolder, {
|
||||
recursive: true,
|
||||
preserveTimestamps: true,
|
||||
})
|
||||
}
|
||||
|
||||
const currentTimestamp = new Date().toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})
|
||||
const commitMessage = argv.message ?? `Quartz sync: ${currentTimestamp}`
|
||||
spawnSync("git", ["add", "."], { stdio: "inherit" })
|
||||
spawnSync("git", ["commit", "-m", commitMessage], { stdio: "inherit" })
|
||||
|
||||
if (contentStat.isSymbolicLink()) {
|
||||
// put symlink back
|
||||
await popContentFolder(contentFolder)
|
||||
}
|
||||
}
|
||||
|
||||
await stashContentFolder(contentFolder)
|
||||
|
||||
if (argv.pull) {
|
||||
console.log(
|
||||
"Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.",
|
||||
)
|
||||
try {
|
||||
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
|
||||
} catch {
|
||||
console.log(styleText("red", "An error occurred above while pulling updates."))
|
||||
await popContentFolder(contentFolder)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await popContentFolder(contentFolder)
|
||||
if (argv.push) {
|
||||
console.log("Pushing your changes")
|
||||
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD").toString().trim()
|
||||
const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, currentBranch], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
if (res.status !== 0) {
|
||||
console.log(
|
||||
styleText("red", `An error occurred above while pushing to remote ${ORIGIN_NAME}.`),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
console.log(styleText("green", "Done!"))
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { isCancel, outro } from "@clack/prompts"
|
||||
import { styleText } from "util"
|
||||
import { contentCacheFolder } from "./constants.js"
|
||||
import { spawnSync } from "child_process"
|
||||
import fs from "fs"
|
||||
|
||||
export function escapePath(fp) {
|
||||
return fp
|
||||
.replace(/\\ /g, " ") // unescape spaces
|
||||
.replace(/^"(.*)"$/, "$1")
|
||||
.replace(/^'(.*)'$/, "$1")
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function exitIfCancel(val) {
|
||||
if (isCancel(val)) {
|
||||
outro(styleText("red", "Exiting"))
|
||||
process.exit(0)
|
||||
} else {
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
export async function stashContentFolder(contentFolder) {
|
||||
await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
|
||||
await fs.promises.cp(contentFolder, contentCacheFolder, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
preserveTimestamps: true,
|
||||
})
|
||||
await fs.promises.rm(contentFolder, { force: true, recursive: true })
|
||||
}
|
||||
|
||||
export function gitPull(origin, branch) {
|
||||
const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"]
|
||||
const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
|
||||
if (out.stderr) {
|
||||
throw new Error(styleText("red", `Error while pulling updates: ${out.stderr}`))
|
||||
} else if (out.status !== 0) {
|
||||
throw new Error(styleText("red", "Error while pulling updates"))
|
||||
}
|
||||
}
|
||||
|
||||
export async function popContentFolder(contentFolder) {
|
||||
await fs.promises.rm(contentFolder, { force: true, recursive: true })
|
||||
await fs.promises.cp(contentCacheFolder, contentFolder, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
preserveTimestamps: true,
|
||||
})
|
||||
await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
||||
const title = fileData.frontmatter?.title
|
||||
if (title) {
|
||||
return <h1 class={classNames(displayClass, "article-title")}>{title}</h1>
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ArticleTitle.css = `
|
||||
.article-title {
|
||||
margin: 2rem 0 0 0;
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => ArticleTitle) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/backlinks.scss"
|
||||
import { resolveRelative, simplifySlug } from "../util/path"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
import OverflowListFactory from "./OverflowList"
|
||||
|
||||
interface BacklinksOptions {
|
||||
hideWhenEmpty: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: BacklinksOptions = {
|
||||
hideWhenEmpty: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<BacklinksOptions>) => {
|
||||
const options: BacklinksOptions = { ...defaultOptions, ...opts }
|
||||
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
|
||||
|
||||
const Backlinks: QuartzComponent = ({
|
||||
fileData,
|
||||
allFiles,
|
||||
displayClass,
|
||||
cfg,
|
||||
}: QuartzComponentProps) => {
|
||||
const slug = simplifySlug(fileData.slug!)
|
||||
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
|
||||
if (options.hideWhenEmpty && backlinkFiles.length == 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div class={classNames(displayClass, "backlinks")}>
|
||||
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
|
||||
<OverflowList>
|
||||
{backlinkFiles.length > 0 ? (
|
||||
backlinkFiles.map((f) => (
|
||||
<li>
|
||||
<a href={resolveRelative(fileData.slug!, f.slug!)} class="internal">
|
||||
{f.frontmatter?.title}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
|
||||
)}
|
||||
</OverflowList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Backlinks.css = style
|
||||
Backlinks.afterDOMLoaded = overflowListAfterDOMLoaded
|
||||
|
||||
return Backlinks
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
// @ts-ignore
|
||||
import clipboardScript from "./scripts/clipboard.inline"
|
||||
import clipboardStyle from "./styles/clipboard.scss"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
const Body: QuartzComponent = ({ children }: QuartzComponentProps) => {
|
||||
return <div id="quartz-body">{children}</div>
|
||||
}
|
||||
|
||||
Body.afterDOMLoaded = clipboardScript
|
||||
Body.css = clipboardStyle
|
||||
|
||||
export default (() => Body) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
|
||||
import { FullSlug, SimpleSlug, resolveRelative, simplifySlug } from "../util/path"
|
||||
import { classNames } from "../util/lang"
|
||||
import { trieFromAllFiles } from "../util/ctx"
|
||||
|
||||
type CrumbData = {
|
||||
displayName: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface BreadcrumbOptions {
|
||||
/**
|
||||
* Symbol between crumbs
|
||||
*/
|
||||
spacerSymbol: string
|
||||
/**
|
||||
* Name of first crumb
|
||||
*/
|
||||
rootName: string
|
||||
/**
|
||||
* Whether to look up frontmatter title for folders (could cause performance problems with big vaults)
|
||||
*/
|
||||
resolveFrontmatterTitle: boolean
|
||||
/**
|
||||
* Whether to display the current page in the breadcrumbs.
|
||||
*/
|
||||
showCurrentPage: boolean
|
||||
/**
|
||||
* Whether to display the root crumb.
|
||||
*/
|
||||
showRoot: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: BreadcrumbOptions = {
|
||||
spacerSymbol: ">",
|
||||
rootName: "Home",
|
||||
resolveFrontmatterTitle: true,
|
||||
showCurrentPage: true,
|
||||
showRoot: true,
|
||||
}
|
||||
|
||||
function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData {
|
||||
return {
|
||||
displayName: displayName.replaceAll("-", " "),
|
||||
path: resolveRelative(baseSlug, currentSlug),
|
||||
}
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<BreadcrumbOptions>) => {
|
||||
const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
|
||||
const Breadcrumbs: QuartzComponent = ({
|
||||
fileData,
|
||||
allFiles,
|
||||
displayClass,
|
||||
ctx,
|
||||
}: QuartzComponentProps) => {
|
||||
const trie = (ctx.trie ??= trieFromAllFiles(allFiles))
|
||||
const slugParts = fileData.slug!.split("/")
|
||||
const pathNodes = trie.ancestryChain(slugParts)
|
||||
|
||||
if (!pathNodes) {
|
||||
return null
|
||||
}
|
||||
|
||||
const crumbs: CrumbData[] = pathNodes.map((node, idx) => {
|
||||
const crumb = formatCrumb(node.displayName, fileData.slug!, simplifySlug(node.slug))
|
||||
if (idx === 0) {
|
||||
crumb.displayName = options.rootName
|
||||
}
|
||||
|
||||
if (idx === pathNodes.length - 1) {
|
||||
crumb.path = ""
|
||||
}
|
||||
|
||||
return crumb
|
||||
})
|
||||
|
||||
if (!options.showCurrentPage) {
|
||||
crumbs.pop()
|
||||
}
|
||||
|
||||
if (!options.showRoot) {
|
||||
crumbs.shift()
|
||||
}
|
||||
|
||||
if (crumbs.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<nav class={classNames(displayClass, "breadcrumb-container")} aria-label="breadcrumbs">
|
||||
{crumbs.map((crumb) => (
|
||||
<div class="breadcrumb-element">
|
||||
<a href={crumb.path}>{crumb.displayName}</a>
|
||||
<span class="breadcrumb-separator" aria-hidden="true">
|
||||
{options.spacerSymbol}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
Breadcrumbs.css = breadcrumbsStyle
|
||||
|
||||
return Breadcrumbs
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/comments.inline"
|
||||
|
||||
type Options = {
|
||||
provider: "giscus"
|
||||
options: {
|
||||
repo: `${string}/${string}`
|
||||
repoId: string
|
||||
category: string
|
||||
categoryId: string
|
||||
themeUrl?: string
|
||||
lightTheme?: string
|
||||
darkTheme?: string
|
||||
mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
|
||||
strict?: boolean
|
||||
reactionsEnabled?: boolean
|
||||
inputPosition?: "top" | "bottom"
|
||||
lang?: string
|
||||
}
|
||||
}
|
||||
|
||||
function boolToStringBool(b: boolean): string {
|
||||
return b ? "1" : "0"
|
||||
}
|
||||
|
||||
export default ((opts: Options) => {
|
||||
const Comments: QuartzComponent = ({ displayClass, fileData, cfg }: QuartzComponentProps) => {
|
||||
// check if comments should be displayed according to frontmatter
|
||||
const disableComment: boolean =
|
||||
typeof fileData.frontmatter?.comments !== "undefined" &&
|
||||
(!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false")
|
||||
if (disableComment) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classNames(displayClass, "giscus")}
|
||||
data-repo={opts.options.repo}
|
||||
data-repo-id={opts.options.repoId}
|
||||
data-category={opts.options.category}
|
||||
data-category-id={opts.options.categoryId}
|
||||
data-mapping={opts.options.mapping ?? "url"}
|
||||
data-strict={boolToStringBool(opts.options.strict ?? true)}
|
||||
data-reactions-enabled={boolToStringBool(opts.options.reactionsEnabled ?? true)}
|
||||
data-input-position={opts.options.inputPosition ?? "bottom"}
|
||||
data-light-theme={opts.options.lightTheme ?? "light"}
|
||||
data-dark-theme={opts.options.darkTheme ?? "dark"}
|
||||
data-theme-url={
|
||||
opts.options.themeUrl ?? `https://${cfg.baseUrl ?? "example.com"}/static/giscus`
|
||||
}
|
||||
data-lang={opts.options.lang ?? "en"}
|
||||
></div>
|
||||
)
|
||||
}
|
||||
|
||||
Comments.afterDOMLoaded = script
|
||||
|
||||
return Comments
|
||||
}) satisfies QuartzComponentConstructor<Options>
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
type ConditionalRenderConfig = {
|
||||
component: QuartzComponent
|
||||
condition: (props: QuartzComponentProps) => boolean
|
||||
}
|
||||
|
||||
export default ((config: ConditionalRenderConfig) => {
|
||||
const ConditionalRender: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
if (config.condition(props)) {
|
||||
return <config.component {...props} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
ConditionalRender.afterDOMLoaded = config.component.afterDOMLoaded
|
||||
ConditionalRender.beforeDOMLoaded = config.component.beforeDOMLoaded
|
||||
ConditionalRender.css = config.component.css
|
||||
|
||||
return ConditionalRender
|
||||
}) satisfies QuartzComponentConstructor<ConditionalRenderConfig>
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { Date, getDate } from "./Date"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import readingTime from "reading-time"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
import { JSX } from "preact"
|
||||
import style from "./styles/contentMeta.scss"
|
||||
|
||||
interface ContentMetaOptions {
|
||||
/**
|
||||
* Whether to display reading time
|
||||
*/
|
||||
showReadingTime: boolean
|
||||
showComma: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: ContentMetaOptions = {
|
||||
showReadingTime: true,
|
||||
showComma: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<ContentMetaOptions>) => {
|
||||
// Merge options with defaults
|
||||
const options: ContentMetaOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
|
||||
const text = fileData.text
|
||||
|
||||
if (text) {
|
||||
const segments: (string | JSX.Element)[] = []
|
||||
|
||||
if (fileData.dates) {
|
||||
segments.push(<Date date={getDate(cfg, fileData)!} locale={cfg.locale} />)
|
||||
}
|
||||
|
||||
// Display reading time if enabled
|
||||
if (options.showReadingTime) {
|
||||
const { minutes, words: _words } = readingTime(text)
|
||||
const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({
|
||||
minutes: Math.ceil(minutes),
|
||||
})
|
||||
segments.push(<span>{displayedTime}</span>)
|
||||
}
|
||||
|
||||
return (
|
||||
<p show-comma={options.showComma} class={classNames(displayClass, "content-meta")}>
|
||||
{segments}
|
||||
</p>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ContentMetadata.css = style
|
||||
|
||||
return ContentMetadata
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
// @ts-ignore
|
||||
import darkmodeScript from "./scripts/darkmode.inline"
|
||||
import styles from "./styles/darkmode.scss"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const Darkmode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
return (
|
||||
<button class={classNames(displayClass, "darkmode")}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
class="dayIcon"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 35 35"
|
||||
style="enable-background:new 0 0 35 35"
|
||||
xmlSpace="preserve"
|
||||
aria-label={i18n(cfg.locale).components.themeToggle.darkMode}
|
||||
>
|
||||
<title>{i18n(cfg.locale).components.themeToggle.darkMode}</title>
|
||||
<path d="M6,17.5C6,16.672,5.328,16,4.5,16h-3C0.672,16,0,16.672,0,17.5 S0.672,19,1.5,19h3C5.328,19,6,18.328,6,17.5z M7.5,26c-0.414,0-0.789,0.168-1.061,0.439l-2,2C4.168,28.711,4,29.086,4,29.5 C4,30.328,4.671,31,5.5,31c0.414,0,0.789-0.168,1.06-0.44l2-2C8.832,28.289,9,27.914,9,27.5C9,26.672,8.329,26,7.5,26z M17.5,6 C18.329,6,19,5.328,19,4.5v-3C19,0.672,18.329,0,17.5,0S16,0.672,16,1.5v3C16,5.328,16.671,6,17.5,6z M27.5,9 c0.414,0,0.789-0.168,1.06-0.439l2-2C30.832,6.289,31,5.914,31,5.5C31,4.672,30.329,4,29.5,4c-0.414,0-0.789,0.168-1.061,0.44 l-2,2C26.168,6.711,26,7.086,26,7.5C26,8.328,26.671,9,27.5,9z M6.439,8.561C6.711,8.832,7.086,9,7.5,9C8.328,9,9,8.328,9,7.5 c0-0.414-0.168-0.789-0.439-1.061l-2-2C6.289,4.168,5.914,4,5.5,4C4.672,4,4,4.672,4,5.5c0,0.414,0.168,0.789,0.439,1.06 L6.439,8.561z M33.5,16h-3c-0.828,0-1.5,0.672-1.5,1.5s0.672,1.5,1.5,1.5h3c0.828,0,1.5-0.672,1.5-1.5S34.328,16,33.5,16z M28.561,26.439C28.289,26.168,27.914,26,27.5,26c-0.828,0-1.5,0.672-1.5,1.5c0,0.414,0.168,0.789,0.439,1.06l2,2 C28.711,30.832,29.086,31,29.5,31c0.828,0,1.5-0.672,1.5-1.5c0-0.414-0.168-0.789-0.439-1.061L28.561,26.439z M17.5,29 c-0.829,0-1.5,0.672-1.5,1.5v3c0,0.828,0.671,1.5,1.5,1.5s1.5-0.672,1.5-1.5v-3C19,29.672,18.329,29,17.5,29z M17.5,7 C11.71,7,7,11.71,7,17.5S11.71,28,17.5,28S28,23.29,28,17.5S23.29,7,17.5,7z M17.5,25c-4.136,0-7.5-3.364-7.5-7.5 c0-4.136,3.364-7.5,7.5-7.5c4.136,0,7.5,3.364,7.5,7.5C25,21.636,21.636,25,17.5,25z"></path>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
class="nightIcon"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 100 100"
|
||||
style="enable-background:new 0 0 100 100"
|
||||
xmlSpace="preserve"
|
||||
aria-label={i18n(cfg.locale).components.themeToggle.lightMode}
|
||||
>
|
||||
<title>{i18n(cfg.locale).components.themeToggle.lightMode}</title>
|
||||
<path d="M96.76,66.458c-0.853-0.852-2.15-1.064-3.23-0.534c-6.063,2.991-12.858,4.571-19.655,4.571 C62.022,70.495,50.88,65.88,42.5,57.5C29.043,44.043,25.658,23.536,34.076,6.47c0.532-1.08,0.318-2.379-0.534-3.23 c-0.851-0.852-2.15-1.064-3.23-0.534c-4.918,2.427-9.375,5.619-13.246,9.491c-9.447,9.447-14.65,22.008-14.65,35.369 c0,13.36,5.203,25.921,14.65,35.368s22.008,14.65,35.368,14.65c13.361,0,25.921-5.203,35.369-14.65 c3.872-3.871,7.064-8.328,9.491-13.246C97.826,68.608,97.611,67.309,96.76,66.458z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
Darkmode.beforeDOMLoaded = darkmodeScript
|
||||
Darkmode.css = styles
|
||||
|
||||
export default (() => Darkmode) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { GlobalConfiguration } from "../cfg"
|
||||
import { ValidLocale } from "../i18n"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
|
||||
interface Props {
|
||||
date: Date
|
||||
locale?: ValidLocale
|
||||
}
|
||||
|
||||
export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
|
||||
|
||||
export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date | undefined {
|
||||
if (!cfg.defaultDateType) {
|
||||
throw new Error(
|
||||
`Field 'defaultDateType' was not set in the configuration object of quartz.config.ts. See https://quartz.jzhao.xyz/configuration#general-configuration for more details.`,
|
||||
)
|
||||
}
|
||||
return data.dates?.[cfg.defaultDateType]
|
||||
}
|
||||
|
||||
export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
|
||||
return d.toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function Date({ date, locale }: Props) {
|
||||
return <time datetime={date.toISOString()}>{formatDate(date, locale)}</time>
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
export default ((component: QuartzComponent) => {
|
||||
const Component = component
|
||||
const DesktopOnly: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
return <Component displayClass="desktop-only" {...props} />
|
||||
}
|
||||
|
||||
DesktopOnly.displayName = component.displayName
|
||||
DesktopOnly.afterDOMLoaded = component?.afterDOMLoaded
|
||||
DesktopOnly.beforeDOMLoaded = component?.beforeDOMLoaded
|
||||
DesktopOnly.css = component?.css
|
||||
return DesktopOnly
|
||||
}) satisfies QuartzComponentConstructor<QuartzComponent>
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/explorer.scss"
|
||||
|
||||
// @ts-ignore
|
||||
import script from "./scripts/explorer.inline"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
import { FileTrieNode } from "../util/fileTrie"
|
||||
import OverflowListFactory from "./OverflowList"
|
||||
import { concatenateResources } from "../util/resources"
|
||||
|
||||
type OrderEntries = "sort" | "filter" | "map"
|
||||
|
||||
export interface Options {
|
||||
title?: string
|
||||
folderDefaultState: "collapsed" | "open"
|
||||
folderClickBehavior: "collapse" | "link"
|
||||
useSavedState: boolean
|
||||
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
|
||||
filterFn: (node: FileTrieNode) => boolean
|
||||
mapFn: (node: FileTrieNode) => void
|
||||
order: OrderEntries[]
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
folderDefaultState: "collapsed",
|
||||
folderClickBehavior: "link",
|
||||
useSavedState: true,
|
||||
mapFn: (node) => {
|
||||
return node
|
||||
},
|
||||
sortFn: (a, b) => {
|
||||
// Sort order: folders first, then files. Sort folders and files alphabeticall
|
||||
if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
|
||||
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
|
||||
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
|
||||
return a.displayName.localeCompare(b.displayName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
})
|
||||
}
|
||||
|
||||
if (!a.isFolder && b.isFolder) {
|
||||
return 1
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
},
|
||||
filterFn: (node) => node.slugSegment !== "tags",
|
||||
order: ["filter", "map", "sort"],
|
||||
}
|
||||
|
||||
export type FolderState = {
|
||||
path: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
let numExplorers = 0
|
||||
export default ((userOpts?: Partial<Options>) => {
|
||||
const opts: Options = { ...defaultOptions, ...userOpts }
|
||||
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
|
||||
|
||||
const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
|
||||
const id = `explorer-${numExplorers++}`
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classNames(displayClass, "explorer")}
|
||||
data-behavior={opts.folderClickBehavior}
|
||||
data-collapsed={opts.folderDefaultState}
|
||||
data-savestate={opts.useSavedState}
|
||||
data-data-fns={JSON.stringify({
|
||||
order: opts.order,
|
||||
sortFn: opts.sortFn.toString(),
|
||||
filterFn: opts.filterFn.toString(),
|
||||
mapFn: opts.mapFn.toString(),
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="explorer-toggle mobile-explorer hide-until-loaded"
|
||||
data-mobile={true}
|
||||
aria-controls={id}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide-menu"
|
||||
>
|
||||
<line x1="4" x2="20" y1="12" y2="12" />
|
||||
<line x1="4" x2="20" y1="6" y2="6" />
|
||||
<line x1="4" x2="20" y1="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="title-button explorer-toggle desktop-explorer"
|
||||
data-mobile={false}
|
||||
aria-expanded={true}
|
||||
>
|
||||
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="5 8 14 8"
|
||||
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 id={id} class="explorer-content" aria-expanded={false} role="group">
|
||||
<OverflowList class="explorer-ul" />
|
||||
</div>
|
||||
<template id="template-file">
|
||||
<li>
|
||||
<a href="#"></a>
|
||||
</li>
|
||||
</template>
|
||||
<template id="template-folder">
|
||||
<li>
|
||||
<div class="folder-container">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="5 8 14 8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="folder-icon"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
<div>
|
||||
<button class="folder-button">
|
||||
<span class="folder-title"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="folder-outer">
|
||||
<ul class="content"></ul>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Explorer.css = style
|
||||
Explorer.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
|
||||
return Explorer
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { concatenateResources } from "../util/resources"
|
||||
import { classNames } from "../util/lang"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
type FlexConfig = {
|
||||
components: {
|
||||
Component: QuartzComponent
|
||||
grow?: boolean
|
||||
shrink?: boolean
|
||||
basis?: string
|
||||
order?: number
|
||||
align?: "start" | "end" | "center" | "stretch"
|
||||
justify?: "start" | "end" | "center" | "between" | "around"
|
||||
}[]
|
||||
direction?: "row" | "row-reverse" | "column" | "column-reverse"
|
||||
wrap?: "nowrap" | "wrap" | "wrap-reverse"
|
||||
gap?: string
|
||||
}
|
||||
|
||||
export default ((config: FlexConfig) => {
|
||||
const Flex: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
const direction = config.direction ?? "row"
|
||||
const wrap = config.wrap ?? "nowrap"
|
||||
const gap = config.gap ?? "1rem"
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classNames(props.displayClass, "flex-component")}
|
||||
style={`flex-direction: ${direction}; flex-wrap: ${wrap}; gap: ${gap};`}
|
||||
>
|
||||
{config.components.map((c) => {
|
||||
const grow = c.grow ? 1 : 0
|
||||
const shrink = (c.shrink ?? true) ? 1 : 0
|
||||
const basis = c.basis ?? "auto"
|
||||
const order = c.order ?? 0
|
||||
const align = c.align ?? "center"
|
||||
const justify = c.justify ?? "center"
|
||||
|
||||
return (
|
||||
<div
|
||||
style={`flex-grow: ${grow}; flex-shrink: ${shrink}; flex-basis: ${basis}; order: ${order}; align-self: ${align}; justify-self: ${justify};`}
|
||||
>
|
||||
<c.Component {...props} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Flex.afterDOMLoaded = concatenateResources(
|
||||
...config.components.map((c) => c.Component.afterDOMLoaded),
|
||||
)
|
||||
Flex.beforeDOMLoaded = concatenateResources(
|
||||
...config.components.map((c) => c.Component.beforeDOMLoaded),
|
||||
)
|
||||
Flex.css = concatenateResources(...config.components.map((c) => c.Component.css))
|
||||
return Flex
|
||||
}) satisfies QuartzComponentConstructor<FlexConfig>
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/footer.scss"
|
||||
import { version } from "../../package.json"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
interface Options {
|
||||
links: Record<string, string>
|
||||
}
|
||||
|
||||
export default ((opts?: Options) => {
|
||||
const Footer: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const year = new Date().getFullYear()
|
||||
const links = opts?.links ?? []
|
||||
return (
|
||||
<footer class={`${displayClass ?? ""}`}>
|
||||
<p>
|
||||
{i18n(cfg.locale).components.footer.createdWith}{" "}
|
||||
<a href="https://quartz.jzhao.xyz/">Quartz v{version}</a> © {year}
|
||||
</p>
|
||||
<ul>
|
||||
{Object.entries(links).map(([text, link]) => (
|
||||
<li>
|
||||
<a href={link}>{text}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
Footer.css = style
|
||||
return Footer
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/graph.inline"
|
||||
import style from "./styles/graph.scss"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
export interface D3Config {
|
||||
drag: boolean
|
||||
zoom: boolean
|
||||
depth: number
|
||||
scale: number
|
||||
repelForce: number
|
||||
centerForce: number
|
||||
linkDistance: number
|
||||
fontSize: number
|
||||
opacityScale: number
|
||||
removeTags: string[]
|
||||
showTags: boolean
|
||||
focusOnHover?: boolean
|
||||
enableRadial?: boolean
|
||||
}
|
||||
|
||||
interface GraphOptions {
|
||||
localGraph: Partial<D3Config> | undefined
|
||||
globalGraph: Partial<D3Config> | undefined
|
||||
}
|
||||
|
||||
const defaultOptions: GraphOptions = {
|
||||
localGraph: {
|
||||
drag: true,
|
||||
zoom: true,
|
||||
depth: 1,
|
||||
scale: 1.1,
|
||||
repelForce: 0.5,
|
||||
centerForce: 0.3,
|
||||
linkDistance: 30,
|
||||
fontSize: 0.6,
|
||||
opacityScale: 1,
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: false,
|
||||
enableRadial: false,
|
||||
},
|
||||
globalGraph: {
|
||||
drag: true,
|
||||
zoom: true,
|
||||
depth: -1,
|
||||
scale: 0.9,
|
||||
repelForce: 0.5,
|
||||
centerForce: 0.2,
|
||||
linkDistance: 30,
|
||||
fontSize: 0.6,
|
||||
opacityScale: 1,
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: true,
|
||||
enableRadial: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<GraphOptions>) => {
|
||||
const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
|
||||
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
|
||||
return (
|
||||
<div class={classNames(displayClass, "graph")}>
|
||||
<h3>{i18n(cfg.locale).components.graph.title}</h3>
|
||||
<div class="graph-outer">
|
||||
<div class="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
|
||||
<button class="global-graph-icon" aria-label="Global Graph">
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 55 55"
|
||||
fill="currentColor"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<path
|
||||
d="M49,0c-3.309,0-6,2.691-6,6c0,1.035,0.263,2.009,0.726,2.86l-9.829,9.829C32.542,17.634,30.846,17,29,17
|
||||
s-3.542,0.634-4.898,1.688l-7.669-7.669C16.785,10.424,17,9.74,17,9c0-2.206-1.794-4-4-4S9,6.794,9,9s1.794,4,4,4
|
||||
c0.74,0,1.424-0.215,2.019-0.567l7.669,7.669C21.634,21.458,21,23.154,21,25s0.634,3.542,1.688,4.897L10.024,42.562
|
||||
C8.958,41.595,7.549,41,6,41c-3.309,0-6,2.691-6,6s2.691,6,6,6s6-2.691,6-6c0-1.035-0.263-2.009-0.726-2.86l12.829-12.829
|
||||
c1.106,0.86,2.44,1.436,3.898,1.619v10.16c-2.833,0.478-5,2.942-5,5.91c0,3.309,2.691,6,6,6s6-2.691,6-6c0-2.967-2.167-5.431-5-5.91
|
||||
v-10.16c1.458-0.183,2.792-0.759,3.898-1.619l7.669,7.669C41.215,39.576,41,40.26,41,41c0,2.206,1.794,4,4,4s4-1.794,4-4
|
||||
s-1.794-4-4-4c-0.74,0-1.424,0.215-2.019,0.567l-7.669-7.669C36.366,28.542,37,26.846,37,25s-0.634-3.542-1.688-4.897l9.665-9.665
|
||||
C46.042,11.405,47.451,12,49,12c3.309,0,6-2.691,6-6S52.309,0,49,0z M11,9c0-1.103,0.897-2,2-2s2,0.897,2,2s-0.897,2-2,2
|
||||
S11,10.103,11,9z M6,51c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S8.206,51,6,51z M33,49c0,2.206-1.794,4-4,4s-4-1.794-4-4
|
||||
s1.794-4,4-4S33,46.794,33,49z M29,31c-3.309,0-6-2.691-6-6s2.691-6,6-6s6,2.691,6,6S32.309,31,29,31z M47,41c0,1.103-0.897,2-2,2
|
||||
s-2-0.897-2-2s0.897-2,2-2S47,39.897,47,41z M49,10c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S51.206,10,49,10z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="global-graph-outer">
|
||||
<div class="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Graph.css = style
|
||||
Graph.afterDOMLoaded = script
|
||||
|
||||
return Graph
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import { i18n } from "../i18n"
|
||||
import { FullSlug, getFileExtension, joinSegments, pathToRoot } from "../util/path"
|
||||
import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
|
||||
import { googleFontHref, googleFontSubsetHref } from "../util/theme"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { unescapeHTML } from "../util/escape"
|
||||
import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage"
|
||||
export default (() => {
|
||||
const Head: QuartzComponent = ({
|
||||
cfg,
|
||||
fileData,
|
||||
externalResources,
|
||||
ctx,
|
||||
}: QuartzComponentProps) => {
|
||||
const titleSuffix = cfg.pageTitleSuffix ?? ""
|
||||
let resolvedTitle = fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
|
||||
|
||||
if (fileData.slug === "index") {
|
||||
resolvedTitle = cfg.pageTitle
|
||||
} else if (resolvedTitle === "index" && fileData.slug?.endsWith("/index")) {
|
||||
resolvedTitle = fileData.slug.split("/").slice(-2, -1)[0] ?? resolvedTitle
|
||||
}
|
||||
|
||||
const title = resolvedTitle + titleSuffix
|
||||
const description =
|
||||
fileData.frontmatter?.socialDescription ??
|
||||
fileData.frontmatter?.description ??
|
||||
unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description)
|
||||
|
||||
const { css, js, additionalHead } = externalResources
|
||||
|
||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||
const path = url.pathname as FullSlug
|
||||
const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!)
|
||||
const iconPath = joinSegments(baseDir, "static/icon.png")
|
||||
|
||||
// Url of current page
|
||||
const socialUrl =
|
||||
fileData.slug === "404" ? url.toString() : joinSegments(url.toString(), fileData.slug!)
|
||||
|
||||
const usesCustomOgImage = ctx.cfg.plugins.emitters.some(
|
||||
(e) => e.name === CustomOgImagesEmitterName,
|
||||
)
|
||||
const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png`
|
||||
|
||||
return (
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<meta charSet="utf-8" />
|
||||
{cfg.theme.cdnCaching && cfg.theme.fontOrigin === "googleFonts" && (
|
||||
<>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link rel="stylesheet" href={googleFontHref(cfg.theme)} />
|
||||
{cfg.theme.typography.title && (
|
||||
<link rel="stylesheet" href={googleFontSubsetHref(cfg.theme, cfg.pageTitle)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin="anonymous" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<meta name="og:site_name" content={cfg.pageTitle}></meta>
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image:alt" content={description} />
|
||||
|
||||
{!usesCustomOgImage && (
|
||||
<>
|
||||
<meta property="og:image" content={ogImageDefaultPath} />
|
||||
<meta property="og:image:url" content={ogImageDefaultPath} />
|
||||
<meta name="twitter:image" content={ogImageDefaultPath} />
|
||||
<meta
|
||||
property="og:image:type"
|
||||
content={`image/${getFileExtension(ogImageDefaultPath) ?? "png"}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{cfg.baseUrl && (
|
||||
<>
|
||||
<meta property="twitter:domain" content={cfg.baseUrl}></meta>
|
||||
<meta property="og:url" content={socialUrl}></meta>
|
||||
<meta property="twitter:url" content={socialUrl}></meta>
|
||||
</>
|
||||
)}
|
||||
|
||||
<link rel="icon" href={iconPath} />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content="Quartz" />
|
||||
|
||||
{css.map((resource) => CSSResourceToStyleElement(resource, true))}
|
||||
{js
|
||||
.filter((resource) => resource.loadTime === "beforeDOMReady")
|
||||
.map((res) => JSResourceToScriptElement(res, true))}
|
||||
{additionalHead.map((resource) => {
|
||||
if (typeof resource === "function") {
|
||||
return resource(fileData)
|
||||
} else {
|
||||
return resource
|
||||
}
|
||||
})}
|
||||
</head>
|
||||
)
|
||||
}
|
||||
|
||||
return Head
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
const Header: QuartzComponent = ({ children }: QuartzComponentProps) => {
|
||||
return children.length > 0 ? <header>{children}</header> : null
|
||||
}
|
||||
|
||||
Header.css = `
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin: 2rem 0;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
flex: auto;
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => Header) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
export default ((component: QuartzComponent) => {
|
||||
const Component = component
|
||||
const MobileOnly: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
return <Component displayClass="mobile-only" {...props} />
|
||||
}
|
||||
|
||||
MobileOnly.displayName = component.displayName
|
||||
MobileOnly.afterDOMLoaded = component?.afterDOMLoaded
|
||||
MobileOnly.beforeDOMLoaded = component?.beforeDOMLoaded
|
||||
MobileOnly.css = component?.css
|
||||
return MobileOnly
|
||||
}) satisfies QuartzComponentConstructor<QuartzComponent>
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { JSX } from "preact"
|
||||
|
||||
const OverflowList = ({
|
||||
children,
|
||||
...props
|
||||
}: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => {
|
||||
return (
|
||||
<ul {...props} class={[props.class, "overflow"].filter(Boolean).join(" ")} id={props.id}>
|
||||
{children}
|
||||
<li class="overflow-end" />
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
let numLists = 0
|
||||
export default () => {
|
||||
const id = `list-${numLists++}`
|
||||
|
||||
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 {
|
||||
parentUl.classList.add("gradient-active")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ul = document.getElementById("${id}")
|
||||
if (!ul) return
|
||||
|
||||
const end = ul.querySelector(".overflow-end")
|
||||
if (!end) return
|
||||
|
||||
observer.observe(end)
|
||||
window.addCleanup(() => observer.disconnect())
|
||||
})
|
||||
`,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import { FullSlug, isFolderPath, resolveRelative } from "../util/path"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { Date, getDate } from "./Date"
|
||||
import { QuartzComponent, QuartzComponentProps } from "./types"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
|
||||
export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||
|
||||
export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn {
|
||||
return (f1, f2) => {
|
||||
// Sort by date/alphabetical
|
||||
if (f1.dates && f2.dates) {
|
||||
// sort descending
|
||||
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
|
||||
} else if (f1.dates && !f2.dates) {
|
||||
// prioritize files with dates
|
||||
return -1
|
||||
} else if (!f1.dates && f2.dates) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// otherwise, sort lexographically by title
|
||||
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
|
||||
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
|
||||
return f1Title.localeCompare(f2Title)
|
||||
}
|
||||
}
|
||||
|
||||
export function byDateAndAlphabeticalFolderFirst(cfg: GlobalConfiguration): SortFn {
|
||||
return (f1, f2) => {
|
||||
// Sort folders first
|
||||
const f1IsFolder = isFolderPath(f1.slug ?? "")
|
||||
const f2IsFolder = isFolderPath(f2.slug ?? "")
|
||||
if (f1IsFolder && !f2IsFolder) return -1
|
||||
if (!f1IsFolder && f2IsFolder) return 1
|
||||
|
||||
// If both are folders or both are files, sort by date/alphabetical
|
||||
if (f1.dates && f2.dates) {
|
||||
// sort descending
|
||||
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
|
||||
} else if (f1.dates && !f2.dates) {
|
||||
// prioritize files with dates
|
||||
return -1
|
||||
} else if (!f1.dates && f2.dates) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// otherwise, sort lexographically by title
|
||||
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
|
||||
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
|
||||
return f1Title.localeCompare(f2Title)
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
limit?: number
|
||||
sort?: SortFn
|
||||
} & QuartzComponentProps
|
||||
|
||||
export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort }: Props) => {
|
||||
const sorter = sort ?? byDateAndAlphabeticalFolderFirst(cfg)
|
||||
let list = allFiles.sort(sorter)
|
||||
if (limit) {
|
||||
list = list.slice(0, limit)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul class="section-ul">
|
||||
{list.map((page) => {
|
||||
const title = page.frontmatter?.title
|
||||
const tags = page.frontmatter?.tags ?? []
|
||||
|
||||
return (
|
||||
<li class="section-li">
|
||||
<div class="section">
|
||||
<p class="meta">
|
||||
{page.dates && <Date date={getDate(cfg, page)!} locale={cfg.locale} />}
|
||||
</p>
|
||||
<div class="desc">
|
||||
<h3>
|
||||
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
|
||||
{title}
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
<ul class="tags">
|
||||
{tags.map((tag) => (
|
||||
<li>
|
||||
<a
|
||||
class="internal tag-link"
|
||||
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
PageList.css = `
|
||||
.section h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section > .tags {
|
||||
margin: 0;
|
||||
}
|
||||
`
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { pathToRoot } from "../util/path"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
const PageTitle: QuartzComponent = ({ fileData, cfg, displayClass }: QuartzComponentProps) => {
|
||||
const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title
|
||||
const baseDir = pathToRoot(fileData.slug!)
|
||||
return (
|
||||
<h2 class={classNames(displayClass, "page-title")}>
|
||||
<a href={baseDir}>{title}</a>
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
PageTitle.css = `
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
margin: 0;
|
||||
font-family: var(--titleFont);
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => PageTitle) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
// @ts-ignore
|
||||
import readerModeScript from "./scripts/readermode.inline"
|
||||
import styles from "./styles/readermode.scss"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const ReaderMode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
return (
|
||||
<button class={classNames(displayClass, "readermode")}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
class="readerIcon"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
width="64px"
|
||||
height="64px"
|
||||
viewBox="0 0 24 24"
|
||||
aria-label={i18n(cfg.locale).components.readerMode.title}
|
||||
>
|
||||
<title>{i18n(cfg.locale).components.readerMode.title}</title>
|
||||
<g transform="translate(-1.8, -1.8) scale(1.15, 1.2)">
|
||||
<path d="M8.9891247,2.5 C10.1384702,2.5 11.2209868,2.96705384 12.0049645,3.76669482 C12.7883914,2.96705384 13.8709081,2.5 15.0202536,2.5 L18.7549359,2.5 C19.1691495,2.5 19.5049359,2.83578644 19.5049359,3.25 L19.5046891,4.004 L21.2546891,4.00457396 C21.6343849,4.00457396 21.9481801,4.28672784 21.9978425,4.6528034 L22.0046891,4.75457396 L22.0046891,20.25 C22.0046891,20.6296958 21.7225353,20.943491 21.3564597,20.9931534 L21.2546891,21 L2.75468914,21 C2.37499337,21 2.06119817,20.7178461 2.01153575,20.3517706 L2.00468914,20.25 L2.00468914,4.75457396 C2.00468914,4.37487819 2.28684302,4.061083 2.65291858,4.01142057 L2.75468914,4.00457396 L4.50368914,4.004 L4.50444233,3.25 C4.50444233,2.87030423 4.78659621,2.55650904 5.15267177,2.50684662 L5.25444233,2.5 L8.9891247,2.5 Z M4.50368914,5.504 L3.50468914,5.504 L3.50468914,19.5 L10.9478955,19.4998273 C10.4513189,18.9207296 9.73864328,18.5588115 8.96709342,18.5065584 L8.77307039,18.5 L5.25444233,18.5 C4.87474657,18.5 4.56095137,18.2178461 4.51128895,17.8517706 L4.50444233,17.75 L4.50368914,5.504 Z M19.5049359,17.75 C19.5049359,18.1642136 19.1691495,18.5 18.7549359,18.5 L15.2363079,18.5 C14.3910149,18.5 13.5994408,18.8724714 13.0614828,19.4998273 L20.5046891,19.5 L20.5046891,5.504 L19.5046891,5.504 L19.5049359,17.75 Z M18.0059359,3.999 L15.0202536,4 L14.8259077,4.00692283 C13.9889509,4.06666544 13.2254227,4.50975805 12.7549359,5.212 L12.7549359,17.777 L12.7782651,17.7601316 C13.4923805,17.2719483 14.3447024,17 15.2363079,17 L18.0059359,16.999 L18.0056891,4.798 L18.0033792,4.75457396 L18.0056891,4.71 L18.0059359,3.999 Z M8.9891247,4 L6.00368914,3.999 L6.00599909,4.75457396 L6.00599909,4.75457396 L6.00368914,4.783 L6.00368914,16.999 L8.77307039,17 C9.57551536,17 10.3461406,17.2202781 11.0128313,17.6202194 L11.2536891,17.776 L11.2536891,5.211 C10.8200889,4.56369974 10.1361548,4.13636104 9.37521067,4.02745763 L9.18347055,4.00692283 L8.9891247,4 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
ReaderMode.beforeDOMLoaded = readerModeScript
|
||||
ReaderMode.css = styles
|
||||
|
||||
export default (() => ReaderMode) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { byDateAndAlphabetical } from "./PageList"
|
||||
import style from "./styles/recentNotes.scss"
|
||||
import { Date, getDate } from "./Date"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
interface Options {
|
||||
title?: string
|
||||
limit: number
|
||||
linkToMore: SimpleSlug | false
|
||||
showTags: boolean
|
||||
filter: (f: QuartzPluginData) => boolean
|
||||
sort: (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||
}
|
||||
|
||||
const defaultOptions = (cfg: GlobalConfiguration): Options => ({
|
||||
limit: 3,
|
||||
linkToMore: false,
|
||||
showTags: true,
|
||||
filter: () => true,
|
||||
sort: byDateAndAlphabetical(cfg),
|
||||
})
|
||||
|
||||
export default ((userOpts?: Partial<Options>) => {
|
||||
const RecentNotes: QuartzComponent = ({
|
||||
allFiles,
|
||||
fileData,
|
||||
displayClass,
|
||||
cfg,
|
||||
}: QuartzComponentProps) => {
|
||||
const opts = { ...defaultOptions(cfg), ...userOpts }
|
||||
const pages = allFiles.filter(opts.filter).sort(opts.sort)
|
||||
const remaining = Math.max(0, pages.length - opts.limit)
|
||||
return (
|
||||
<div class={classNames(displayClass, "recent-notes")}>
|
||||
<h3>{opts.title ?? i18n(cfg.locale).components.recentNotes.title}</h3>
|
||||
<ul class="recent-ul">
|
||||
{pages.slice(0, opts.limit).map((page) => {
|
||||
const title = page.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
|
||||
const tags = page.frontmatter?.tags ?? []
|
||||
|
||||
return (
|
||||
<li class="recent-li">
|
||||
<div class="section">
|
||||
<div class="desc">
|
||||
<h3>
|
||||
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
|
||||
{title}
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
{page.dates && (
|
||||
<p class="meta">
|
||||
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
|
||||
</p>
|
||||
)}
|
||||
{opts.showTags && (
|
||||
<ul class="tags">
|
||||
{tags.map((tag) => (
|
||||
<li>
|
||||
<a
|
||||
class="internal tag-link"
|
||||
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{opts.linkToMore && remaining > 0 && (
|
||||
<p>
|
||||
<a href={resolveRelative(fileData.slug!, opts.linkToMore)}>
|
||||
{i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
RecentNotes.css = style
|
||||
return RecentNotes
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/search.scss"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/search.inline"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
export interface SearchOptions {
|
||||
enablePreview: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: SearchOptions = {
|
||||
enablePreview: true,
|
||||
}
|
||||
|
||||
export default ((userOpts?: Partial<SearchOptions>) => {
|
||||
const Search: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
const searchPlaceholder = i18n(cfg.locale).components.search.searchBarPlaceholder
|
||||
return (
|
||||
<div class={classNames(displayClass, "search")}>
|
||||
<button class="search-button">
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.9 19.7">
|
||||
<title>Search</title>
|
||||
<g class="search-path" fill="none">
|
||||
<path stroke-linecap="square" d="M18.5 18.3l-5.4-5.4" />
|
||||
<circle cx="8" cy="8" r="7" />
|
||||
</g>
|
||||
</svg>
|
||||
<p>{i18n(cfg.locale).components.search.title}</p>
|
||||
</button>
|
||||
<div class="search-container">
|
||||
<div class="search-space">
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="search-bar"
|
||||
name="search"
|
||||
type="text"
|
||||
aria-label={searchPlaceholder}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
<div class="search-layout" data-preview={opts.enablePreview}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Search.afterDOMLoaded = script
|
||||
Search.css = style
|
||||
|
||||
return Search
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
function Spacer({ displayClass }: QuartzComponentProps) {
|
||||
return <div class={classNames(displayClass, "spacer")}></div>
|
||||
}
|
||||
|
||||
export default (() => Spacer) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import legacyStyle from "./styles/legacyToc.scss"
|
||||
import modernStyle from "./styles/toc.scss"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
// @ts-ignore
|
||||
import script from "./scripts/toc.inline"
|
||||
import { i18n } from "../i18n"
|
||||
import OverflowListFactory from "./OverflowList"
|
||||
import { concatenateResources } from "../util/resources"
|
||||
|
||||
interface Options {
|
||||
layout: "modern" | "legacy"
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
layout: "modern",
|
||||
}
|
||||
|
||||
let numTocs = 0
|
||||
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
|
||||
}
|
||||
|
||||
const id = `toc-${numTocs++}`
|
||||
return (
|
||||
<div class={classNames(displayClass, "toc")}>
|
||||
<button
|
||||
type="button"
|
||||
class={fileData.collapseToc ? "collapsed toc-header" : "toc-header"}
|
||||
aria-controls={id}
|
||||
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>
|
||||
<OverflowList
|
||||
id={id}
|
||||
class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
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}>
|
||||
{tocEntry.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
LegacyTableOfContents.css = legacyStyle
|
||||
|
||||
return layout === "modern" ? TableOfContents : LegacyTableOfContents
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { FullSlug, resolveRelative } from "../util/path"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
||||
const tags = fileData.frontmatter?.tags
|
||||
if (tags && tags.length > 0) {
|
||||
return (
|
||||
<ul class={classNames(displayClass, "tags")}>
|
||||
{tags.map((tag) => {
|
||||
const linkDest = resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)
|
||||
return (
|
||||
<li>
|
||||
<a href={linkDest} class="internal tag-link">
|
||||
{tag}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
TagList.css = `
|
||||
.tags {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
padding-left: 0;
|
||||
gap: 0.4rem;
|
||||
margin: 1rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-li > .section > .tags {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tags > li {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
a.internal.tag-link {
|
||||
border-radius: 8px;
|
||||
background-color: var(--highlight);
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: 0 0.1rem;
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => TagList) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import Content from "./pages/Content"
|
||||
import TagContent from "./pages/TagContent"
|
||||
import FolderContent from "./pages/FolderContent"
|
||||
import NotFound from "./pages/404"
|
||||
import ArticleTitle from "./ArticleTitle"
|
||||
import Darkmode from "./Darkmode"
|
||||
import ReaderMode from "./ReaderMode"
|
||||
import Head from "./Head"
|
||||
import PageTitle from "./PageTitle"
|
||||
import ContentMeta from "./ContentMeta"
|
||||
import Spacer from "./Spacer"
|
||||
import TableOfContents from "./TableOfContents"
|
||||
import Explorer from "./Explorer"
|
||||
import TagList from "./TagList"
|
||||
import Graph from "./Graph"
|
||||
import Backlinks from "./Backlinks"
|
||||
import Search from "./Search"
|
||||
import Footer from "./Footer"
|
||||
import DesktopOnly from "./DesktopOnly"
|
||||
import MobileOnly from "./MobileOnly"
|
||||
import RecentNotes from "./RecentNotes"
|
||||
import Breadcrumbs from "./Breadcrumbs"
|
||||
import Comments from "./Comments"
|
||||
import Flex from "./Flex"
|
||||
import ConditionalRender from "./ConditionalRender"
|
||||
|
||||
export {
|
||||
ArticleTitle,
|
||||
Content,
|
||||
TagContent,
|
||||
FolderContent,
|
||||
Darkmode,
|
||||
ReaderMode,
|
||||
Head,
|
||||
PageTitle,
|
||||
ContentMeta,
|
||||
Spacer,
|
||||
TableOfContents,
|
||||
Explorer,
|
||||
TagList,
|
||||
Graph,
|
||||
Backlinks,
|
||||
Search,
|
||||
Footer,
|
||||
DesktopOnly,
|
||||
MobileOnly,
|
||||
RecentNotes,
|
||||
NotFound,
|
||||
Breadcrumbs,
|
||||
Comments,
|
||||
Flex,
|
||||
ConditionalRender,
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { i18n } from "../../i18n"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
const NotFound: QuartzComponent = ({ cfg }: QuartzComponentProps) => {
|
||||
// If baseUrl contains a pathname after the domain, use this as the home link
|
||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||
const baseDir = url.pathname
|
||||
|
||||
return (
|
||||
<article class="popover-hint">
|
||||
<h1>404</h1>
|
||||
<p>{i18n(cfg.locale).pages.error.notFound}</p>
|
||||
<a href={baseDir}>{i18n(cfg.locale).pages.error.home}</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default (() => NotFound) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { ComponentChildren } from "preact"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
const Content: QuartzComponent = ({ fileData, tree }: QuartzComponentProps) => {
|
||||
const content = htmlToJsx(fileData.filePath!, tree) as ComponentChildren
|
||||
const classes: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classString = ["popover-hint", ...classes].join(" ")
|
||||
return <article class={classString}>{content}</article>
|
||||
}
|
||||
|
||||
export default (() => Content) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
import style from "../styles/listPage.scss"
|
||||
import { PageList, SortFn } from "../PageList"
|
||||
import { Root } from "hast"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { i18n } from "../../i18n"
|
||||
import { QuartzPluginData } from "../../plugins/vfile"
|
||||
import { ComponentChildren } from "preact"
|
||||
import { concatenateResources } from "../../util/resources"
|
||||
import { trieFromAllFiles } from "../../util/ctx"
|
||||
|
||||
interface FolderContentOptions {
|
||||
/**
|
||||
* Whether to display number of folders
|
||||
*/
|
||||
showFolderCount: boolean
|
||||
showSubfolders: boolean
|
||||
sort?: SortFn
|
||||
}
|
||||
|
||||
const defaultOptions: FolderContentOptions = {
|
||||
showFolderCount: true,
|
||||
showSubfolders: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<FolderContentOptions>) => {
|
||||
const options: FolderContentOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
const FolderContent: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
const { tree, fileData, allFiles, cfg } = props
|
||||
|
||||
const trie = (props.ctx.trie ??= trieFromAllFiles(allFiles))
|
||||
const folder = trie.findNode(fileData.slug!.split("/"))
|
||||
if (!folder) {
|
||||
return null
|
||||
}
|
||||
|
||||
const allPagesInFolder: QuartzPluginData[] =
|
||||
folder.children
|
||||
.map((node) => {
|
||||
// regular file, proceed
|
||||
if (node.data) {
|
||||
return node.data
|
||||
}
|
||||
|
||||
if (node.isFolder && options.showSubfolders) {
|
||||
// folders that dont have data need synthetic files
|
||||
const getMostRecentDates = (): QuartzPluginData["dates"] => {
|
||||
let maybeDates: QuartzPluginData["dates"] | undefined = undefined
|
||||
for (const child of node.children) {
|
||||
if (child.data?.dates) {
|
||||
// compare all dates and assign to maybeDates if its more recent or its not set
|
||||
if (!maybeDates) {
|
||||
maybeDates = { ...child.data.dates }
|
||||
} else {
|
||||
if (child.data.dates.created > maybeDates.created) {
|
||||
maybeDates.created = child.data.dates.created
|
||||
}
|
||||
|
||||
if (child.data.dates.modified > maybeDates.modified) {
|
||||
maybeDates.modified = child.data.dates.modified
|
||||
}
|
||||
|
||||
if (child.data.dates.published > maybeDates.published) {
|
||||
maybeDates.published = child.data.dates.published
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
maybeDates ?? {
|
||||
created: new Date(),
|
||||
modified: new Date(),
|
||||
published: new Date(),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
slug: node.slug,
|
||||
dates: getMostRecentDates(),
|
||||
frontmatter: {
|
||||
title: node.displayName,
|
||||
tags: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter((page) => page !== undefined) ?? []
|
||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classes = cssClasses.join(" ")
|
||||
const listProps = {
|
||||
...props,
|
||||
sort: options.sort,
|
||||
allFiles: allPagesInFolder,
|
||||
}
|
||||
|
||||
const content = (
|
||||
(tree as Root).children.length === 0
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
) as ComponentChildren
|
||||
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>{content}</article>
|
||||
<div class="page-listing">
|
||||
{options.showFolderCount && (
|
||||
<p>
|
||||
{i18n(cfg.locale).pages.folderContent.itemsUnderFolder({
|
||||
count: allPagesInFolder.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<PageList {...listProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FolderContent.css = concatenateResources(style, PageList.css)
|
||||
return FolderContent
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
import style from "../styles/listPage.scss"
|
||||
import { PageList, SortFn } from "../PageList"
|
||||
import { FullSlug, getAllSegmentPrefixes, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { QuartzPluginData } from "../../plugins/vfile"
|
||||
import { Root } from "hast"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { i18n } from "../../i18n"
|
||||
import { ComponentChildren } from "preact"
|
||||
import { concatenateResources } from "../../util/resources"
|
||||
|
||||
interface TagContentOptions {
|
||||
sort?: SortFn
|
||||
numPages: number
|
||||
}
|
||||
|
||||
const defaultOptions: TagContentOptions = {
|
||||
numPages: 10,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<TagContentOptions>) => {
|
||||
const options: TagContentOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
const TagContent: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
const { tree, fileData, allFiles, cfg } = props
|
||||
const slug = fileData.slug
|
||||
|
||||
if (!(slug?.startsWith("tags/") || slug === "tags")) {
|
||||
throw new Error(`Component "TagContent" tried to render a non-tag page: ${slug}`)
|
||||
}
|
||||
|
||||
const tag = simplifySlug(slug.slice("tags/".length) as FullSlug)
|
||||
const allPagesWithTag = (tag: string) =>
|
||||
allFiles.filter((file) =>
|
||||
(file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(tag),
|
||||
)
|
||||
|
||||
const content = (
|
||||
(tree as Root).children.length === 0
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
) as ComponentChildren
|
||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classes = cssClasses.join(" ")
|
||||
if (tag === "/") {
|
||||
const tags = [
|
||||
...new Set(
|
||||
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
|
||||
),
|
||||
].sort((a, b) => a.localeCompare(b))
|
||||
const tagItemMap: Map<string, QuartzPluginData[]> = new Map()
|
||||
for (const tag of tags) {
|
||||
tagItemMap.set(tag, allPagesWithTag(tag))
|
||||
}
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>
|
||||
<p>{content}</p>
|
||||
</article>
|
||||
<p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p>
|
||||
<div>
|
||||
{tags.map((tag) => {
|
||||
const pages = tagItemMap.get(tag)!
|
||||
const listProps = {
|
||||
...props,
|
||||
allFiles: pages,
|
||||
}
|
||||
|
||||
const contentPage = allFiles.filter((file) => file.slug === `tags/${tag}`).at(0)
|
||||
|
||||
const root = contentPage?.htmlAst
|
||||
const content =
|
||||
!root || root?.children.length === 0
|
||||
? contentPage?.description
|
||||
: htmlToJsx(contentPage.filePath!, root)
|
||||
|
||||
const tagListingPage = `/tags/${tag}` as FullSlug
|
||||
const href = resolveRelative(fileData.slug!, tagListingPage)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>
|
||||
<a class="internal tag-link" href={href}>
|
||||
{tag}
|
||||
</a>
|
||||
</h2>
|
||||
{content && <p>{content}</p>}
|
||||
<div class="page-listing">
|
||||
<p>
|
||||
{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}
|
||||
{pages.length > options.numPages && (
|
||||
<>
|
||||
{" "}
|
||||
<span>
|
||||
{i18n(cfg.locale).pages.tagContent.showingFirst({
|
||||
count: options.numPages,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<PageList limit={options.numPages} {...listProps} sort={options?.sort} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
const pages = allPagesWithTag(tag)
|
||||
const listProps = {
|
||||
...props,
|
||||
allFiles: pages,
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>{content}</article>
|
||||
<div class="page-listing">
|
||||
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
|
||||
<div>
|
||||
<PageList {...listProps} sort={options?.sort} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TagContent.css = concatenateResources(style, PageList.css)
|
||||
return TagContent
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
import { render } from "preact-render-to-string"
|
||||
import { QuartzComponent, QuartzComponentProps } from "./types"
|
||||
import HeaderConstructor from "./Header"
|
||||
import BodyConstructor from "./Body"
|
||||
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
|
||||
import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
|
||||
import { clone } from "../util/clone"
|
||||
import { visit } from "unist-util-visit"
|
||||
import { Root, Element, ElementContent } from "hast"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { i18n } from "../i18n"
|
||||
import { styleText } from "util"
|
||||
import { buildAetheriaPageContext } from "./aetheria"
|
||||
|
||||
interface RenderComponents {
|
||||
head: QuartzComponent
|
||||
header: QuartzComponent[]
|
||||
beforeBody: QuartzComponent[]
|
||||
pageBody: QuartzComponent
|
||||
afterBody: QuartzComponent[]
|
||||
left: QuartzComponent[]
|
||||
right: QuartzComponent[]
|
||||
footer: QuartzComponent
|
||||
}
|
||||
|
||||
const headerRegex = new RegExp(/h[1-6]/)
|
||||
export function pageResources(
|
||||
baseDir: FullSlug | RelativeURL,
|
||||
staticResources: StaticResources,
|
||||
): StaticResources {
|
||||
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
|
||||
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
|
||||
|
||||
const resources: StaticResources = {
|
||||
css: [
|
||||
{
|
||||
content: joinSegments(baseDir, "index.css"),
|
||||
},
|
||||
...staticResources.css,
|
||||
],
|
||||
js: [
|
||||
{
|
||||
src: joinSegments(baseDir, "prescript.js"),
|
||||
loadTime: "beforeDOMReady",
|
||||
contentType: "external",
|
||||
},
|
||||
{
|
||||
loadTime: "beforeDOMReady",
|
||||
contentType: "inline",
|
||||
spaPreserve: true,
|
||||
script: contentIndexScript,
|
||||
},
|
||||
...staticResources.js,
|
||||
],
|
||||
additionalHead: staticResources.additionalHead,
|
||||
}
|
||||
|
||||
resources.js.push({
|
||||
src: joinSegments(baseDir, "postscript.js"),
|
||||
loadTime: "afterDOMReady",
|
||||
moduleType: "module",
|
||||
contentType: "external",
|
||||
})
|
||||
|
||||
return resources
|
||||
}
|
||||
|
||||
function renderTranscludes(
|
||||
root: Root,
|
||||
cfg: GlobalConfiguration,
|
||||
slug: FullSlug,
|
||||
componentData: QuartzComponentProps,
|
||||
visited: Set<FullSlug>,
|
||||
) {
|
||||
// process transcludes in componentData
|
||||
visit(root, "element", (node, _index, _parent) => {
|
||||
if (node.tagName === "blockquote") {
|
||||
const classNames = (node.properties?.className ?? []) as string[]
|
||||
if (classNames.includes("transclude")) {
|
||||
const inner = node.children[0] as Element
|
||||
const transcludeTarget = (inner.properties["data-slug"] ?? slug) as FullSlug
|
||||
if (visited.has(transcludeTarget)) {
|
||||
console.warn(
|
||||
styleText(
|
||||
"yellow",
|
||||
`Warning: Skipping circular transclusion: ${slug} -> ${transcludeTarget}`,
|
||||
),
|
||||
)
|
||||
node.children = [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "p",
|
||||
properties: { style: "color: var(--secondary);" },
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: `Circular transclusion detected: ${transcludeTarget}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
return
|
||||
}
|
||||
visited.add(transcludeTarget)
|
||||
|
||||
const page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
|
||||
if (!page) {
|
||||
return
|
||||
}
|
||||
|
||||
let blockRef = node.properties.dataBlock as string | undefined
|
||||
if (blockRef?.startsWith("#^")) {
|
||||
// block transclude
|
||||
blockRef = blockRef.slice("#^".length)
|
||||
let blockNode = page.blocks?.[blockRef]
|
||||
if (blockNode) {
|
||||
if (blockNode.tagName === "li") {
|
||||
blockNode = {
|
||||
type: "element",
|
||||
tagName: "ul",
|
||||
properties: {},
|
||||
children: [blockNode],
|
||||
}
|
||||
}
|
||||
|
||||
node.children = [
|
||||
normalizeHastElement(blockNode, slug, transcludeTarget),
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
|
||||
children: [
|
||||
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
} else if (blockRef?.startsWith("#") && page.htmlAst) {
|
||||
// header transclude
|
||||
blockRef = blockRef.slice(1)
|
||||
let startIdx = undefined
|
||||
let startDepth = undefined
|
||||
let endIdx = undefined
|
||||
for (const [i, el] of page.htmlAst.children.entries()) {
|
||||
// skip non-headers
|
||||
if (!(el.type === "element" && el.tagName.match(headerRegex))) continue
|
||||
const depth = Number(el.tagName.substring(1))
|
||||
|
||||
// lookin for our blockref
|
||||
if (startIdx === undefined || startDepth === undefined) {
|
||||
// skip until we find the blockref that matches
|
||||
if (el.properties?.id === blockRef) {
|
||||
startIdx = i
|
||||
startDepth = depth
|
||||
}
|
||||
} else if (depth <= startDepth) {
|
||||
// looking for new header that is same level or higher
|
||||
endIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (startIdx === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
node.children = [
|
||||
...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((child) =>
|
||||
normalizeHastElement(child as Element, slug, transcludeTarget),
|
||||
),
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
|
||||
children: [
|
||||
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
|
||||
],
|
||||
},
|
||||
]
|
||||
} else if (page.htmlAst) {
|
||||
// page transclude
|
||||
node.children = [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "h1",
|
||||
properties: {},
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value:
|
||||
page.frontmatter?.title ??
|
||||
i18n(cfg.locale).components.transcludes.transcludeOf({
|
||||
targetSlug: page.slug!,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
...(page.htmlAst.children as ElementContent[]).map((child) =>
|
||||
normalizeHastElement(child as Element, slug, transcludeTarget),
|
||||
),
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
|
||||
children: [
|
||||
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function renderPage(
|
||||
cfg: GlobalConfiguration,
|
||||
slug: FullSlug,
|
||||
componentData: QuartzComponentProps,
|
||||
components: RenderComponents,
|
||||
pageResources: StaticResources,
|
||||
): string {
|
||||
// make a deep copy of the tree so we don't remove the transclusion references
|
||||
// for the file cached in contentMap in build.ts
|
||||
const root = clone(componentData.tree) as Root
|
||||
const visited = new Set<FullSlug>([slug])
|
||||
renderTranscludes(root, cfg, slug, componentData, visited)
|
||||
|
||||
// set componentData.tree to the edited html that has transclusions rendered
|
||||
componentData.tree = root
|
||||
componentData.aetheria = buildAetheriaPageContext(root, componentData.fileData, componentData.allFiles)
|
||||
|
||||
const {
|
||||
head: Head,
|
||||
header,
|
||||
beforeBody,
|
||||
pageBody: Content,
|
||||
afterBody,
|
||||
left,
|
||||
right,
|
||||
footer: Footer,
|
||||
} = components
|
||||
const Header = HeaderConstructor()
|
||||
const Body = BodyConstructor()
|
||||
const hasAfterBody = afterBody.length > 0
|
||||
|
||||
const LeftComponent = (
|
||||
<div class="left sidebar">
|
||||
{left.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const RightComponent = (
|
||||
<div class="right sidebar">
|
||||
{right.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const lang = componentData.fileData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"
|
||||
const direction = i18n(cfg.locale).direction ?? "ltr"
|
||||
const doc = (
|
||||
<html lang={lang} dir={direction}>
|
||||
<Head {...componentData} />
|
||||
<body data-slug={slug}>
|
||||
<div id="quartz-root" class="page">
|
||||
<Body {...componentData}>
|
||||
{LeftComponent}
|
||||
<div class="center">
|
||||
<div class="page-header">
|
||||
<Header {...componentData}>
|
||||
{header.map((HeaderComponent) => (
|
||||
<HeaderComponent {...componentData} />
|
||||
))}
|
||||
</Header>
|
||||
<div class="popover-hint">
|
||||
{beforeBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Content {...componentData} />
|
||||
{hasAfterBody && (
|
||||
<>
|
||||
<hr />
|
||||
<div class="page-footer">
|
||||
{afterBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{RightComponent}
|
||||
<Footer {...componentData} />
|
||||
</Body>
|
||||
</div>
|
||||
</body>
|
||||
{pageResources.js
|
||||
.filter((resource) => resource.loadTime === "afterDOMReady")
|
||||
.map((res) => JSResourceToScriptElement(res, true))}
|
||||
</html>
|
||||
)
|
||||
|
||||
return "<!DOCTYPE html>\n" + render(doc)
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
function toggleCallout(this: HTMLElement) {
|
||||
const outerBlock = this.parentElement!
|
||||
outerBlock.classList.toggle("is-collapsed")
|
||||
const content = outerBlock.getElementsByClassName("callout-content")[0] as HTMLElement
|
||||
if (!content) return
|
||||
const collapsed = outerBlock.classList.contains("is-collapsed")
|
||||
content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
|
||||
}
|
||||
|
||||
function setupCallout() {
|
||||
const collapsible = document.getElementsByClassName(
|
||||
`callout is-collapsible`,
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const div of collapsible) {
|
||||
const title = div.getElementsByClassName("callout-title")[0] as HTMLElement
|
||||
const content = div.getElementsByClassName("callout-content")[0] as HTMLElement
|
||||
if (!title || !content) continue
|
||||
|
||||
title.addEventListener("click", toggleCallout)
|
||||
window.addCleanup(() => title.removeEventListener("click", toggleCallout))
|
||||
|
||||
const collapsed = div.classList.contains("is-collapsed")
|
||||
content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", setupCallout)
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { getFullSlug } from "../../util/path"
|
||||
|
||||
const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}`
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const checkboxes = document.querySelectorAll(
|
||||
"input.checkbox-toggle",
|
||||
) as NodeListOf<HTMLInputElement>
|
||||
checkboxes.forEach((el, index) => {
|
||||
const elId = checkboxId(index)
|
||||
|
||||
const switchState = (e: Event) => {
|
||||
const newCheckboxState = (e.target as HTMLInputElement)?.checked ? "true" : "false"
|
||||
localStorage.setItem(elId, newCheckboxState)
|
||||
}
|
||||
|
||||
el.addEventListener("change", switchState)
|
||||
window.addCleanup(() => el.removeEventListener("change", switchState))
|
||||
if (localStorage.getItem(elId) === "true") {
|
||||
el.checked = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
const svgCopy =
|
||||
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path></svg>'
|
||||
const svgCheck =
|
||||
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>'
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const els = document.getElementsByTagName("pre")
|
||||
for (let i = 0; i < els.length; i++) {
|
||||
const codeBlock = els[i].getElementsByTagName("code")[0]
|
||||
if (codeBlock) {
|
||||
const source = (
|
||||
codeBlock.dataset.clipboard ? JSON.parse(codeBlock.dataset.clipboard) : codeBlock.innerText
|
||||
).replace(/\n\n/g, "\n")
|
||||
const button = document.createElement("button")
|
||||
button.className = "clipboard-button"
|
||||
button.type = "button"
|
||||
button.innerHTML = svgCopy
|
||||
button.ariaLabel = "Copy source"
|
||||
function onClick() {
|
||||
navigator.clipboard.writeText(source).then(
|
||||
() => {
|
||||
button.blur()
|
||||
button.innerHTML = svgCheck
|
||||
setTimeout(() => {
|
||||
button.innerHTML = svgCopy
|
||||
button.style.borderColor = ""
|
||||
}, 2000)
|
||||
},
|
||||
(error) => console.error(error),
|
||||
)
|
||||
}
|
||||
button.addEventListener("click", onClick)
|
||||
window.addCleanup(() => button.removeEventListener("click", onClick))
|
||||
els[i].prepend(button)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
const changeTheme = (e: CustomEventMap["themechange"]) => {
|
||||
const theme = e.detail.theme
|
||||
const iframe = document.querySelector("iframe.giscus-frame") as HTMLIFrameElement
|
||||
if (!iframe) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!iframe.contentWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
giscus: {
|
||||
setConfig: {
|
||||
theme: getThemeUrl(getThemeName(theme)),
|
||||
},
|
||||
},
|
||||
},
|
||||
"https://giscus.app",
|
||||
)
|
||||
}
|
||||
|
||||
const getThemeName = (theme: string) => {
|
||||
if (theme !== "dark" && theme !== "light") {
|
||||
return theme
|
||||
}
|
||||
const giscusContainer = document.querySelector(".giscus") as GiscusElement
|
||||
if (!giscusContainer) {
|
||||
return theme
|
||||
}
|
||||
const darkGiscus = giscusContainer.dataset.darkTheme ?? "dark"
|
||||
const lightGiscus = giscusContainer.dataset.lightTheme ?? "light"
|
||||
return theme === "dark" ? darkGiscus : lightGiscus
|
||||
}
|
||||
|
||||
const getThemeUrl = (theme: string) => {
|
||||
const giscusContainer = document.querySelector(".giscus") as GiscusElement
|
||||
if (!giscusContainer) {
|
||||
return `https://giscus.app/themes/${theme}.css`
|
||||
}
|
||||
return `${giscusContainer.dataset.themeUrl ?? "https://giscus.app/themes"}/${theme}.css`
|
||||
}
|
||||
|
||||
type GiscusElement = Omit<HTMLElement, "dataset"> & {
|
||||
dataset: DOMStringMap & {
|
||||
repo: `${string}/${string}`
|
||||
repoId: string
|
||||
category: string
|
||||
categoryId: string
|
||||
themeUrl: string
|
||||
lightTheme: string
|
||||
darkTheme: string
|
||||
mapping: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
|
||||
strict: string
|
||||
reactionsEnabled: string
|
||||
inputPosition: "top" | "bottom"
|
||||
lang: string
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const giscusContainer = document.querySelector(".giscus") as GiscusElement
|
||||
if (!giscusContainer) {
|
||||
return
|
||||
}
|
||||
|
||||
const giscusScript = document.createElement("script")
|
||||
giscusScript.src = "https://giscus.app/client.js"
|
||||
giscusScript.async = true
|
||||
giscusScript.crossOrigin = "anonymous"
|
||||
giscusScript.setAttribute("data-loading", "lazy")
|
||||
giscusScript.setAttribute("data-emit-metadata", "0")
|
||||
giscusScript.setAttribute("data-repo", giscusContainer.dataset.repo)
|
||||
giscusScript.setAttribute("data-repo-id", giscusContainer.dataset.repoId)
|
||||
giscusScript.setAttribute("data-category", giscusContainer.dataset.category)
|
||||
giscusScript.setAttribute("data-category-id", giscusContainer.dataset.categoryId)
|
||||
giscusScript.setAttribute("data-mapping", giscusContainer.dataset.mapping)
|
||||
giscusScript.setAttribute("data-strict", giscusContainer.dataset.strict)
|
||||
giscusScript.setAttribute("data-reactions-enabled", giscusContainer.dataset.reactionsEnabled)
|
||||
giscusScript.setAttribute("data-input-position", giscusContainer.dataset.inputPosition)
|
||||
giscusScript.setAttribute("data-lang", giscusContainer.dataset.lang)
|
||||
const theme = document.documentElement.getAttribute("saved-theme")
|
||||
if (theme) {
|
||||
giscusScript.setAttribute("data-theme", getThemeUrl(getThemeName(theme)))
|
||||
}
|
||||
|
||||
giscusContainer.appendChild(giscusScript)
|
||||
|
||||
document.addEventListener("themechange", changeTheme)
|
||||
window.addCleanup(() => document.removeEventListener("themechange", changeTheme))
|
||||
})
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"
|
||||
const currentTheme = localStorage.getItem("theme") ?? userPref
|
||||
document.documentElement.setAttribute("saved-theme", currentTheme)
|
||||
|
||||
const emitThemeChangeEvent = (theme: "light" | "dark") => {
|
||||
const event: CustomEventMap["themechange"] = new CustomEvent("themechange", {
|
||||
detail: { theme },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const switchTheme = () => {
|
||||
const newTheme =
|
||||
document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark"
|
||||
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||
localStorage.setItem("theme", newTheme)
|
||||
emitThemeChangeEvent(newTheme)
|
||||
}
|
||||
|
||||
const themeChange = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? "dark" : "light"
|
||||
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||
localStorage.setItem("theme", newTheme)
|
||||
emitThemeChangeEvent(newTheme)
|
||||
}
|
||||
|
||||
for (const darkmodeButton of document.getElementsByClassName("darkmode")) {
|
||||
darkmodeButton.addEventListener("click", switchTheme)
|
||||
window.addCleanup(() => darkmodeButton.removeEventListener("click", switchTheme))
|
||||
}
|
||||
|
||||
// Listen for changes in prefers-color-scheme
|
||||
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
colorSchemeMediaQuery.addEventListener("change", themeChange)
|
||||
window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange))
|
||||
})
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
import { FileTrieNode } from "../../util/fileTrie"
|
||||
import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
|
||||
type MaybeHTMLElement = HTMLElement | undefined
|
||||
|
||||
interface ParsedOptions {
|
||||
folderClickBehavior: "collapse" | "link"
|
||||
folderDefaultState: "collapsed" | "open"
|
||||
useSavedState: boolean
|
||||
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
|
||||
filterFn: (node: FileTrieNode) => boolean
|
||||
mapFn: (node: FileTrieNode) => void
|
||||
order: "sort" | "filter" | "map"[]
|
||||
}
|
||||
|
||||
type FolderState = {
|
||||
path: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
let currentExplorerState: Array<FolderState>
|
||||
function toggleExplorer(this: HTMLElement) {
|
||||
const nearestExplorer = this.closest(".explorer") as HTMLElement
|
||||
if (!nearestExplorer) return
|
||||
const explorerCollapsed = nearestExplorer.classList.toggle("collapsed")
|
||||
nearestExplorer.setAttribute(
|
||||
"aria-expanded",
|
||||
nearestExplorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
|
||||
)
|
||||
|
||||
if (!explorerCollapsed) {
|
||||
// Stop <html> from being scrollable when mobile explorer is open
|
||||
document.documentElement.classList.add("mobile-no-scroll")
|
||||
} else {
|
||||
document.documentElement.classList.remove("mobile-no-scroll")
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(evt: MouseEvent) {
|
||||
evt.stopPropagation()
|
||||
const target = evt.target as MaybeHTMLElement
|
||||
if (!target) return
|
||||
|
||||
// Check if target was svg icon or button
|
||||
const isSvg = target.nodeName === "svg"
|
||||
|
||||
// corresponding <ul> element relative to clicked button/folder
|
||||
const folderContainer = (
|
||||
isSvg
|
||||
? // svg -> div.folder-container
|
||||
target.parentElement
|
||||
: // button.folder-button -> div -> div.folder-container
|
||||
target.parentElement?.parentElement
|
||||
) as MaybeHTMLElement
|
||||
if (!folderContainer) return
|
||||
const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement
|
||||
if (!childFolderContainer) return
|
||||
|
||||
childFolderContainer.classList.toggle("open")
|
||||
|
||||
// Collapse folder container
|
||||
const isCollapsed = !childFolderContainer.classList.contains("open")
|
||||
setFolderState(childFolderContainer, isCollapsed)
|
||||
|
||||
const currentFolderState = currentExplorerState.find(
|
||||
(item) => item.path === folderContainer.dataset.folderpath,
|
||||
)
|
||||
if (currentFolderState) {
|
||||
currentFolderState.collapsed = isCollapsed
|
||||
} else {
|
||||
currentExplorerState.push({
|
||||
path: folderContainer.dataset.folderpath as FullSlug,
|
||||
collapsed: isCollapsed,
|
||||
})
|
||||
}
|
||||
|
||||
const stringifiedFileTree = JSON.stringify(currentExplorerState)
|
||||
localStorage.setItem("fileTree", stringifiedFileTree)
|
||||
}
|
||||
|
||||
function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement {
|
||||
const template = document.getElementById("template-file") as HTMLTemplateElement
|
||||
const clone = template.content.cloneNode(true) as DocumentFragment
|
||||
const li = clone.querySelector("li") as HTMLLIElement
|
||||
const a = li.querySelector("a") as HTMLAnchorElement
|
||||
a.href = resolveRelative(currentSlug, node.slug)
|
||||
a.dataset.for = node.slug
|
||||
a.textContent = node.displayName
|
||||
|
||||
if (currentSlug === node.slug) {
|
||||
a.classList.add("active")
|
||||
}
|
||||
|
||||
return li
|
||||
}
|
||||
|
||||
function createFolderNode(
|
||||
currentSlug: FullSlug,
|
||||
node: FileTrieNode,
|
||||
opts: ParsedOptions,
|
||||
): HTMLLIElement {
|
||||
const template = document.getElementById("template-folder") as HTMLTemplateElement
|
||||
const clone = template.content.cloneNode(true) as DocumentFragment
|
||||
const li = clone.querySelector("li") as HTMLLIElement
|
||||
const folderContainer = li.querySelector(".folder-container") as HTMLElement
|
||||
const titleContainer = folderContainer.querySelector("div") as HTMLElement
|
||||
const folderOuter = li.querySelector(".folder-outer") as HTMLElement
|
||||
const ul = folderOuter.querySelector("ul") as HTMLUListElement
|
||||
|
||||
const folderPath = node.slug
|
||||
folderContainer.dataset.folderpath = folderPath
|
||||
|
||||
if (currentSlug === folderPath) {
|
||||
folderContainer.classList.add("active")
|
||||
}
|
||||
|
||||
if (opts.folderClickBehavior === "link") {
|
||||
// Replace button with link for link behavior
|
||||
const button = titleContainer.querySelector(".folder-button") as HTMLElement
|
||||
const a = document.createElement("a")
|
||||
a.href = resolveRelative(currentSlug, folderPath)
|
||||
a.dataset.for = folderPath
|
||||
a.className = "folder-title"
|
||||
a.textContent = node.displayName
|
||||
button.replaceWith(a)
|
||||
} else {
|
||||
const span = titleContainer.querySelector(".folder-title") as HTMLElement
|
||||
span.textContent = node.displayName
|
||||
}
|
||||
|
||||
// if the saved state is collapsed or the default state is collapsed
|
||||
const isCollapsed =
|
||||
currentExplorerState.find((item) => item.path === folderPath)?.collapsed ??
|
||||
opts.folderDefaultState === "collapsed"
|
||||
|
||||
// if this folder is a prefix of the current path we
|
||||
// want to open it anyways
|
||||
const simpleFolderPath = simplifySlug(folderPath)
|
||||
const folderIsPrefixOfCurrentSlug =
|
||||
simpleFolderPath === currentSlug.slice(0, simpleFolderPath.length)
|
||||
|
||||
if (!isCollapsed || folderIsPrefixOfCurrentSlug) {
|
||||
folderOuter.classList.add("open")
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const childNode = child.isFolder
|
||||
? createFolderNode(currentSlug, child, opts)
|
||||
: createFileNode(currentSlug, child)
|
||||
ul.appendChild(childNode)
|
||||
}
|
||||
|
||||
return li
|
||||
}
|
||||
|
||||
async function setupExplorer(currentSlug: FullSlug) {
|
||||
const allExplorers = document.querySelectorAll("div.explorer") as NodeListOf<HTMLElement>
|
||||
|
||||
for (const explorer of allExplorers) {
|
||||
const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
|
||||
const opts: ParsedOptions = {
|
||||
folderClickBehavior: (explorer.dataset.behavior || "collapse") as "collapse" | "link",
|
||||
folderDefaultState: (explorer.dataset.collapsed || "collapsed") as "collapsed" | "open",
|
||||
useSavedState: explorer.dataset.savestate === "true",
|
||||
order: dataFns.order || ["filter", "map", "sort"],
|
||||
sortFn: new Function("return " + (dataFns.sortFn || "undefined"))(),
|
||||
filterFn: new Function("return " + (dataFns.filterFn || "undefined"))(),
|
||||
mapFn: new Function("return " + (dataFns.mapFn || "undefined"))(),
|
||||
}
|
||||
|
||||
// Get folder state from local storage
|
||||
const storageTree = localStorage.getItem("fileTree")
|
||||
const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
|
||||
const oldIndex = new Map<string, boolean>(
|
||||
serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
|
||||
)
|
||||
|
||||
const data = await fetchData
|
||||
const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
|
||||
const trie = FileTrieNode.fromEntries(entries)
|
||||
|
||||
// Apply functions in order
|
||||
for (const fn of opts.order) {
|
||||
switch (fn) {
|
||||
case "filter":
|
||||
if (opts.filterFn) trie.filter(opts.filterFn)
|
||||
break
|
||||
case "map":
|
||||
if (opts.mapFn) trie.map(opts.mapFn)
|
||||
break
|
||||
case "sort":
|
||||
if (opts.sortFn) trie.sort(opts.sortFn)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Get folder paths for state management
|
||||
const folderPaths = trie.getFolderPaths()
|
||||
currentExplorerState = folderPaths.map((path) => {
|
||||
const previousState = oldIndex.get(path)
|
||||
return {
|
||||
path,
|
||||
collapsed:
|
||||
previousState === undefined ? opts.folderDefaultState === "collapsed" : previousState,
|
||||
}
|
||||
})
|
||||
|
||||
const explorerUl = explorer.querySelector(".explorer-ul")
|
||||
if (!explorerUl) continue
|
||||
|
||||
// Create and insert new content
|
||||
const fragment = document.createDocumentFragment()
|
||||
for (const child of trie.children) {
|
||||
const node = child.isFolder
|
||||
? createFolderNode(currentSlug, child, opts)
|
||||
: createFileNode(currentSlug, child)
|
||||
|
||||
fragment.appendChild(node)
|
||||
}
|
||||
explorerUl.insertBefore(fragment, explorerUl.firstChild)
|
||||
|
||||
// restore explorer scrollTop position if it exists
|
||||
const scrollTop = sessionStorage.getItem("explorerScrollTop")
|
||||
if (scrollTop) {
|
||||
explorerUl.scrollTop = parseInt(scrollTop)
|
||||
} else {
|
||||
// try to scroll to the active element if it exists
|
||||
const activeElement = explorerUl.querySelector(".active")
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event handlers
|
||||
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
|
||||
if (opts.folderClickBehavior === "collapse") {
|
||||
const folderButtons = explorer.getElementsByClassName(
|
||||
"folder-button",
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const button of folderButtons) {
|
||||
button.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => button.removeEventListener("click", toggleFolder))
|
||||
}
|
||||
}
|
||||
|
||||
const folderIcons = explorer.getElementsByClassName(
|
||||
"folder-icon",
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const icon of folderIcons) {
|
||||
icon.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("prenav", async () => {
|
||||
// save explorer scrollTop position
|
||||
const explorer = document.querySelector(".explorer-ul")
|
||||
if (!explorer) return
|
||||
sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
|
||||
})
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const currentSlug = e.detail.url
|
||||
await setupExplorer(currentSlug)
|
||||
|
||||
// if mobile hamburger is visible, collapse by default
|
||||
for (const explorer of document.getElementsByClassName("explorer")) {
|
||||
const mobileExplorer = explorer.querySelector(".mobile-explorer")
|
||||
if (!mobileExplorer) return
|
||||
|
||||
if (mobileExplorer.checkVisibility()) {
|
||||
explorer.classList.add("collapsed")
|
||||
explorer.setAttribute("aria-expanded", "false")
|
||||
|
||||
// Allow <html> to be scrollable when mobile explorer is collapsed
|
||||
document.documentElement.classList.remove("mobile-no-scroll")
|
||||
}
|
||||
|
||||
mobileExplorer.classList.remove("hide-until-loaded")
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
// Desktop explorer opens by default, and it stays open when the window is resized
|
||||
// to mobile screen size. Applies `no-scroll` to <html> in this edge case.
|
||||
const explorer = document.querySelector(".explorer")
|
||||
if (explorer && !explorer.classList.contains("collapsed")) {
|
||||
document.documentElement.classList.add("mobile-no-scroll")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
|
||||
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
|
||||
}
|
||||
|
|
@ -1,649 +0,0 @@
|
|||
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
import {
|
||||
SimulationNodeDatum,
|
||||
SimulationLinkDatum,
|
||||
Simulation,
|
||||
forceSimulation,
|
||||
forceManyBody,
|
||||
forceCenter,
|
||||
forceLink,
|
||||
forceCollide,
|
||||
forceRadial,
|
||||
zoomIdentity,
|
||||
select,
|
||||
drag,
|
||||
zoom,
|
||||
} from "d3"
|
||||
import { Text, Graphics, Application, Container, Circle } from "pixi.js"
|
||||
import { Group as TweenGroup, Tween as Tweened } from "@tweenjs/tween.js"
|
||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { D3Config } from "../Graph"
|
||||
|
||||
type GraphicsInfo = {
|
||||
color: string
|
||||
gfx: Graphics
|
||||
alpha: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
type NodeData = {
|
||||
id: SimpleSlug
|
||||
text: string
|
||||
tags: string[]
|
||||
} & SimulationNodeDatum
|
||||
|
||||
type SimpleLinkData = {
|
||||
source: SimpleSlug
|
||||
target: SimpleSlug
|
||||
}
|
||||
|
||||
type LinkData = {
|
||||
source: NodeData
|
||||
target: NodeData
|
||||
} & SimulationLinkDatum<NodeData>
|
||||
|
||||
type LinkRenderData = GraphicsInfo & {
|
||||
simulationData: LinkData
|
||||
}
|
||||
|
||||
type NodeRenderData = GraphicsInfo & {
|
||||
simulationData: NodeData
|
||||
label: Text
|
||||
}
|
||||
|
||||
const localStorageKey = "graph-visited"
|
||||
function getVisited(): Set<SimpleSlug> {
|
||||
return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]"))
|
||||
}
|
||||
|
||||
function addToVisited(slug: SimpleSlug) {
|
||||
const visited = getVisited()
|
||||
visited.add(slug)
|
||||
localStorage.setItem(localStorageKey, JSON.stringify([...visited]))
|
||||
}
|
||||
|
||||
type TweenNode = {
|
||||
update: (time: number) => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
|
||||
const slug = simplifySlug(fullSlug)
|
||||
const visited = getVisited()
|
||||
removeAllChildren(graph)
|
||||
|
||||
let {
|
||||
drag: enableDrag,
|
||||
zoom: enableZoom,
|
||||
depth,
|
||||
scale,
|
||||
repelForce,
|
||||
centerForce,
|
||||
linkDistance,
|
||||
fontSize,
|
||||
opacityScale,
|
||||
removeTags,
|
||||
showTags,
|
||||
focusOnHover,
|
||||
enableRadial,
|
||||
} = JSON.parse(graph.dataset["cfg"]!) as D3Config
|
||||
|
||||
const data: Map<SimpleSlug, ContentDetails> = new Map(
|
||||
Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
|
||||
simplifySlug(k as FullSlug),
|
||||
v,
|
||||
]),
|
||||
)
|
||||
const links: SimpleLinkData[] = []
|
||||
const tags: SimpleSlug[] = []
|
||||
const validLinks = new Set(data.keys())
|
||||
|
||||
const tweens = new Map<string, TweenNode>()
|
||||
for (const [source, details] of data.entries()) {
|
||||
const outgoing = details.links ?? []
|
||||
|
||||
for (const dest of outgoing) {
|
||||
if (validLinks.has(dest)) {
|
||||
links.push({ source: source, target: dest })
|
||||
}
|
||||
}
|
||||
|
||||
if (showTags) {
|
||||
const localTags = details.tags
|
||||
.filter((tag) => !removeTags.includes(tag))
|
||||
.map((tag) => simplifySlug(("tags/" + tag) as FullSlug))
|
||||
|
||||
tags.push(...localTags.filter((tag) => !tags.includes(tag)))
|
||||
|
||||
for (const tag of localTags) {
|
||||
links.push({ source: source, target: tag })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const neighbourhood = new Set<SimpleSlug>()
|
||||
const wl: (SimpleSlug | "__SENTINEL")[] = [slug, "__SENTINEL"]
|
||||
if (depth >= 0) {
|
||||
while (depth >= 0 && wl.length > 0) {
|
||||
// compute neighbours
|
||||
const cur = wl.shift()!
|
||||
if (cur === "__SENTINEL") {
|
||||
depth--
|
||||
wl.push("__SENTINEL")
|
||||
} else {
|
||||
neighbourhood.add(cur)
|
||||
const outgoing = links.filter((l) => l.source === cur)
|
||||
const incoming = links.filter((l) => l.target === cur)
|
||||
wl.push(...outgoing.map((l) => l.target), ...incoming.map((l) => l.source))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
validLinks.forEach((id) => neighbourhood.add(id))
|
||||
if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
|
||||
}
|
||||
|
||||
const nodes = [...neighbourhood].map((url) => {
|
||||
const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url)
|
||||
return {
|
||||
id: url,
|
||||
text,
|
||||
tags: data.get(url)?.tags ?? [],
|
||||
}
|
||||
})
|
||||
const graphData: { nodes: NodeData[]; links: LinkData[] } = {
|
||||
nodes,
|
||||
links: links
|
||||
.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target))
|
||||
.map((l) => ({
|
||||
source: nodes.find((n) => n.id === l.source)!,
|
||||
target: nodes.find((n) => n.id === l.target)!,
|
||||
})),
|
||||
}
|
||||
|
||||
const width = graph.offsetWidth
|
||||
const height = Math.max(graph.offsetHeight, 250)
|
||||
|
||||
// we virtualize the simulation and use pixi to actually render it
|
||||
const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
|
||||
.force("charge", forceManyBody().strength(-100 * repelForce))
|
||||
.force("center", forceCenter().strength(centerForce))
|
||||
.force("link", forceLink(graphData.links).distance(linkDistance))
|
||||
.force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
|
||||
|
||||
const radius = (Math.min(width, height) / 2) * 0.8
|
||||
if (enableRadial) simulation.force("radial", forceRadial(radius).strength(0.2))
|
||||
|
||||
// precompute style prop strings as pixi doesn't support css variables
|
||||
const cssVars = [
|
||||
"--secondary",
|
||||
"--tertiary",
|
||||
"--gray",
|
||||
"--light",
|
||||
"--lightgray",
|
||||
"--dark",
|
||||
"--darkgray",
|
||||
"--bodyFont",
|
||||
] as const
|
||||
const computedStyleMap = cssVars.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
|
||||
return acc
|
||||
},
|
||||
{} as Record<(typeof cssVars)[number], string>,
|
||||
)
|
||||
|
||||
// calculate color
|
||||
const color = (d: NodeData) => {
|
||||
const isCurrent = d.id === slug
|
||||
if (isCurrent) {
|
||||
return computedStyleMap["--secondary"]
|
||||
} else if (visited.has(d.id) || d.id.startsWith("tags/")) {
|
||||
return computedStyleMap["--tertiary"]
|
||||
} else {
|
||||
return computedStyleMap["--gray"]
|
||||
}
|
||||
}
|
||||
|
||||
function nodeRadius(d: NodeData) {
|
||||
const numLinks = graphData.links.filter(
|
||||
(l) => l.source.id === d.id || l.target.id === d.id,
|
||||
).length
|
||||
return 2 + Math.sqrt(numLinks)
|
||||
}
|
||||
|
||||
let hoveredNodeId: string | null = null
|
||||
let hoveredNeighbours: Set<string> = new Set()
|
||||
const linkRenderData: LinkRenderData[] = []
|
||||
const nodeRenderData: NodeRenderData[] = []
|
||||
function updateHoverInfo(newHoveredId: string | null) {
|
||||
hoveredNodeId = newHoveredId
|
||||
|
||||
if (newHoveredId === null) {
|
||||
hoveredNeighbours = new Set()
|
||||
for (const n of nodeRenderData) {
|
||||
n.active = false
|
||||
}
|
||||
|
||||
for (const l of linkRenderData) {
|
||||
l.active = false
|
||||
}
|
||||
} else {
|
||||
hoveredNeighbours = new Set()
|
||||
for (const l of linkRenderData) {
|
||||
const linkData = l.simulationData
|
||||
if (linkData.source.id === newHoveredId || linkData.target.id === newHoveredId) {
|
||||
hoveredNeighbours.add(linkData.source.id)
|
||||
hoveredNeighbours.add(linkData.target.id)
|
||||
}
|
||||
|
||||
l.active = linkData.source.id === newHoveredId || linkData.target.id === newHoveredId
|
||||
}
|
||||
|
||||
for (const n of nodeRenderData) {
|
||||
n.active = hoveredNeighbours.has(n.simulationData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dragStartTime = 0
|
||||
let dragging = false
|
||||
|
||||
function renderLinks() {
|
||||
tweens.get("link")?.stop()
|
||||
const tweenGroup = new TweenGroup()
|
||||
|
||||
for (const l of linkRenderData) {
|
||||
let alpha = 1
|
||||
|
||||
// if we are hovering over a node, we want to highlight the immediate neighbours
|
||||
// with full alpha and the rest with default alpha
|
||||
if (hoveredNodeId) {
|
||||
alpha = l.active ? 1 : 0.2
|
||||
}
|
||||
|
||||
l.color = l.active ? computedStyleMap["--gray"] : computedStyleMap["--lightgray"]
|
||||
tweenGroup.add(new Tweened<LinkRenderData>(l).to({ alpha }, 200))
|
||||
}
|
||||
|
||||
tweenGroup.getAll().forEach((tw) => tw.start())
|
||||
tweens.set("link", {
|
||||
update: tweenGroup.update.bind(tweenGroup),
|
||||
stop() {
|
||||
tweenGroup.getAll().forEach((tw) => tw.stop())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderLabels() {
|
||||
tweens.get("label")?.stop()
|
||||
const tweenGroup = new TweenGroup()
|
||||
|
||||
const defaultScale = 1 / scale
|
||||
const activeScale = defaultScale * 1.1
|
||||
for (const n of nodeRenderData) {
|
||||
const nodeId = n.simulationData.id
|
||||
|
||||
if (hoveredNodeId === nodeId) {
|
||||
tweenGroup.add(
|
||||
new Tweened<Text>(n.label).to(
|
||||
{
|
||||
alpha: 1,
|
||||
scale: { x: activeScale, y: activeScale },
|
||||
},
|
||||
100,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
tweenGroup.add(
|
||||
new Tweened<Text>(n.label).to(
|
||||
{
|
||||
alpha: n.label.alpha,
|
||||
scale: { x: defaultScale, y: defaultScale },
|
||||
},
|
||||
100,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tweenGroup.getAll().forEach((tw) => tw.start())
|
||||
tweens.set("label", {
|
||||
update: tweenGroup.update.bind(tweenGroup),
|
||||
stop() {
|
||||
tweenGroup.getAll().forEach((tw) => tw.stop())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderNodes() {
|
||||
tweens.get("hover")?.stop()
|
||||
|
||||
const tweenGroup = new TweenGroup()
|
||||
for (const n of nodeRenderData) {
|
||||
let alpha = 1
|
||||
|
||||
// if we are hovering over a node, we want to highlight the immediate neighbours
|
||||
if (hoveredNodeId !== null && focusOnHover) {
|
||||
alpha = n.active ? 1 : 0.2
|
||||
}
|
||||
|
||||
tweenGroup.add(new Tweened<Graphics>(n.gfx, tweenGroup).to({ alpha }, 200))
|
||||
}
|
||||
|
||||
tweenGroup.getAll().forEach((tw) => tw.start())
|
||||
tweens.set("hover", {
|
||||
update: tweenGroup.update.bind(tweenGroup),
|
||||
stop() {
|
||||
tweenGroup.getAll().forEach((tw) => tw.stop())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderPixiFromD3() {
|
||||
renderNodes()
|
||||
renderLinks()
|
||||
renderLabels()
|
||||
}
|
||||
|
||||
tweens.forEach((tween) => tween.stop())
|
||||
tweens.clear()
|
||||
|
||||
const app = new Application()
|
||||
await app.init({
|
||||
width,
|
||||
height,
|
||||
antialias: true,
|
||||
autoStart: false,
|
||||
autoDensity: true,
|
||||
backgroundAlpha: 0,
|
||||
preference: "webgpu",
|
||||
resolution: window.devicePixelRatio,
|
||||
eventMode: "static",
|
||||
})
|
||||
graph.appendChild(app.canvas)
|
||||
|
||||
const stage = app.stage
|
||||
stage.interactive = false
|
||||
|
||||
const labelsContainer = new Container<Text>({ zIndex: 3, isRenderGroup: true })
|
||||
const nodesContainer = new Container<Graphics>({ zIndex: 2, isRenderGroup: true })
|
||||
const linkContainer = new Container<Graphics>({ zIndex: 1, isRenderGroup: true })
|
||||
stage.addChild(nodesContainer, labelsContainer, linkContainer)
|
||||
|
||||
for (const n of graphData.nodes) {
|
||||
const nodeId = n.id
|
||||
|
||||
const label = new Text({
|
||||
interactive: false,
|
||||
eventMode: "none",
|
||||
text: n.text,
|
||||
alpha: 0,
|
||||
anchor: { x: 0.5, y: 1.2 },
|
||||
style: {
|
||||
fontSize: fontSize * 15,
|
||||
fill: computedStyleMap["--dark"],
|
||||
fontFamily: computedStyleMap["--bodyFont"],
|
||||
},
|
||||
resolution: window.devicePixelRatio * 4,
|
||||
})
|
||||
label.scale.set(1 / scale)
|
||||
|
||||
let oldLabelOpacity = 0
|
||||
const isTagNode = nodeId.startsWith("tags/")
|
||||
const gfx = new Graphics({
|
||||
interactive: true,
|
||||
label: nodeId,
|
||||
eventMode: "static",
|
||||
hitArea: new Circle(0, 0, nodeRadius(n)),
|
||||
cursor: "pointer",
|
||||
})
|
||||
.circle(0, 0, nodeRadius(n))
|
||||
.fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) })
|
||||
.on("pointerover", (e) => {
|
||||
updateHoverInfo(e.target.label)
|
||||
oldLabelOpacity = label.alpha
|
||||
if (!dragging) {
|
||||
renderPixiFromD3()
|
||||
}
|
||||
})
|
||||
.on("pointerleave", () => {
|
||||
updateHoverInfo(null)
|
||||
label.alpha = oldLabelOpacity
|
||||
if (!dragging) {
|
||||
renderPixiFromD3()
|
||||
}
|
||||
})
|
||||
|
||||
if (isTagNode) {
|
||||
gfx.stroke({ width: 2, color: computedStyleMap["--tertiary"] })
|
||||
}
|
||||
|
||||
nodesContainer.addChild(gfx)
|
||||
labelsContainer.addChild(label)
|
||||
|
||||
const nodeRenderDatum: NodeRenderData = {
|
||||
simulationData: n,
|
||||
gfx,
|
||||
label,
|
||||
color: color(n),
|
||||
alpha: 1,
|
||||
active: false,
|
||||
}
|
||||
|
||||
nodeRenderData.push(nodeRenderDatum)
|
||||
}
|
||||
|
||||
for (const l of graphData.links) {
|
||||
const gfx = new Graphics({ interactive: false, eventMode: "none" })
|
||||
linkContainer.addChild(gfx)
|
||||
|
||||
const linkRenderDatum: LinkRenderData = {
|
||||
simulationData: l,
|
||||
gfx,
|
||||
color: computedStyleMap["--lightgray"],
|
||||
alpha: 1,
|
||||
active: false,
|
||||
}
|
||||
|
||||
linkRenderData.push(linkRenderDatum)
|
||||
}
|
||||
|
||||
let currentTransform = zoomIdentity
|
||||
if (enableDrag) {
|
||||
select<HTMLCanvasElement, NodeData | undefined>(app.canvas).call(
|
||||
drag<HTMLCanvasElement, NodeData | undefined>()
|
||||
.container(() => app.canvas)
|
||||
.subject(() => graphData.nodes.find((n) => n.id === hoveredNodeId))
|
||||
.on("start", function dragstarted(event) {
|
||||
if (!event.active) simulation.alphaTarget(1).restart()
|
||||
event.subject.fx = event.subject.x
|
||||
event.subject.fy = event.subject.y
|
||||
event.subject.__initialDragPos = {
|
||||
x: event.subject.x,
|
||||
y: event.subject.y,
|
||||
fx: event.subject.fx,
|
||||
fy: event.subject.fy,
|
||||
}
|
||||
dragStartTime = Date.now()
|
||||
dragging = true
|
||||
})
|
||||
.on("drag", function dragged(event) {
|
||||
const initPos = event.subject.__initialDragPos
|
||||
event.subject.fx = initPos.x + (event.x - initPos.x) / currentTransform.k
|
||||
event.subject.fy = initPos.y + (event.y - initPos.y) / currentTransform.k
|
||||
})
|
||||
.on("end", function dragended(event) {
|
||||
if (!event.active) simulation.alphaTarget(0)
|
||||
event.subject.fx = null
|
||||
event.subject.fy = null
|
||||
dragging = false
|
||||
|
||||
// if the time between mousedown and mouseup is short, we consider it a click
|
||||
if (Date.now() - dragStartTime < 500) {
|
||||
const node = graphData.nodes.find((n) => n.id === event.subject.id) as NodeData
|
||||
const targ = resolveRelative(fullSlug, node.id)
|
||||
window.spaNavigate(new URL(targ, window.location.toString()))
|
||||
}
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
for (const node of nodeRenderData) {
|
||||
node.gfx.on("click", () => {
|
||||
const targ = resolveRelative(fullSlug, node.simulationData.id)
|
||||
window.spaNavigate(new URL(targ, window.location.toString()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (enableZoom) {
|
||||
select<HTMLCanvasElement, NodeData>(app.canvas).call(
|
||||
zoom<HTMLCanvasElement, NodeData>()
|
||||
.extent([
|
||||
[0, 0],
|
||||
[width, height],
|
||||
])
|
||||
.scaleExtent([0.25, 4])
|
||||
.on("zoom", ({ transform }) => {
|
||||
currentTransform = transform
|
||||
stage.scale.set(transform.k, transform.k)
|
||||
stage.position.set(transform.x, transform.y)
|
||||
|
||||
// zoom adjusts opacity of labels too
|
||||
const scale = transform.k * opacityScale
|
||||
let scaleOpacity = Math.max((scale - 1) / 3.75, 0)
|
||||
const activeNodes = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label)
|
||||
|
||||
for (const label of labelsContainer.children) {
|
||||
if (!activeNodes.includes(label)) {
|
||||
label.alpha = scaleOpacity
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
let stopAnimation = false
|
||||
function animate(time: number) {
|
||||
if (stopAnimation) return
|
||||
for (const n of nodeRenderData) {
|
||||
const { x, y } = n.simulationData
|
||||
if (!x || !y) continue
|
||||
n.gfx.position.set(x + width / 2, y + height / 2)
|
||||
if (n.label) {
|
||||
n.label.position.set(x + width / 2, y + height / 2)
|
||||
}
|
||||
}
|
||||
|
||||
for (const l of linkRenderData) {
|
||||
const linkData = l.simulationData
|
||||
l.gfx.clear()
|
||||
l.gfx.moveTo(linkData.source.x! + width / 2, linkData.source.y! + height / 2)
|
||||
l.gfx
|
||||
.lineTo(linkData.target.x! + width / 2, linkData.target.y! + height / 2)
|
||||
.stroke({ alpha: l.alpha, width: 1, color: l.color })
|
||||
}
|
||||
|
||||
tweens.forEach((t) => t.update(time))
|
||||
app.renderer.render(stage)
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
return () => {
|
||||
stopAnimation = true
|
||||
app.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
let localGraphCleanups: (() => void)[] = []
|
||||
let globalGraphCleanups: (() => void)[] = []
|
||||
|
||||
function cleanupLocalGraphs() {
|
||||
for (const cleanup of localGraphCleanups) {
|
||||
cleanup()
|
||||
}
|
||||
localGraphCleanups = []
|
||||
}
|
||||
|
||||
function cleanupGlobalGraphs() {
|
||||
for (const cleanup of globalGraphCleanups) {
|
||||
cleanup()
|
||||
}
|
||||
globalGraphCleanups = []
|
||||
}
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const slug = e.detail.url
|
||||
addToVisited(simplifySlug(slug))
|
||||
|
||||
async function renderLocalGraph() {
|
||||
cleanupLocalGraphs()
|
||||
const localGraphContainers = document.getElementsByClassName("graph-container")
|
||||
for (const container of localGraphContainers) {
|
||||
localGraphCleanups.push(await renderGraph(container as HTMLElement, slug))
|
||||
}
|
||||
}
|
||||
|
||||
await renderLocalGraph()
|
||||
const handleThemeChange = () => {
|
||||
void renderLocalGraph()
|
||||
}
|
||||
|
||||
document.addEventListener("themechange", handleThemeChange)
|
||||
window.addCleanup(() => {
|
||||
document.removeEventListener("themechange", handleThemeChange)
|
||||
})
|
||||
|
||||
const containers = [...document.getElementsByClassName("global-graph-outer")] as HTMLElement[]
|
||||
async function renderGlobalGraph() {
|
||||
const slug = getFullSlug(window)
|
||||
for (const container of containers) {
|
||||
container.classList.add("active")
|
||||
const sidebar = container.closest(".sidebar") as HTMLElement
|
||||
if (sidebar) {
|
||||
sidebar.style.zIndex = "1"
|
||||
}
|
||||
|
||||
const graphContainer = container.querySelector(".global-graph-container") as HTMLElement
|
||||
registerEscapeHandler(container, hideGlobalGraph)
|
||||
if (graphContainer) {
|
||||
globalGraphCleanups.push(await renderGraph(graphContainer, slug))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideGlobalGraph() {
|
||||
cleanupGlobalGraphs()
|
||||
for (const container of containers) {
|
||||
container.classList.remove("active")
|
||||
const sidebar = container.closest(".sidebar") as HTMLElement
|
||||
if (sidebar) {
|
||||
sidebar.style.zIndex = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
||||
if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
const anyGlobalGraphOpen = containers.some((container) =>
|
||||
container.classList.contains("active"),
|
||||
)
|
||||
anyGlobalGraphOpen ? hideGlobalGraph() : renderGlobalGraph()
|
||||
}
|
||||
}
|
||||
|
||||
const containerIcons = document.getElementsByClassName("global-graph-icon")
|
||||
Array.from(containerIcons).forEach((icon) => {
|
||||
icon.addEventListener("click", renderGlobalGraph)
|
||||
window.addCleanup(() => icon.removeEventListener("click", renderGlobalGraph))
|
||||
})
|
||||
|
||||
document.addEventListener("keydown", shortcutHandler)
|
||||
window.addCleanup(() => {
|
||||
document.removeEventListener("keydown", shortcutHandler)
|
||||
cleanupLocalGraphs()
|
||||
cleanupGlobalGraphs()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,300 +0,0 @@
|
|||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
class DiagramPanZoom {
|
||||
private isDragging = false
|
||||
private startPan: Position = { x: 0, y: 0 }
|
||||
private currentPan: Position = { x: 0, y: 0 }
|
||||
private scale = 1
|
||||
private readonly MIN_SCALE = 0.5
|
||||
private readonly MAX_SCALE = 3
|
||||
|
||||
cleanups: (() => void)[] = []
|
||||
|
||||
constructor(
|
||||
private container: HTMLElement,
|
||||
private content: HTMLElement,
|
||||
) {
|
||||
this.setupEventListeners()
|
||||
this.setupNavigationControls()
|
||||
this.resetTransform()
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
// Mouse drag events
|
||||
const mouseDownHandler = this.onMouseDown.bind(this)
|
||||
const mouseMoveHandler = this.onMouseMove.bind(this)
|
||||
const mouseUpHandler = this.onMouseUp.bind(this)
|
||||
|
||||
// Touch drag events
|
||||
const touchStartHandler = this.onTouchStart.bind(this)
|
||||
const touchMoveHandler = this.onTouchMove.bind(this)
|
||||
const touchEndHandler = this.onTouchEnd.bind(this)
|
||||
|
||||
const resizeHandler = this.resetTransform.bind(this)
|
||||
|
||||
this.container.addEventListener("mousedown", mouseDownHandler)
|
||||
document.addEventListener("mousemove", mouseMoveHandler)
|
||||
document.addEventListener("mouseup", mouseUpHandler)
|
||||
|
||||
this.container.addEventListener("touchstart", touchStartHandler, { passive: false })
|
||||
document.addEventListener("touchmove", touchMoveHandler, { passive: false })
|
||||
document.addEventListener("touchend", touchEndHandler)
|
||||
|
||||
window.addEventListener("resize", resizeHandler)
|
||||
|
||||
this.cleanups.push(
|
||||
() => this.container.removeEventListener("mousedown", mouseDownHandler),
|
||||
() => document.removeEventListener("mousemove", mouseMoveHandler),
|
||||
() => document.removeEventListener("mouseup", mouseUpHandler),
|
||||
() => this.container.removeEventListener("touchstart", touchStartHandler),
|
||||
() => document.removeEventListener("touchmove", touchMoveHandler),
|
||||
() => document.removeEventListener("touchend", touchEndHandler),
|
||||
() => window.removeEventListener("resize", resizeHandler),
|
||||
)
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const cleanup of this.cleanups) {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
private setupNavigationControls() {
|
||||
const controls = document.createElement("div")
|
||||
controls.className = "mermaid-controls"
|
||||
|
||||
// Zoom controls
|
||||
const zoomIn = this.createButton("+", () => this.zoom(0.1))
|
||||
const zoomOut = this.createButton("-", () => this.zoom(-0.1))
|
||||
const resetBtn = this.createButton("Reset", () => this.resetTransform())
|
||||
|
||||
controls.appendChild(zoomOut)
|
||||
controls.appendChild(resetBtn)
|
||||
controls.appendChild(zoomIn)
|
||||
|
||||
this.container.appendChild(controls)
|
||||
}
|
||||
|
||||
private createButton(text: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = document.createElement("button")
|
||||
button.textContent = text
|
||||
button.className = "mermaid-control-button"
|
||||
button.addEventListener("click", onClick)
|
||||
window.addCleanup(() => button.removeEventListener("click", onClick))
|
||||
return button
|
||||
}
|
||||
|
||||
private onMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return // Only handle left click
|
||||
this.isDragging = true
|
||||
this.startPan = { x: e.clientX - this.currentPan.x, y: e.clientY - this.currentPan.y }
|
||||
this.container.style.cursor = "grabbing"
|
||||
}
|
||||
|
||||
private onMouseMove(e: MouseEvent) {
|
||||
if (!this.isDragging) return
|
||||
e.preventDefault()
|
||||
|
||||
this.currentPan = {
|
||||
x: e.clientX - this.startPan.x,
|
||||
y: e.clientY - this.startPan.y,
|
||||
}
|
||||
|
||||
this.updateTransform()
|
||||
}
|
||||
|
||||
private onMouseUp() {
|
||||
this.isDragging = false
|
||||
this.container.style.cursor = "grab"
|
||||
}
|
||||
|
||||
private onTouchStart(e: TouchEvent) {
|
||||
if (e.touches.length !== 1) return
|
||||
this.isDragging = true
|
||||
const touch = e.touches[0]
|
||||
this.startPan = { x: touch.clientX - this.currentPan.x, y: touch.clientY - this.currentPan.y }
|
||||
}
|
||||
|
||||
private onTouchMove(e: TouchEvent) {
|
||||
if (!this.isDragging || e.touches.length !== 1) return
|
||||
e.preventDefault() // Prevent scrolling
|
||||
|
||||
const touch = e.touches[0]
|
||||
this.currentPan = {
|
||||
x: touch.clientX - this.startPan.x,
|
||||
y: touch.clientY - this.startPan.y,
|
||||
}
|
||||
|
||||
this.updateTransform()
|
||||
}
|
||||
|
||||
private onTouchEnd() {
|
||||
this.isDragging = false
|
||||
}
|
||||
|
||||
private zoom(delta: number) {
|
||||
const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
|
||||
|
||||
// Zoom around center
|
||||
const rect = this.content.getBoundingClientRect()
|
||||
const centerX = rect.width / 2
|
||||
const centerY = rect.height / 2
|
||||
|
||||
const scaleDiff = newScale - this.scale
|
||||
this.currentPan.x -= centerX * scaleDiff
|
||||
this.currentPan.y -= centerY * scaleDiff
|
||||
|
||||
this.scale = newScale
|
||||
this.updateTransform()
|
||||
}
|
||||
|
||||
private updateTransform() {
|
||||
this.content.style.transform = `translate(${this.currentPan.x}px, ${this.currentPan.y}px) scale(${this.scale})`
|
||||
}
|
||||
|
||||
private resetTransform() {
|
||||
const svg = this.content.querySelector("svg")!
|
||||
const rect = svg.getBoundingClientRect()
|
||||
const width = rect.width / this.scale
|
||||
const height = rect.height / this.scale
|
||||
|
||||
this.scale = 1
|
||||
this.currentPan = {
|
||||
x: (this.container.clientWidth - width) / 2,
|
||||
y: (this.container.clientHeight - height) / 2,
|
||||
}
|
||||
this.updateTransform()
|
||||
}
|
||||
}
|
||||
|
||||
const cssVars = [
|
||||
"--secondary",
|
||||
"--tertiary",
|
||||
"--gray",
|
||||
"--light",
|
||||
"--lightgray",
|
||||
"--highlight",
|
||||
"--dark",
|
||||
"--darkgray",
|
||||
"--codeFont",
|
||||
] as const
|
||||
|
||||
let mermaidImport = undefined
|
||||
document.addEventListener("nav", async () => {
|
||||
const center = document.querySelector(".center") as HTMLElement
|
||||
const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement>
|
||||
if (nodes.length === 0) return
|
||||
|
||||
mermaidImport ||= await import(
|
||||
// @ts-ignore
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/mermaid/11.4.0/mermaid.esm.min.mjs"
|
||||
)
|
||||
const mermaid = mermaidImport.default
|
||||
|
||||
const textMapping: WeakMap<HTMLElement, string> = new WeakMap()
|
||||
for (const node of nodes) {
|
||||
textMapping.set(node, node.innerText)
|
||||
}
|
||||
|
||||
async function renderMermaid() {
|
||||
// de-init any other diagrams
|
||||
for (const node of nodes) {
|
||||
node.removeAttribute("data-processed")
|
||||
const oldText = textMapping.get(node)
|
||||
if (oldText) {
|
||||
node.innerHTML = oldText
|
||||
}
|
||||
}
|
||||
|
||||
const computedStyleMap = cssVars.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = window.getComputedStyle(document.documentElement).getPropertyValue(key)
|
||||
return acc
|
||||
},
|
||||
{} as Record<(typeof cssVars)[number], string>,
|
||||
)
|
||||
|
||||
const darkMode = document.documentElement.getAttribute("saved-theme") === "dark"
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "loose",
|
||||
theme: darkMode ? "dark" : "base",
|
||||
themeVariables: {
|
||||
fontFamily: computedStyleMap["--codeFont"],
|
||||
primaryColor: computedStyleMap["--light"],
|
||||
primaryTextColor: computedStyleMap["--darkgray"],
|
||||
primaryBorderColor: computedStyleMap["--tertiary"],
|
||||
lineColor: computedStyleMap["--darkgray"],
|
||||
secondaryColor: computedStyleMap["--secondary"],
|
||||
tertiaryColor: computedStyleMap["--tertiary"],
|
||||
clusterBkg: computedStyleMap["--light"],
|
||||
edgeLabelBackground: computedStyleMap["--highlight"],
|
||||
},
|
||||
})
|
||||
|
||||
await mermaid.run({ nodes })
|
||||
}
|
||||
|
||||
await renderMermaid()
|
||||
document.addEventListener("themechange", renderMermaid)
|
||||
window.addCleanup(() => document.removeEventListener("themechange", renderMermaid))
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const codeBlock = nodes[i] as HTMLElement
|
||||
const pre = codeBlock.parentElement as HTMLPreElement
|
||||
const clipboardBtn = pre.querySelector(".clipboard-button") as HTMLButtonElement
|
||||
const expandBtn = pre.querySelector(".expand-button") as HTMLButtonElement
|
||||
|
||||
const clipboardStyle = window.getComputedStyle(clipboardBtn)
|
||||
const clipboardWidth =
|
||||
clipboardBtn.offsetWidth +
|
||||
parseFloat(clipboardStyle.marginLeft || "0") +
|
||||
parseFloat(clipboardStyle.marginRight || "0")
|
||||
|
||||
// Set expand button position
|
||||
expandBtn.style.right = `calc(${clipboardWidth}px + 0.3rem)`
|
||||
pre.prepend(expandBtn)
|
||||
|
||||
// query popup container
|
||||
const popupContainer = pre.querySelector("#mermaid-container") as HTMLElement
|
||||
if (!popupContainer) return
|
||||
|
||||
let panZoom: DiagramPanZoom | null = null
|
||||
function showMermaid() {
|
||||
const container = popupContainer.querySelector("#mermaid-space") as HTMLElement
|
||||
const content = popupContainer.querySelector(".mermaid-content") as HTMLElement
|
||||
if (!content) return
|
||||
removeAllChildren(content)
|
||||
|
||||
// Clone the mermaid content
|
||||
const mermaidContent = codeBlock.querySelector("svg")!.cloneNode(true) as SVGElement
|
||||
content.appendChild(mermaidContent)
|
||||
|
||||
// Show container
|
||||
popupContainer.classList.add("active")
|
||||
container.style.cursor = "grab"
|
||||
|
||||
// Initialize pan-zoom after showing the popup
|
||||
panZoom = new DiagramPanZoom(container, content)
|
||||
}
|
||||
|
||||
function hideMermaid() {
|
||||
popupContainer.classList.remove("active")
|
||||
panZoom?.cleanup()
|
||||
panZoom = null
|
||||
}
|
||||
|
||||
expandBtn.addEventListener("click", showMermaid)
|
||||
registerEscapeHandler(popupContainer, hideMermaid)
|
||||
|
||||
window.addCleanup(() => {
|
||||
panZoom?.cleanup()
|
||||
expandBtn.removeEventListener("click", showMermaid)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
|
||||
import { normalizeRelativeURLs } from "../../util/path"
|
||||
import { fetchCanonical } from "./util"
|
||||
|
||||
const p = new DOMParser()
|
||||
let activeAnchor: HTMLAnchorElement | null = null
|
||||
|
||||
async function mouseEnterHandler(
|
||||
this: HTMLAnchorElement,
|
||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||
) {
|
||||
const link = (activeAnchor = this)
|
||||
if (link.dataset.noPopover === "true") {
|
||||
return
|
||||
}
|
||||
|
||||
async function setPosition(popoverElement: HTMLElement) {
|
||||
const { x, y } = await computePosition(link, popoverElement, {
|
||||
strategy: "fixed",
|
||||
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
|
||||
})
|
||||
Object.assign(popoverElement.style, {
|
||||
transform: `translate(${x.toFixed()}px, ${y.toFixed()}px)`,
|
||||
})
|
||||
}
|
||||
|
||||
function showPopover(popoverElement: HTMLElement) {
|
||||
clearActivePopover()
|
||||
popoverElement.classList.add("active-popover")
|
||||
setPosition(popoverElement as HTMLElement)
|
||||
|
||||
if (hash !== "") {
|
||||
const targetAnchor = `#popover-internal-${hash.slice(1)}`
|
||||
const heading = popoverInner.querySelector(targetAnchor) as HTMLElement | null
|
||||
if (heading) {
|
||||
// leave ~12px of buffer when scrolling to a heading
|
||||
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const targetUrl = new URL(link.href)
|
||||
const hash = decodeURIComponent(targetUrl.hash)
|
||||
targetUrl.hash = ""
|
||||
targetUrl.search = ""
|
||||
const popoverId = `popover-${link.pathname}`
|
||||
const prevPopoverElement = document.getElementById(popoverId)
|
||||
|
||||
// dont refetch if there's already a popover
|
||||
if (!!document.getElementById(popoverId)) {
|
||||
showPopover(prevPopoverElement as HTMLElement)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetchCanonical(targetUrl).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
if (!response) return
|
||||
const [contentType] = response.headers.get("Content-Type")!.split(";")
|
||||
const [contentTypeCategory, typeInfo] = contentType.split("/")
|
||||
|
||||
const popoverElement = document.createElement("div")
|
||||
popoverElement.id = popoverId
|
||||
popoverElement.classList.add("popover")
|
||||
const popoverInner = document.createElement("div")
|
||||
popoverInner.classList.add("popover-inner")
|
||||
popoverInner.dataset.contentType = contentType ?? undefined
|
||||
popoverElement.appendChild(popoverInner)
|
||||
|
||||
switch (contentTypeCategory) {
|
||||
case "image":
|
||||
const img = document.createElement("img")
|
||||
img.src = targetUrl.toString()
|
||||
img.alt = targetUrl.pathname
|
||||
|
||||
popoverInner.appendChild(img)
|
||||
break
|
||||
case "application":
|
||||
switch (typeInfo) {
|
||||
case "pdf":
|
||||
const pdf = document.createElement("iframe")
|
||||
pdf.src = targetUrl.toString()
|
||||
popoverInner.appendChild(pdf)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
break
|
||||
default:
|
||||
const contents = await response.text()
|
||||
const html = p.parseFromString(contents, "text/html")
|
||||
normalizeRelativeURLs(html, targetUrl)
|
||||
// prepend all IDs inside popovers to prevent duplicates
|
||||
html.querySelectorAll("[id]").forEach((el) => {
|
||||
const targetID = `popover-internal-${el.id}`
|
||||
el.id = targetID
|
||||
})
|
||||
const elts = [...html.getElementsByClassName("popover-hint")]
|
||||
if (elts.length === 0) return
|
||||
|
||||
elts.forEach((elt) => popoverInner.appendChild(elt))
|
||||
}
|
||||
|
||||
if (!!document.getElementById(popoverId)) {
|
||||
return
|
||||
}
|
||||
|
||||
document.body.appendChild(popoverElement)
|
||||
if (activeAnchor !== this) {
|
||||
return
|
||||
}
|
||||
|
||||
showPopover(popoverElement)
|
||||
}
|
||||
|
||||
function clearActivePopover() {
|
||||
activeAnchor = null
|
||||
const allPopoverElements = document.querySelectorAll(".popover")
|
||||
allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[]
|
||||
for (const link of links) {
|
||||
link.addEventListener("mouseenter", mouseEnterHandler)
|
||||
link.addEventListener("mouseleave", clearActivePopover)
|
||||
window.addCleanup(() => {
|
||||
link.removeEventListener("mouseenter", mouseEnterHandler)
|
||||
link.removeEventListener("mouseleave", clearActivePopover)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
let isReaderMode = false
|
||||
|
||||
const emitReaderModeChangeEvent = (mode: "on" | "off") => {
|
||||
const event: CustomEventMap["readermodechange"] = new CustomEvent("readermodechange", {
|
||||
detail: { mode },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const switchReaderMode = () => {
|
||||
isReaderMode = !isReaderMode
|
||||
const newMode = isReaderMode ? "on" : "off"
|
||||
document.documentElement.setAttribute("reader-mode", newMode)
|
||||
emitReaderModeChangeEvent(newMode)
|
||||
}
|
||||
|
||||
for (const readerModeButton of document.getElementsByClassName("readermode")) {
|
||||
readerModeButton.addEventListener("click", switchReaderMode)
|
||||
window.addCleanup(() => readerModeButton.removeEventListener("click", switchReaderMode))
|
||||
}
|
||||
|
||||
// Set initial state
|
||||
document.documentElement.setAttribute("reader-mode", isReaderMode ? "on" : "off")
|
||||
})
|
||||
|
|
@ -1,540 +0,0 @@
|
|||
import FlexSearch, { DefaultDocumentSearchResults } from "flexsearch"
|
||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
|
||||
|
||||
interface Item {
|
||||
id: number
|
||||
slug: FullSlug
|
||||
title: string
|
||||
content: string
|
||||
tags: string[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// Can be expanded with things like "term" in the future
|
||||
type SearchType = "basic" | "tags"
|
||||
let searchType: SearchType = "basic"
|
||||
let currentSearchTerm: string = ""
|
||||
const encoder = (str: string): string[] => {
|
||||
const tokens: string[] = []
|
||||
let bufferStart = -1
|
||||
let bufferEnd = -1
|
||||
const lower = str.toLowerCase()
|
||||
|
||||
let i = 0
|
||||
for (const char of lower) {
|
||||
const code = char.codePointAt(0)!
|
||||
|
||||
const isCJK =
|
||||
(code >= 0x3040 && code <= 0x309f) ||
|
||||
(code >= 0x30a0 && code <= 0x30ff) ||
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0xac00 && code <= 0xd7af) ||
|
||||
(code >= 0x20000 && code <= 0x2a6df)
|
||||
|
||||
const isWhitespace = code === 32 || code === 9 || code === 10 || code === 13
|
||||
|
||||
if (isCJK) {
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart, bufferEnd))
|
||||
bufferStart = -1
|
||||
}
|
||||
tokens.push(char)
|
||||
} else if (isWhitespace) {
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart, bufferEnd))
|
||||
bufferStart = -1
|
||||
}
|
||||
} else {
|
||||
if (bufferStart === -1) bufferStart = i
|
||||
bufferEnd = i + char.length
|
||||
}
|
||||
|
||||
i += char.length
|
||||
}
|
||||
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart))
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
let index = new FlexSearch.Document<Item>({
|
||||
encode: encoder,
|
||||
document: {
|
||||
id: "id",
|
||||
tag: "tags",
|
||||
index: [
|
||||
{
|
||||
field: "title",
|
||||
tokenize: "forward",
|
||||
},
|
||||
{
|
||||
field: "content",
|
||||
tokenize: "forward",
|
||||
},
|
||||
{
|
||||
field: "tags",
|
||||
tokenize: "forward",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const p = new DOMParser()
|
||||
const fetchContentCache: Map<FullSlug, Element[]> = new Map()
|
||||
const contextWindowWords = 30
|
||||
const numSearchResults = 8
|
||||
const numTagResults = 5
|
||||
|
||||
const tokenizeTerm = (term: string) => {
|
||||
const tokens = term.split(/\s+/).filter((t) => t.trim() !== "")
|
||||
const tokenLen = tokens.length
|
||||
if (tokenLen > 1) {
|
||||
for (let i = 1; i < tokenLen; i++) {
|
||||
tokens.push(tokens.slice(0, i + 1).join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.sort((a, b) => b.length - a.length) // always highlight longest terms first
|
||||
}
|
||||
|
||||
function highlight(searchTerm: string, text: string, trim?: boolean) {
|
||||
const tokenizedTerms = tokenizeTerm(searchTerm)
|
||||
let tokenizedText = text.split(/\s+/).filter((t) => t !== "")
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = tokenizedText.length - 1
|
||||
if (trim) {
|
||||
const includesCheck = (tok: string) =>
|
||||
tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
|
||||
const occurrencesIndices = tokenizedText.map(includesCheck)
|
||||
|
||||
let bestSum = 0
|
||||
let bestIndex = 0
|
||||
for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) {
|
||||
const window = occurrencesIndices.slice(i, i + contextWindowWords)
|
||||
const windowSum = window.reduce((total, cur) => total + (cur ? 1 : 0), 0)
|
||||
if (windowSum >= bestSum) {
|
||||
bestSum = windowSum
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
startIndex = Math.max(bestIndex - contextWindowWords, 0)
|
||||
endIndex = Math.min(startIndex + 2 * contextWindowWords, tokenizedText.length - 1)
|
||||
tokenizedText = tokenizedText.slice(startIndex, endIndex)
|
||||
}
|
||||
|
||||
const slice = tokenizedText
|
||||
.map((tok) => {
|
||||
// see if this tok is prefixed by any search terms
|
||||
for (const searchTok of tokenizedTerms) {
|
||||
if (tok.toLowerCase().includes(searchTok.toLowerCase())) {
|
||||
const regex = new RegExp(searchTok.toLowerCase(), "gi")
|
||||
return tok.replace(regex, `<span class="highlight">$&</span>`)
|
||||
}
|
||||
}
|
||||
return tok
|
||||
})
|
||||
.join(" ")
|
||||
|
||||
return `${startIndex === 0 ? "" : "..."}${slice}${
|
||||
endIndex === tokenizedText.length - 1 ? "" : "..."
|
||||
}`
|
||||
}
|
||||
|
||||
function highlightHTML(searchTerm: string, el: HTMLElement) {
|
||||
const p = new DOMParser()
|
||||
const tokenizedTerms = tokenizeTerm(searchTerm)
|
||||
const html = p.parseFromString(el.innerHTML, "text/html")
|
||||
|
||||
const createHighlightSpan = (text: string) => {
|
||||
const span = document.createElement("span")
|
||||
span.className = "highlight"
|
||||
span.textContent = text
|
||||
return span
|
||||
}
|
||||
|
||||
const highlightTextNodes = (node: Node, term: string) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const nodeText = node.nodeValue ?? ""
|
||||
const regex = new RegExp(term.toLowerCase(), "gi")
|
||||
const matches = nodeText.match(regex)
|
||||
if (!matches || matches.length === 0) return
|
||||
const spanContainer = document.createElement("span")
|
||||
let lastIndex = 0
|
||||
for (const match of matches) {
|
||||
const matchIndex = nodeText.indexOf(match, lastIndex)
|
||||
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex, matchIndex)))
|
||||
spanContainer.appendChild(createHighlightSpan(match))
|
||||
lastIndex = matchIndex + match.length
|
||||
}
|
||||
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex)))
|
||||
node.parentNode?.replaceChild(spanContainer, node)
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if ((node as HTMLElement).classList.contains("highlight")) return
|
||||
Array.from(node.childNodes).forEach((child) => highlightTextNodes(child, term))
|
||||
}
|
||||
}
|
||||
|
||||
for (const term of tokenizedTerms) {
|
||||
highlightTextNodes(html.body, term)
|
||||
}
|
||||
|
||||
return html.body
|
||||
}
|
||||
|
||||
async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: ContentIndex) {
|
||||
const container = searchElement.querySelector(".search-container") as HTMLElement
|
||||
if (!container) return
|
||||
|
||||
const sidebar = container.closest(".sidebar") as HTMLElement | null
|
||||
|
||||
const searchButton = searchElement.querySelector(".search-button") as HTMLButtonElement
|
||||
if (!searchButton) return
|
||||
|
||||
const searchBar = searchElement.querySelector(".search-bar") as HTMLInputElement
|
||||
if (!searchBar) return
|
||||
|
||||
const searchLayout = searchElement.querySelector(".search-layout") as HTMLElement
|
||||
if (!searchLayout) return
|
||||
|
||||
const idDataMap = Object.keys(data) as FullSlug[]
|
||||
const appendLayout = (el: HTMLElement) => {
|
||||
searchLayout.appendChild(el)
|
||||
}
|
||||
|
||||
const enablePreview = searchLayout.dataset.preview === "true"
|
||||
let preview: HTMLDivElement | undefined = undefined
|
||||
let previewInner: HTMLDivElement | undefined = undefined
|
||||
const results = document.createElement("div")
|
||||
results.className = "results-container"
|
||||
appendLayout(results)
|
||||
|
||||
if (enablePreview) {
|
||||
preview = document.createElement("div")
|
||||
preview.className = "preview-container"
|
||||
appendLayout(preview)
|
||||
}
|
||||
|
||||
function hideSearch() {
|
||||
container.classList.remove("active")
|
||||
searchBar.value = "" // clear the input when we dismiss the search
|
||||
if (sidebar) sidebar.style.zIndex = ""
|
||||
removeAllChildren(results)
|
||||
if (preview) {
|
||||
removeAllChildren(preview)
|
||||
}
|
||||
searchLayout.classList.remove("display-results")
|
||||
searchType = "basic" // reset search type after closing
|
||||
searchButton.focus()
|
||||
}
|
||||
|
||||
function showSearch(searchTypeNew: SearchType) {
|
||||
searchType = searchTypeNew
|
||||
if (sidebar) sidebar.style.zIndex = "1"
|
||||
container.classList.add("active")
|
||||
searchBar.focus()
|
||||
}
|
||||
|
||||
let currentHover: HTMLInputElement | null = null
|
||||
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
||||
if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
const searchBarOpen = container.classList.contains("active")
|
||||
searchBarOpen ? hideSearch() : showSearch("basic")
|
||||
return
|
||||
} else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
// Hotkey to open tag search
|
||||
e.preventDefault()
|
||||
const searchBarOpen = container.classList.contains("active")
|
||||
searchBarOpen ? hideSearch() : showSearch("tags")
|
||||
|
||||
// add "#" prefix for tag search
|
||||
searchBar.value = "#"
|
||||
return
|
||||
}
|
||||
|
||||
if (currentHover) {
|
||||
currentHover.classList.remove("focus")
|
||||
}
|
||||
|
||||
// If search is active, then we will render the first result and display accordingly
|
||||
if (!container.classList.contains("active")) return
|
||||
if (e.key === "Enter" && !e.isComposing) {
|
||||
// If result has focus, navigate to that one, otherwise pick first result
|
||||
if (results.contains(document.activeElement)) {
|
||||
const active = document.activeElement as HTMLInputElement
|
||||
if (active.classList.contains("no-match")) return
|
||||
await displayPreview(active)
|
||||
active.click()
|
||||
} else {
|
||||
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
|
||||
if (!anchor || anchor.classList.contains("no-match")) return
|
||||
await displayPreview(anchor)
|
||||
anchor.click()
|
||||
}
|
||||
} else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
|
||||
e.preventDefault()
|
||||
if (results.contains(document.activeElement)) {
|
||||
// If an element in results-container already has focus, focus previous one
|
||||
const currentResult = currentHover
|
||||
? currentHover
|
||||
: (document.activeElement as HTMLInputElement | null)
|
||||
const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null
|
||||
currentResult?.classList.remove("focus")
|
||||
prevResult?.focus()
|
||||
if (prevResult) currentHover = prevResult
|
||||
await displayPreview(prevResult)
|
||||
}
|
||||
} else if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault()
|
||||
// The results should already been focused, so we need to find the next one.
|
||||
// The activeElement is the search bar, so we need to find the first result and focus it.
|
||||
if (document.activeElement === searchBar || currentHover !== null) {
|
||||
const firstResult = currentHover
|
||||
? currentHover
|
||||
: (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null)
|
||||
const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null
|
||||
firstResult?.classList.remove("focus")
|
||||
secondResult?.focus()
|
||||
if (secondResult) currentHover = secondResult
|
||||
await displayPreview(secondResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatForDisplay = (term: string, id: number) => {
|
||||
const slug = idDataMap[id]
|
||||
return {
|
||||
id,
|
||||
slug,
|
||||
title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
|
||||
content: highlight(term, data[slug].content ?? "", true),
|
||||
tags: highlightTags(term.substring(1), data[slug].tags),
|
||||
}
|
||||
}
|
||||
|
||||
function highlightTags(term: string, tags: string[]) {
|
||||
if (!tags || searchType !== "tags") {
|
||||
return []
|
||||
}
|
||||
|
||||
return tags
|
||||
.map((tag) => {
|
||||
if (tag.toLowerCase().includes(term.toLowerCase())) {
|
||||
return `<li><p class="match-tag">#${tag}</p></li>`
|
||||
} else {
|
||||
return `<li><p>#${tag}</p></li>`
|
||||
}
|
||||
})
|
||||
.slice(0, numTagResults)
|
||||
}
|
||||
|
||||
function resolveUrl(slug: FullSlug): URL {
|
||||
return new URL(resolveRelative(currentSlug, slug), location.toString())
|
||||
}
|
||||
|
||||
const resultToHTML = ({ slug, title, content, tags }: Item) => {
|
||||
const htmlTags = tags.length > 0 ? `<ul class="tags">${tags.join("")}</ul>` : ``
|
||||
const itemTile = document.createElement("a")
|
||||
itemTile.classList.add("result-card")
|
||||
itemTile.id = slug
|
||||
itemTile.href = resolveUrl(slug).toString()
|
||||
itemTile.innerHTML = `
|
||||
<h3 class="card-title">${title}</h3>
|
||||
${htmlTags}
|
||||
<p class="card-description">${content}</p>
|
||||
`
|
||||
itemTile.addEventListener("click", (event) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
hideSearch()
|
||||
})
|
||||
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
hideSearch()
|
||||
}
|
||||
|
||||
async function onMouseEnter(ev: MouseEvent) {
|
||||
if (!ev.target) return
|
||||
const target = ev.target as HTMLInputElement
|
||||
await displayPreview(target)
|
||||
}
|
||||
|
||||
itemTile.addEventListener("mouseenter", onMouseEnter)
|
||||
window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter))
|
||||
itemTile.addEventListener("click", handler)
|
||||
window.addCleanup(() => itemTile.removeEventListener("click", handler))
|
||||
|
||||
return itemTile
|
||||
}
|
||||
|
||||
async function displayResults(finalResults: Item[]) {
|
||||
removeAllChildren(results)
|
||||
if (finalResults.length === 0) {
|
||||
results.innerHTML = `<a class="result-card no-match">
|
||||
<h3>No results.</h3>
|
||||
<p>Try another search term?</p>
|
||||
</a>`
|
||||
} else {
|
||||
results.append(...finalResults.map(resultToHTML))
|
||||
}
|
||||
|
||||
if (finalResults.length === 0 && preview) {
|
||||
// no results, clear previous preview
|
||||
removeAllChildren(preview)
|
||||
} else {
|
||||
// focus on first result, then also dispatch preview immediately
|
||||
const firstChild = results.firstElementChild as HTMLElement
|
||||
firstChild.classList.add("focus")
|
||||
currentHover = firstChild as HTMLInputElement
|
||||
await displayPreview(firstChild)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchContent(slug: FullSlug): Promise<Element[]> {
|
||||
if (fetchContentCache.has(slug)) {
|
||||
return fetchContentCache.get(slug) as Element[]
|
||||
}
|
||||
|
||||
const targetUrl = resolveUrl(slug).toString()
|
||||
const contents = await fetch(targetUrl)
|
||||
.then((res) => res.text())
|
||||
.then((contents) => {
|
||||
if (contents === undefined) {
|
||||
throw new Error(`Could not fetch ${targetUrl}`)
|
||||
}
|
||||
const html = p.parseFromString(contents ?? "", "text/html")
|
||||
normalizeRelativeURLs(html, targetUrl)
|
||||
return [...html.getElementsByClassName("popover-hint")]
|
||||
})
|
||||
|
||||
fetchContentCache.set(slug, contents)
|
||||
return contents
|
||||
}
|
||||
|
||||
async function displayPreview(el: HTMLElement | null) {
|
||||
if (!searchLayout || !enablePreview || !el || !preview) return
|
||||
const slug = el.id as FullSlug
|
||||
const innerDiv = await fetchContent(slug).then((contents) =>
|
||||
contents.flatMap((el) => [...highlightHTML(currentSearchTerm, el as HTMLElement).children]),
|
||||
)
|
||||
previewInner = document.createElement("div")
|
||||
previewInner.classList.add("preview-inner")
|
||||
previewInner.append(...innerDiv)
|
||||
preview.replaceChildren(previewInner)
|
||||
|
||||
// scroll to longest
|
||||
const highlights = [...preview.getElementsByClassName("highlight")].sort(
|
||||
(a, b) => b.innerHTML.length - a.innerHTML.length,
|
||||
)
|
||||
highlights[0]?.scrollIntoView({ block: "start" })
|
||||
}
|
||||
|
||||
async function onType(e: HTMLElementEventMap["input"]) {
|
||||
if (!searchLayout || !index) return
|
||||
currentSearchTerm = (e.target as HTMLInputElement).value
|
||||
searchLayout.classList.toggle("display-results", currentSearchTerm !== "")
|
||||
searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"
|
||||
|
||||
let searchResults: DefaultDocumentSearchResults<Item>
|
||||
if (searchType === "tags") {
|
||||
currentSearchTerm = currentSearchTerm.substring(1).trim()
|
||||
const separatorIndex = currentSearchTerm.indexOf(" ")
|
||||
if (separatorIndex != -1) {
|
||||
// search by title and content index and then filter by tag (implemented in flexsearch)
|
||||
const tag = currentSearchTerm.substring(0, separatorIndex)
|
||||
const query = currentSearchTerm.substring(separatorIndex + 1).trim()
|
||||
searchResults = await index.searchAsync({
|
||||
query: query,
|
||||
// return at least 10000 documents, so it is enough to filter them by tag (implemented in flexsearch)
|
||||
limit: Math.max(numSearchResults, 10000),
|
||||
index: ["title", "content"],
|
||||
tag: { tags: tag },
|
||||
})
|
||||
for (let searchResult of searchResults) {
|
||||
searchResult.result = searchResult.result.slice(0, numSearchResults)
|
||||
}
|
||||
// set search type to basic and remove tag from term for proper highlightning and scroll
|
||||
searchType = "basic"
|
||||
currentSearchTerm = query
|
||||
} else {
|
||||
// default search by tags index
|
||||
searchResults = await index.searchAsync({
|
||||
query: currentSearchTerm,
|
||||
limit: numSearchResults,
|
||||
index: ["tags"],
|
||||
})
|
||||
}
|
||||
} else if (searchType === "basic") {
|
||||
searchResults = await index.searchAsync({
|
||||
query: currentSearchTerm,
|
||||
limit: numSearchResults,
|
||||
index: ["title", "content"],
|
||||
})
|
||||
}
|
||||
|
||||
const getByField = (field: string): number[] => {
|
||||
const results = searchResults.filter((x) => x.field === field)
|
||||
return results.length === 0 ? [] : ([...results[0].result] as number[])
|
||||
}
|
||||
|
||||
// order titles ahead of content
|
||||
const allIds: Set<number> = new Set([
|
||||
...getByField("title"),
|
||||
...getByField("content"),
|
||||
...getByField("tags"),
|
||||
])
|
||||
const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id))
|
||||
await displayResults(finalResults)
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", shortcutHandler)
|
||||
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
|
||||
searchButton.addEventListener("click", () => showSearch("basic"))
|
||||
window.addCleanup(() => searchButton.removeEventListener("click", () => showSearch("basic")))
|
||||
searchBar.addEventListener("input", onType)
|
||||
window.addCleanup(() => searchBar.removeEventListener("input", onType))
|
||||
|
||||
registerEscapeHandler(container, hideSearch)
|
||||
await fillDocument(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills flexsearch document with data
|
||||
* @param index index to fill
|
||||
* @param data data to fill index with
|
||||
*/
|
||||
let indexPopulated = false
|
||||
async function fillDocument(data: ContentIndex) {
|
||||
if (indexPopulated) return
|
||||
let id = 0
|
||||
const promises: Array<Promise<unknown>> = []
|
||||
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
|
||||
promises.push(
|
||||
index.addAsync(id++, {
|
||||
id,
|
||||
slug: slug as FullSlug,
|
||||
title: fileData.title,
|
||||
content: fileData.content,
|
||||
tags: fileData.tags,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
indexPopulated = true
|
||||
}
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const currentSlug = e.detail.url
|
||||
const data = await fetchData
|
||||
const searchElement = document.getElementsByClassName("search")
|
||||
for (const element of searchElement) {
|
||||
await setupSearch(element, currentSlug, data)
|
||||
}
|
||||
})
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import test, { describe } from "node:test"
|
||||
import assert from "node:assert"
|
||||
|
||||
// Inline the encoder function from search.inline.ts for testing
|
||||
const encoder = (str: string): string[] => {
|
||||
const tokens: string[] = []
|
||||
let bufferStart = -1
|
||||
let bufferEnd = -1
|
||||
const lower = str.toLowerCase()
|
||||
|
||||
let i = 0
|
||||
for (const char of lower) {
|
||||
const code = char.codePointAt(0)!
|
||||
|
||||
const isCJK =
|
||||
(code >= 0x3040 && code <= 0x309f) ||
|
||||
(code >= 0x30a0 && code <= 0x30ff) ||
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0xac00 && code <= 0xd7af) ||
|
||||
(code >= 0x20000 && code <= 0x2a6df)
|
||||
|
||||
const isWhitespace = code === 32 || code === 9 || code === 10 || code === 13
|
||||
|
||||
if (isCJK) {
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart, bufferEnd))
|
||||
bufferStart = -1
|
||||
}
|
||||
tokens.push(char)
|
||||
} else if (isWhitespace) {
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart, bufferEnd))
|
||||
bufferStart = -1
|
||||
}
|
||||
} else {
|
||||
if (bufferStart === -1) bufferStart = i
|
||||
bufferEnd = i + char.length
|
||||
}
|
||||
|
||||
i += char.length
|
||||
}
|
||||
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart))
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
describe("search encoder", () => {
|
||||
describe("English text", () => {
|
||||
test("should tokenize simple English words", () => {
|
||||
const result = encoder("hello world")
|
||||
assert.deepStrictEqual(result, ["hello", "world"])
|
||||
})
|
||||
|
||||
test("should handle multiple spaces", () => {
|
||||
const result = encoder("hello world")
|
||||
assert.deepStrictEqual(result, ["hello", "world"])
|
||||
})
|
||||
|
||||
test("should handle tabs and newlines", () => {
|
||||
const result = encoder("hello\tworld\ntest")
|
||||
assert.deepStrictEqual(result, ["hello", "world", "test"])
|
||||
})
|
||||
|
||||
test("should lowercase all text", () => {
|
||||
const result = encoder("Hello WORLD Test")
|
||||
assert.deepStrictEqual(result, ["hello", "world", "test"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("CJK text", () => {
|
||||
test("should tokenize Japanese Hiragana character by character", () => {
|
||||
const result = encoder("こんにちは")
|
||||
assert.deepStrictEqual(result, ["こ", "ん", "に", "ち", "は"])
|
||||
})
|
||||
|
||||
test("should tokenize Japanese Katakana character by character", () => {
|
||||
const result = encoder("コントロール")
|
||||
assert.deepStrictEqual(result, ["コ", "ン", "ト", "ロ", "ー", "ル"])
|
||||
})
|
||||
|
||||
test("should tokenize Japanese Kanji character by character", () => {
|
||||
const result = encoder("日本語")
|
||||
assert.deepStrictEqual(result, ["日", "本", "語"])
|
||||
})
|
||||
|
||||
test("should tokenize Korean Hangul character by character", () => {
|
||||
const result = encoder("안녕하세요")
|
||||
assert.deepStrictEqual(result, ["안", "녕", "하", "세", "요"])
|
||||
})
|
||||
|
||||
test("should tokenize Chinese characters character by character", () => {
|
||||
const result = encoder("你好世界")
|
||||
assert.deepStrictEqual(result, ["你", "好", "世", "界"])
|
||||
})
|
||||
|
||||
test("should handle mixed Hiragana/Katakana/Kanji", () => {
|
||||
const result = encoder("て以来")
|
||||
assert.deepStrictEqual(result, ["て", "以", "来"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Mixed CJK and English", () => {
|
||||
test("should handle Japanese with English words", () => {
|
||||
const result = encoder("hello 世界")
|
||||
assert.deepStrictEqual(result, ["hello", "世", "界"])
|
||||
})
|
||||
|
||||
test("should handle English with Japanese words", () => {
|
||||
const result = encoder("世界 hello world")
|
||||
assert.deepStrictEqual(result, ["世", "界", "hello", "world"])
|
||||
})
|
||||
|
||||
test("should handle complex mixed content", () => {
|
||||
const result = encoder("これはtest文章です")
|
||||
assert.deepStrictEqual(result, ["こ", "れ", "は", "test", "文", "章", "で", "す"])
|
||||
})
|
||||
|
||||
test("should handle mixed Korean and English", () => {
|
||||
const result = encoder("hello 안녕 world")
|
||||
assert.deepStrictEqual(result, ["hello", "안", "녕", "world"])
|
||||
})
|
||||
|
||||
test("should handle mixed Chinese and English", () => {
|
||||
const result = encoder("你好 world")
|
||||
assert.deepStrictEqual(result, ["你", "好", "world"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
test("should handle empty string", () => {
|
||||
const result = encoder("")
|
||||
assert.deepStrictEqual(result, [])
|
||||
})
|
||||
|
||||
test("should handle only whitespace", () => {
|
||||
const result = encoder(" \t\n ")
|
||||
assert.deepStrictEqual(result, [])
|
||||
})
|
||||
|
||||
test("should handle single character", () => {
|
||||
const result = encoder("a")
|
||||
assert.deepStrictEqual(result, ["a"])
|
||||
})
|
||||
|
||||
test("should handle single CJK character", () => {
|
||||
const result = encoder("あ")
|
||||
assert.deepStrictEqual(result, ["あ"])
|
||||
})
|
||||
|
||||
test("should handle CJK with trailing whitespace", () => {
|
||||
const result = encoder("日本語 ")
|
||||
assert.deepStrictEqual(result, ["日", "本", "語"])
|
||||
})
|
||||
|
||||
test("should handle English with trailing whitespace", () => {
|
||||
const result = encoder("hello ")
|
||||
assert.deepStrictEqual(result, ["hello"])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
import micromorph from "micromorph"
|
||||
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
|
||||
import { fetchCanonical } from "./util"
|
||||
|
||||
// adapted from `micromorph`
|
||||
// https://github.com/natemoo-re/micromorph
|
||||
const NODE_TYPE_ELEMENT = 1
|
||||
let announcer = document.createElement("route-announcer")
|
||||
const isElement = (target: EventTarget | null): target is Element =>
|
||||
(target as Node)?.nodeType === NODE_TYPE_ELEMENT
|
||||
const isLocalUrl = (href: string) => {
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (window.location.origin === url.origin) {
|
||||
return true
|
||||
}
|
||||
} catch (e) {}
|
||||
return false
|
||||
}
|
||||
|
||||
const isSamePage = (url: URL): boolean => {
|
||||
const sameOrigin = url.origin === window.location.origin
|
||||
const samePath = url.pathname === window.location.pathname
|
||||
return sameOrigin && samePath
|
||||
}
|
||||
|
||||
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
|
||||
if (!isElement(target)) return
|
||||
if (target.attributes.getNamedItem("target")?.value === "_blank") return
|
||||
const a = target.closest("a")
|
||||
if (!a) return
|
||||
if ("routerIgnore" in a.dataset) return
|
||||
const { href } = a
|
||||
if (!isLocalUrl(href)) return
|
||||
return { url: new URL(href), scroll: "routerNoscroll" in a.dataset ? false : undefined }
|
||||
}
|
||||
|
||||
function notifyNav(url: FullSlug) {
|
||||
const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } })
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
const cleanupFns: Set<(...args: any[]) => void> = new Set()
|
||||
window.addCleanup = (fn) => cleanupFns.add(fn)
|
||||
|
||||
function startLoading() {
|
||||
const loadingBar = document.createElement("div")
|
||||
loadingBar.className = "navigation-progress"
|
||||
loadingBar.style.width = "0"
|
||||
if (!document.body.contains(loadingBar)) {
|
||||
document.body.appendChild(loadingBar)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
loadingBar.style.width = "80%"
|
||||
}, 100)
|
||||
}
|
||||
|
||||
let isNavigating = false
|
||||
let p: DOMParser
|
||||
async function _navigate(url: URL, isBack: boolean = false) {
|
||||
isNavigating = true
|
||||
startLoading()
|
||||
p = p || new DOMParser()
|
||||
const contents = await fetchCanonical(url)
|
||||
.then((res) => {
|
||||
const contentType = res.headers.get("content-type")
|
||||
if (contentType?.startsWith("text/html")) {
|
||||
return res.text()
|
||||
} else {
|
||||
window.location.assign(url)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.location.assign(url)
|
||||
})
|
||||
|
||||
if (!contents) return
|
||||
|
||||
// notify about to nav
|
||||
const event: CustomEventMap["prenav"] = new CustomEvent("prenav", { detail: {} })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
// cleanup old
|
||||
cleanupFns.forEach((fn) => fn())
|
||||
cleanupFns.clear()
|
||||
|
||||
const html = p.parseFromString(contents, "text/html")
|
||||
normalizeRelativeURLs(html, url)
|
||||
|
||||
let title = html.querySelector("title")?.textContent
|
||||
if (title) {
|
||||
document.title = title
|
||||
} else {
|
||||
const h1 = document.querySelector("h1")
|
||||
title = h1?.innerText ?? h1?.textContent ?? url.pathname
|
||||
}
|
||||
if (announcer.textContent !== title) {
|
||||
announcer.textContent = title
|
||||
}
|
||||
announcer.dataset.persist = ""
|
||||
html.body.appendChild(announcer)
|
||||
|
||||
// morph body
|
||||
await micromorph(document.body, html.body)
|
||||
|
||||
// scroll into place and add history
|
||||
if (!isBack) {
|
||||
if (url.hash) {
|
||||
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
|
||||
el?.scrollIntoView()
|
||||
} else {
|
||||
window.scrollTo({ top: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// now, patch head, re-executing scripts
|
||||
const elementsToRemove = document.head.querySelectorAll(":not([data-persist])")
|
||||
elementsToRemove.forEach((el) => el.remove())
|
||||
const elementsToAdd = html.head.querySelectorAll(":not([data-persist])")
|
||||
elementsToAdd.forEach((el) => document.head.appendChild(el))
|
||||
|
||||
// delay setting the url until now
|
||||
// at this point everything is loaded so changing the url should resolve to the correct addresses
|
||||
if (!isBack) {
|
||||
history.pushState({}, "", url)
|
||||
}
|
||||
|
||||
notifyNav(getFullSlug(window))
|
||||
delete announcer.dataset.persist
|
||||
}
|
||||
|
||||
async function navigate(url: URL, isBack: boolean = false) {
|
||||
if (isNavigating) return
|
||||
isNavigating = true
|
||||
try {
|
||||
await _navigate(url, isBack)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
window.location.assign(url)
|
||||
} finally {
|
||||
isNavigating = false
|
||||
}
|
||||
}
|
||||
|
||||
window.spaNavigate = navigate
|
||||
|
||||
function createRouter() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("click", async (event) => {
|
||||
const { url } = getOpts(event) ?? {}
|
||||
// dont hijack behaviour, just let browser act normally
|
||||
if (!url || event.ctrlKey || event.metaKey) return
|
||||
event.preventDefault()
|
||||
|
||||
if (isSamePage(url) && url.hash) {
|
||||
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
|
||||
el?.scrollIntoView()
|
||||
history.pushState({}, "", url)
|
||||
return
|
||||
}
|
||||
|
||||
navigate(url, false)
|
||||
})
|
||||
|
||||
window.addEventListener("popstate", (event) => {
|
||||
const { url } = getOpts(event) ?? {}
|
||||
if (window.location.hash && window.location.pathname === url?.pathname) return
|
||||
navigate(new URL(window.location.toString()), true)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
return new (class Router {
|
||||
go(pathname: RelativeURL) {
|
||||
const url = new URL(pathname, window.location.toString())
|
||||
return navigate(url, false)
|
||||
}
|
||||
|
||||
back() {
|
||||
return window.history.back()
|
||||
}
|
||||
|
||||
forward() {
|
||||
return window.history.forward()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
createRouter()
|
||||
notifyNav(getFullSlug(window))
|
||||
|
||||
if (!customElements.get("route-announcer")) {
|
||||
const attrs = {
|
||||
"aria-live": "assertive",
|
||||
"aria-atomic": "true",
|
||||
style:
|
||||
"position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px",
|
||||
}
|
||||
|
||||
customElements.define(
|
||||
"route-announcer",
|
||||
class RouteAnnouncer extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
connectedCallback() {
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
this.setAttribute(key, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const slug = entry.target.id
|
||||
const tocEntryElements = document.querySelectorAll(`a[data-for="${slug}"]`)
|
||||
const windowHeight = entry.rootBounds?.height
|
||||
if (windowHeight && tocEntryElements.length > 0) {
|
||||
if (entry.boundingClientRect.y < windowHeight) {
|
||||
tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.add("in-view"))
|
||||
} else {
|
||||
tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.remove("in-view"))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function toggleToc(this: HTMLElement) {
|
||||
this.classList.toggle("collapsed")
|
||||
this.setAttribute(
|
||||
"aria-expanded",
|
||||
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
|
||||
)
|
||||
const content = this.nextElementSibling as HTMLElement | undefined
|
||||
if (!content) return
|
||||
content.classList.toggle("collapsed")
|
||||
}
|
||||
|
||||
function setupToc() {
|
||||
for (const toc of document.getElementsByClassName("toc")) {
|
||||
const button = toc.querySelector(".toc-header")
|
||||
const content = toc.querySelector(".toc-content")
|
||||
if (!button || !content) return
|
||||
button.addEventListener("click", toggleToc)
|
||||
window.addCleanup(() => button.removeEventListener("click", toggleToc))
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
setupToc()
|
||||
|
||||
// update toc entry highlighting
|
||||
observer.disconnect()
|
||||
const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]")
|
||||
headers.forEach((header) => observer.observe(header))
|
||||
})
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb: () => void) {
|
||||
if (!outsideContainer) return
|
||||
function click(this: HTMLElement, e: HTMLElementEventMap["click"]) {
|
||||
if (e.target !== this) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cb()
|
||||
}
|
||||
|
||||
function esc(e: HTMLElementEventMap["keydown"]) {
|
||||
if (!e.key.startsWith("Esc")) return
|
||||
e.preventDefault()
|
||||
cb()
|
||||
}
|
||||
|
||||
outsideContainer?.addEventListener("click", click)
|
||||
window.addCleanup(() => outsideContainer?.removeEventListener("click", click))
|
||||
document.addEventListener("keydown", esc)
|
||||
window.addCleanup(() => document.removeEventListener("keydown", esc))
|
||||
}
|
||||
|
||||
export function removeAllChildren(node: HTMLElement) {
|
||||
while (node.firstChild) {
|
||||
node.removeChild(node.firstChild)
|
||||
}
|
||||
}
|
||||
|
||||
// AliasRedirect emits HTML redirects which also have the link[rel="canonical"]
|
||||
// containing the URL it's redirecting to.
|
||||
// Extracting it here with regex is _probably_ faster than parsing the entire HTML
|
||||
// with a DOMParser effectively twice (here and later in the SPA code), even if
|
||||
// way less robust - we only care about our own generated redirects after all.
|
||||
const canonicalRegex = /<link rel="canonical" href="([^"]*)">/
|
||||
|
||||
export async function fetchCanonical(url: URL): Promise<Response> {
|
||||
const res = await fetch(`${url}`)
|
||||
if (!res.headers.get("content-type")?.startsWith("text/html")) {
|
||||
return res
|
||||
}
|
||||
|
||||
// reading the body can only be done once, so we need to clone the response
|
||||
// to allow the caller to read it if it's was not a redirect
|
||||
const text = await res.clone().text()
|
||||
const [_, redirect] = text.match(canonicalRegex) ?? []
|
||||
return redirect ? fetch(`${new URL(redirect, url)}`) : res
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.backlinks {
|
||||
flex-direction: column;
|
||||
|
||||
& > h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& > ul.overflow {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0;
|
||||
max-height: calc(100% - 2rem);
|
||||
overscroll-behavior: contain;
|
||||
|
||||
& > li {
|
||||
& > a {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
.breadcrumb-container {
|
||||
margin: 0;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.breadcrumb-element {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.breadcrumb-separator {
|
||||
margin: 0 0 0 0.4rem;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
.clipboard-button {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
float: right;
|
||||
right: 0;
|
||||
padding: 0.4rem;
|
||||
margin: 0.3rem;
|
||||
color: var(--gray);
|
||||
border-color: var(--dark);
|
||||
background-color: var(--light);
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
opacity: 0;
|
||||
transition: 0.2s;
|
||||
|
||||
& > svg {
|
||||
fill: var(--light);
|
||||
filter: contrast(0.3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
&:hover > .clipboard-button {
|
||||
opacity: 1;
|
||||
transition: 0.2s;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
.content-meta {
|
||||
margin-top: 0;
|
||||
color: var(--darkgray);
|
||||
|
||||
&[show-comma="true"] {
|
||||
> *:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
|
||||
&::after {
|
||||
content: ",";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
.darkmode {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 20px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
text-align: inherit;
|
||||
flex-shrink: 0;
|
||||
|
||||
& svg {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: calc(50% - 10px);
|
||||
fill: var(--darkgray);
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
:root[saved-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[saved-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[saved-theme="dark"] .darkmode {
|
||||
& > .dayIcon {
|
||||
display: none;
|
||||
}
|
||||
& > .nightIcon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
:root .darkmode {
|
||||
& > .dayIcon {
|
||||
display: inline;
|
||||
}
|
||||
& > .nightIcon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
@media all and ($mobile) {
|
||||
.page > #quartz-body {
|
||||
// Shift page position when toggling Explorer on mobile.
|
||||
& > :not(.sidebar.left:has(.explorer)) {
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
&.lock-scroll > :not(.sidebar.left:has(.explorer)) {
|
||||
transform: translateX(100dvw);
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
// Sticky top bar (stays in place when scrolling down on mobile).
|
||||
.sidebar.left:has(.explorer) {
|
||||
box-sizing: border-box;
|
||||
position: sticky;
|
||||
background-color: var(--light);
|
||||
padding: 1rem 0 1rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hide-until-loaded ~ .explorer-content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
|
||||
min-height: 1.2rem;
|
||||
flex: 0 1 auto;
|
||||
|
||||
&.collapsed {
|
||||
flex: 0 1 1.2rem;
|
||||
|
||||
& .fold {
|
||||
transform: rotateZ(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
& .fold {
|
||||
margin-left: 0.5rem;
|
||||
transition: transform 0.3s ease;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media all and ($mobile) {
|
||||
order: -1;
|
||||
height: initial;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
button.mobile-explorer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button.desktop-explorer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media all and ($mobile) {
|
||||
button.mobile-explorer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
button.desktop-explorer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.desktop-only {
|
||||
@media all and not ($mobile) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
pointer-events: all;
|
||||
transition: transform 0.35s ease;
|
||||
|
||||
& > polyline {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button.mobile-explorer,
|
||||
button.desktop-explorer {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& h2 {
|
||||
font-size: 1rem;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.explorer-content {
|
||||
list-style: none;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&.explorer-ul {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
& li > a {
|
||||
color: var(--dark);
|
||||
opacity: 0.75;
|
||||
pointer-events: all;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
color: var(--tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.folder-outer {
|
||||
visibility: collapse;
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition-property: grid-template-rows, visibility;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
.folder-outer.open {
|
||||
visibility: visible;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.folder-outer > ul {
|
||||
overflow: hidden;
|
||||
margin-left: 6px;
|
||||
padding-left: 0.8rem;
|
||||
border-left: 1px solid var(--lightgray);
|
||||
}
|
||||
}
|
||||
|
||||
.folder-container {
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
|
||||
& div > a {
|
||||
color: var(--secondary);
|
||||
font-family: var(--headerFont);
|
||||
font-size: 0.95rem;
|
||||
font-weight: $semiBoldWeight;
|
||||
line-height: 1.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& div > a:hover {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
& div > button {
|
||||
color: var(--dark);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: var(--headerFont);
|
||||
|
||||
& span {
|
||||
font-size: 0.95rem;
|
||||
display: inline-block;
|
||||
color: var(--secondary);
|
||||
font-weight: $semiBoldWeight;
|
||||
margin: 0;
|
||||
line-height: 1.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
margin-right: 5px;
|
||||
color: var(--secondary);
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease;
|
||||
backface-visibility: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.folder-icon:hover {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
.explorer {
|
||||
@media all and ($mobile) {
|
||||
&.collapsed {
|
||||
flex: 0 0 34px;
|
||||
|
||||
& > .explorer-content {
|
||||
transform: translateX(-100vw);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.collapsed) {
|
||||
flex: 0 0 34px;
|
||||
|
||||
& > .explorer-content {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.explorer-content {
|
||||
box-sizing: border-box;
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin-top: 0;
|
||||
background-color: var(--light);
|
||||
max-width: 100vw;
|
||||
width: 100vw;
|
||||
transform: translateX(-100vw);
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
visibility 200ms ease;
|
||||
overflow: hidden;
|
||||
padding: 4rem 0 2rem 0;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.mobile-explorer {
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
z-index: 101;
|
||||
|
||||
.lucide-menu {
|
||||
stroke: var(--darkgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-no-scroll {
|
||||
@media all and ($mobile) {
|
||||
.explorer-content > .explorer-ul {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
footer {
|
||||
text-align: left;
|
||||
margin-bottom: 4rem;
|
||||
opacity: 0.7;
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
margin-top: -1rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.graph {
|
||||
& > h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& > .graph-outer {
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--lightgray);
|
||||
box-sizing: border-box;
|
||||
height: 250px;
|
||||
margin: 0.5em 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
& > .global-graph-icon {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--dark);
|
||||
opacity: 0.5;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: absolute;
|
||||
padding: 0.2rem;
|
||||
margin: 0.3rem;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.5s ease;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: var(--lightgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .global-graph-outer {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(4px);
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
|
||||
&.active {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& > .global-graph-container {
|
||||
border: 1px solid var(--lightgray);
|
||||
background-color: var(--light);
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 80vh;
|
||||
width: 80vw;
|
||||
|
||||
@media all and not ($desktop) {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
details.toc {
|
||||
& summary {
|
||||
cursor: pointer;
|
||||
|
||||
&::marker {
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
& > * {
|
||||
padding-left: 0.25rem;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0.5rem 1.25rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@for $i from 1 through 6 {
|
||||
& .depth-#{$i} {
|
||||
padding-left: calc(1rem * #{$i});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
ul.section-ul {
|
||||
list-style: none;
|
||||
margin-top: 2em;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
li.section-li {
|
||||
margin-bottom: 1em;
|
||||
|
||||
& > .section {
|
||||
display: grid;
|
||||
grid-template-columns: fit-content(8em) 3fr 1fr;
|
||||
|
||||
@media all and ($mobile) {
|
||||
& > .tags {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > .desc > h3 > a {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
& .meta {
|
||||
margin: 0 1em 0 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// modifications in popover context
|
||||
.popover .section {
|
||||
grid-template-columns: fit-content(8em) 1fr !important;
|
||||
|
||||
& > .tags {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
.expand-button {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
float: right;
|
||||
padding: 0.4rem;
|
||||
margin: 0.3rem;
|
||||
right: 0; // NOTE: right will be set in mermaid.inline.ts
|
||||
color: var(--gray);
|
||||
border-color: var(--dark);
|
||||
background-color: var(--light);
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
opacity: 0;
|
||||
transition: 0.2s;
|
||||
|
||||
& > svg {
|
||||
fill: var(--light);
|
||||
filter: contrast(0.3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
&:hover > .expand-button {
|
||||
opacity: 1;
|
||||
transition: 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
#mermaid-container {
|
||||
position: fixed;
|
||||
contain: layout;
|
||||
z-index: 999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
backdrop-filter: blur(4px);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
&.active {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& > #mermaid-space {
|
||||
border: 1px solid var(--lightgray);
|
||||
background-color: var(--light);
|
||||
border-radius: 5px;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 80vh;
|
||||
width: 80vw;
|
||||
overflow: hidden;
|
||||
|
||||
& > .mermaid-content {
|
||||
position: relative;
|
||||
transform-origin: 0 0;
|
||||
transition: transform 0.1s ease;
|
||||
overflow: visible;
|
||||
min-height: 200px;
|
||||
min-width: 200px;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
max-width: none;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
& > .mermaid-controls {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--light);
|
||||
border: 1px solid var(--lightgray);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 2;
|
||||
|
||||
.mermaid-control-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--lightgray);
|
||||
background: var(--light);
|
||||
color: var(--dark);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-family: var(--bodyFont);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--lightgray);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
// Style the reset button differently
|
||||
&:nth-child(2) {
|
||||
width: auto;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
@keyframes dropin {
|
||||
0% {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
1% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.popover {
|
||||
z-index: 999;
|
||||
position: fixed;
|
||||
overflow: visible;
|
||||
padding: 1rem;
|
||||
left: 0;
|
||||
top: 0;
|
||||
will-change: transform;
|
||||
|
||||
& > .popover-inner {
|
||||
position: relative;
|
||||
width: 30rem;
|
||||
max-height: 20rem;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
font-weight: initial;
|
||||
font-style: initial;
|
||||
line-height: normal;
|
||||
font-size: initial;
|
||||
font-family: var(--bodyFont);
|
||||
border: 1px solid var(--lightgray);
|
||||
background-color: var(--light);
|
||||
border-radius: 5px;
|
||||
box-shadow: 6px 6px 36px 0 rgba(0, 0, 0, 0.25);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
white-space: normal;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
& > .popover-inner[data-content-type] {
|
||||
&[data-content-type*="pdf"],
|
||||
&[data-content-type*="image"] {
|
||||
padding: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
&[data-content-type*="image"] {
|
||||
img {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-content-type*="pdf"] {
|
||||
iframe {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
visibility 0.3s ease;
|
||||
|
||||
@media all and ($mobile) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.active-popover,
|
||||
.popover:hover {
|
||||
animation: dropin 0.3s ease;
|
||||
animation-fill-mode: forwards;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
.readermode {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 20px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
text-align: inherit;
|
||||
flex-shrink: 0;
|
||||
|
||||
& svg {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: calc(50% - 10px);
|
||||
fill: var(--darkgray);
|
||||
stroke: var(--darkgray);
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
:root[reader-mode="on"] {
|
||||
& .sidebar.left,
|
||||
& .sidebar.right {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
.recent-notes {
|
||||
& > h3 {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
& > ul.recent-ul {
|
||||
list-style: none;
|
||||
margin-top: 1rem;
|
||||
padding-left: 0;
|
||||
|
||||
& > li {
|
||||
margin: 1rem 0;
|
||||
.section > .desc > h3 > a {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.section > .meta {
|
||||
margin: 0 0 0.5rem 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.search {
|
||||
min-width: fit-content;
|
||||
max-width: 14rem;
|
||||
@media all and ($mobile) {
|
||||
flex-grow: 0.3;
|
||||
}
|
||||
|
||||
& > .search-button {
|
||||
background-color: transparent;
|
||||
border: 1px var(--lightgray) solid;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
height: 2rem;
|
||||
padding: 0 1rem 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
|
||||
& > p {
|
||||
display: inline;
|
||||
color: var(--gray);
|
||||
text-wrap: unset;
|
||||
}
|
||||
|
||||
& svg {
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
min-width: 18px;
|
||||
margin: 0 0.5rem;
|
||||
|
||||
.search-path {
|
||||
stroke: var(--darkgray);
|
||||
stroke-width: 1.5px;
|
||||
transition: stroke 0.5s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .search-container {
|
||||
position: fixed;
|
||||
contain: layout;
|
||||
z-index: 999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
&.active {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& > .search-space {
|
||||
width: 65%;
|
||||
margin-top: 12vh;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media all and not ($desktop) {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
border-radius: 7px;
|
||||
background: var(--light);
|
||||
box-shadow:
|
||||
0 14px 50px rgba(27, 33, 48, 0.12),
|
||||
0 10px 30px rgba(27, 33, 48, 0.16);
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
& > input {
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em 1em;
|
||||
font-family: var(--bodyFont);
|
||||
color: var(--dark);
|
||||
font-size: 1.1em;
|
||||
border: 1px solid var(--lightgray);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > .search-layout {
|
||||
display: none;
|
||||
flex-direction: row;
|
||||
border: 1px solid var(--lightgray);
|
||||
flex: 0 0 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.display-results {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&[data-preview] > .results-container {
|
||||
flex: 0 0 min(30%, 450px);
|
||||
}
|
||||
|
||||
@media all and not ($mobile) {
|
||||
&[data-preview] {
|
||||
& .result-card > p.preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > div {
|
||||
&:first-child {
|
||||
border-right: 1px solid var(--lightgray);
|
||||
border-top-right-radius: unset;
|
||||
border-bottom-right-radius: unset;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-left-radius: unset;
|
||||
border-bottom-left-radius: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > div {
|
||||
height: calc(75vh - 12vh);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
@media all and ($mobile) {
|
||||
flex-direction: column;
|
||||
|
||||
& > .preview-container {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&[data-preview] > .results-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
}
|
||||
|
||||
& .highlight {
|
||||
background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0));
|
||||
border-radius: 5px;
|
||||
scroll-margin-top: 2rem;
|
||||
}
|
||||
|
||||
& > .preview-container {
|
||||
flex-grow: 1;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
color: var(--dark);
|
||||
line-height: 1.5em;
|
||||
font-weight: $normalWeight;
|
||||
overflow-y: auto;
|
||||
padding: 0 2rem;
|
||||
|
||||
& .preview-inner {
|
||||
margin: 0 auto;
|
||||
width: min($pageWidth, 100%);
|
||||
}
|
||||
|
||||
a[role="anchor"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
& > .results-container {
|
||||
overflow-y: auto;
|
||||
|
||||
& .result-card {
|
||||
overflow: hidden;
|
||||
padding: 1em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
border-bottom: 1px solid var(--lightgray);
|
||||
width: 100%;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
|
||||
// normalize card props
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
text-transform: none;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
font-weight: inherit;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus {
|
||||
background: var(--lightgray);
|
||||
}
|
||||
|
||||
& > h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media all and not ($mobile) {
|
||||
& > p.card-description {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > ul.tags {
|
||||
margin-top: 0.45rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
& > ul > li > p {
|
||||
border-radius: 8px;
|
||||
background-color: var(--highlight);
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: 0 0.1rem;
|
||||
line-height: 1.4rem;
|
||||
font-weight: $boldWeight;
|
||||
color: var(--secondary);
|
||||
|
||||
&.match-tag {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
& > p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.toc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
min-height: 1.4rem;
|
||||
flex: 0 0.5 auto;
|
||||
&:has(button.toc-header.collapsed) {
|
||||
flex: 0 1 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
button.toc-header {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& h3 {
|
||||
font-size: 1rem;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .fold {
|
||||
margin-left: 0.5rem;
|
||||
transition: transform 0.3s ease;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.collapsed .fold {
|
||||
transform: rotateZ(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
ul.toc-content.overflow {
|
||||
list-style: none;
|
||||
position: relative;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0;
|
||||
max-height: calc(100% - 2rem);
|
||||
overscroll-behavior: contain;
|
||||
list-style: none;
|
||||
|
||||
& > li > a {
|
||||
color: var(--dark);
|
||||
opacity: 0.35;
|
||||
transition:
|
||||
0.5s ease opacity,
|
||||
0.3s ease color;
|
||||
&.in-view {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 0 through 6 {
|
||||
& .depth-#{$i} {
|
||||
padding-left: calc(1rem * #{$i});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { ComponentType, JSX } from "preact"
|
||||
import { StaticResources, StringResource } from "../util/resources"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { Node } from "hast"
|
||||
import { BuildCtx } from "../util/ctx"
|
||||
|
||||
export type QuartzComponentProps = {
|
||||
ctx: BuildCtx
|
||||
externalResources: StaticResources
|
||||
fileData: QuartzPluginData
|
||||
cfg: GlobalConfiguration
|
||||
children: (QuartzComponent | JSX.Element)[]
|
||||
tree: Node
|
||||
allFiles: QuartzPluginData[]
|
||||
displayClass?: "mobile-only" | "desktop-only"
|
||||
} & JSX.IntrinsicAttributes & {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type QuartzComponent = ComponentType<QuartzComponentProps> & {
|
||||
css?: StringResource
|
||||
beforeDOMLoaded?: StringResource
|
||||
afterDOMLoaded?: StringResource
|
||||
}
|
||||
|
||||
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
|
||||
opts: Options,
|
||||
) => QuartzComponent
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { Translation, CalloutTranslation } from "./locales/definition"
|
||||
import enUs from "./locales/en-US"
|
||||
import enGb from "./locales/en-GB"
|
||||
import fr from "./locales/fr-FR"
|
||||
import it from "./locales/it-IT"
|
||||
import ja from "./locales/ja-JP"
|
||||
import de from "./locales/de-DE"
|
||||
import nl from "./locales/nl-NL"
|
||||
import ro from "./locales/ro-RO"
|
||||
import ca from "./locales/ca-ES"
|
||||
import es from "./locales/es-ES"
|
||||
import ar from "./locales/ar-SA"
|
||||
import uk from "./locales/uk-UA"
|
||||
import ru from "./locales/ru-RU"
|
||||
import ko from "./locales/ko-KR"
|
||||
import zh from "./locales/zh-CN"
|
||||
import zhTw from "./locales/zh-TW"
|
||||
import vi from "./locales/vi-VN"
|
||||
import pt from "./locales/pt-BR"
|
||||
import hu from "./locales/hu-HU"
|
||||
import fa from "./locales/fa-IR"
|
||||
import pl from "./locales/pl-PL"
|
||||
import cs from "./locales/cs-CZ"
|
||||
import tr from "./locales/tr-TR"
|
||||
import th from "./locales/th-TH"
|
||||
import lt from "./locales/lt-LT"
|
||||
import fi from "./locales/fi-FI"
|
||||
import no from "./locales/nb-NO"
|
||||
import id from "./locales/id-ID"
|
||||
import kk from "./locales/kk-KZ"
|
||||
import he from "./locales/he-IL"
|
||||
|
||||
export const TRANSLATIONS = {
|
||||
"en-US": enUs,
|
||||
"en-GB": enGb,
|
||||
"fr-FR": fr,
|
||||
"it-IT": it,
|
||||
"ja-JP": ja,
|
||||
"de-DE": de,
|
||||
"nl-NL": nl,
|
||||
"nl-BE": nl,
|
||||
"ro-RO": ro,
|
||||
"ro-MD": ro,
|
||||
"ca-ES": ca,
|
||||
"es-ES": es,
|
||||
"ar-SA": ar,
|
||||
"ar-AE": ar,
|
||||
"ar-QA": ar,
|
||||
"ar-BH": ar,
|
||||
"ar-KW": ar,
|
||||
"ar-OM": ar,
|
||||
"ar-YE": ar,
|
||||
"ar-IR": ar,
|
||||
"ar-SY": ar,
|
||||
"ar-IQ": ar,
|
||||
"ar-JO": ar,
|
||||
"ar-PL": ar,
|
||||
"ar-LB": ar,
|
||||
"ar-EG": ar,
|
||||
"ar-SD": ar,
|
||||
"ar-LY": ar,
|
||||
"ar-MA": ar,
|
||||
"ar-TN": ar,
|
||||
"ar-DZ": ar,
|
||||
"ar-MR": ar,
|
||||
"uk-UA": uk,
|
||||
"ru-RU": ru,
|
||||
"ko-KR": ko,
|
||||
"zh-CN": zh,
|
||||
"zh-TW": zhTw,
|
||||
"vi-VN": vi,
|
||||
"pt-BR": pt,
|
||||
"hu-HU": hu,
|
||||
"fa-IR": fa,
|
||||
"pl-PL": pl,
|
||||
"cs-CZ": cs,
|
||||
"tr-TR": tr,
|
||||
"th-TH": th,
|
||||
"lt-LT": lt,
|
||||
"fi-FI": fi,
|
||||
"nb-NO": no,
|
||||
"id-ID": id,
|
||||
"kk-KZ": kk,
|
||||
"he-IL": he,
|
||||
} as const
|
||||
|
||||
export const defaultTranslation = "en-US"
|
||||
export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale ?? defaultTranslation]
|
||||
export type ValidLocale = keyof typeof TRANSLATIONS
|
||||
export type ValidCallout = keyof CalloutTranslation
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "غير معنون",
|
||||
description: "لم يتم تقديم أي وصف",
|
||||
},
|
||||
direction: "rtl" as const,
|
||||
components: {
|
||||
callout: {
|
||||
note: "ملاحظة",
|
||||
abstract: "ملخص",
|
||||
info: "معلومات",
|
||||
todo: "للقيام",
|
||||
tip: "نصيحة",
|
||||
success: "نجاح",
|
||||
question: "سؤال",
|
||||
warning: "تحذير",
|
||||
failure: "فشل",
|
||||
danger: "خطر",
|
||||
bug: "خلل",
|
||||
example: "مثال",
|
||||
quote: "اقتباس",
|
||||
},
|
||||
backlinks: {
|
||||
title: "وصلات العودة",
|
||||
noBacklinksFound: "لا يوجد وصلات عودة",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "الوضع النهاري",
|
||||
darkMode: "الوضع الليلي",
|
||||
},
|
||||
explorer: {
|
||||
title: "المستعرض",
|
||||
},
|
||||
readerMode: {
|
||||
title: "وضع القارئ",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "أُنشئ باستخدام",
|
||||
},
|
||||
graph: {
|
||||
title: "التمثيل التفاعلي",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "آخر الملاحظات",
|
||||
seeRemainingMore: ({ remaining }) => `تصفح ${remaining} أكثر →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `مقتبس من ${targetSlug}`,
|
||||
linkToOriginal: "وصلة للملاحظة الرئيسة",
|
||||
},
|
||||
search: {
|
||||
title: "بحث",
|
||||
searchBarPlaceholder: "ابحث عن شيء ما",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "فهرس المحتويات",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) =>
|
||||
minutes == 1
|
||||
? `دقيقة أو أقل للقراءة`
|
||||
: minutes == 2
|
||||
? `دقيقتان للقراءة`
|
||||
: `${minutes} دقائق للقراءة`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "آخر الملاحظات",
|
||||
lastFewNotes: ({ count }) => `آخر ${count} ملاحظة`,
|
||||
},
|
||||
error: {
|
||||
title: "غير موجود",
|
||||
notFound: "إما أن هذه الصفحة خاصة أو غير موجودة.",
|
||||
home: "العوده للصفحة الرئيسية",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "مجلد",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "يوجد عنصر واحد فقط تحت هذا المجلد" : `يوجد ${count} عناصر تحت هذا المجلد.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "الوسم",
|
||||
tagIndex: "مؤشر الوسم",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "يوجد عنصر واحد فقط تحت هذا الوسم" : `يوجد ${count} عناصر تحت هذا الوسم.`,
|
||||
showingFirst: ({ count }) => `إظهار أول ${count} أوسمة.`,
|
||||
totalTags: ({ count }) => `يوجد ${count} أوسمة.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Sense títol",
|
||||
description: "Sense descripció",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Nota",
|
||||
abstract: "Resum",
|
||||
info: "Informació",
|
||||
todo: "Per fer",
|
||||
tip: "Consell",
|
||||
success: "Èxit",
|
||||
question: "Pregunta",
|
||||
warning: "Advertència",
|
||||
failure: "Fall",
|
||||
danger: "Perill",
|
||||
bug: "Error",
|
||||
example: "Exemple",
|
||||
quote: "Cita",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Retroenllaç",
|
||||
noBacklinksFound: "No s'han trobat retroenllaços",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Mode clar",
|
||||
darkMode: "Mode fosc",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Mode lector",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorador",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Creat amb",
|
||||
},
|
||||
graph: {
|
||||
title: "Vista Gràfica",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Notes Recents",
|
||||
seeRemainingMore: ({ remaining }) => `Vegi ${remaining} més →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Transcluit de ${targetSlug}`,
|
||||
linkToOriginal: "Enllaç a l'original",
|
||||
},
|
||||
search: {
|
||||
title: "Cercar",
|
||||
searchBarPlaceholder: "Cerca alguna cosa",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Taula de Continguts",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `Es llegeix en ${minutes} min`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Notes recents",
|
||||
lastFewNotes: ({ count }) => `Últimes ${count} notes`,
|
||||
},
|
||||
error: {
|
||||
title: "No s'ha trobat.",
|
||||
notFound: "Aquesta pàgina és privada o no existeix.",
|
||||
home: "Torna a la pàgina principal",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Carpeta",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 article en aquesta carpeta." : `${count} articles en esta carpeta.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Etiqueta",
|
||||
tagIndex: "índex d'Etiquetes",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 article amb aquesta etiqueta." : `${count} article amb aquesta etiqueta.`,
|
||||
showingFirst: ({ count }) => `Mostrant les primeres ${count} etiquetes.`,
|
||||
totalTags: ({ count }) => `S'han trobat ${count} etiquetes en total.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Bez názvu",
|
||||
description: "Nebyl uveden žádný popis",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Poznámka",
|
||||
abstract: "Abstract",
|
||||
info: "Info",
|
||||
todo: "Todo",
|
||||
tip: "Tip",
|
||||
success: "Úspěch",
|
||||
question: "Otázka",
|
||||
warning: "Upozornění",
|
||||
failure: "Chyba",
|
||||
danger: "Nebezpečí",
|
||||
bug: "Bug",
|
||||
example: "Příklad",
|
||||
quote: "Citace",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Příchozí odkazy",
|
||||
noBacklinksFound: "Nenalezeny žádné příchozí odkazy",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Světlý režim",
|
||||
darkMode: "Tmavý režim",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Režim čtečky",
|
||||
},
|
||||
explorer: {
|
||||
title: "Procházet",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Vytvořeno pomocí",
|
||||
},
|
||||
graph: {
|
||||
title: "Graf",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Nejnovější poznámky",
|
||||
seeRemainingMore: ({ remaining }) => `Zobraz ${remaining} dalších →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Zobrazení ${targetSlug}`,
|
||||
linkToOriginal: "Odkaz na původní dokument",
|
||||
},
|
||||
search: {
|
||||
title: "Hledat",
|
||||
searchBarPlaceholder: "Hledejte něco",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Obsah",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min čtení`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Nejnovější poznámky",
|
||||
lastFewNotes: ({ count }) => `Posledních ${count} poznámek`,
|
||||
},
|
||||
error: {
|
||||
title: "Nenalezeno",
|
||||
notFound: "Tato stránka je buď soukromá, nebo neexistuje.",
|
||||
home: "Návrat na domovskou stránku",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Složka",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 položka v této složce." : `${count} položek v této složce.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Tag",
|
||||
tagIndex: "Rejstřík tagů",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 položka s tímto tagem." : `${count} položek s tímto tagem.`,
|
||||
showingFirst: ({ count }) => `Zobrazují se první ${count} tagy.`,
|
||||
totalTags: ({ count }) => `Nalezeno celkem ${count} tagů.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Unbenannt",
|
||||
description: "Keine Beschreibung angegeben",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Hinweis",
|
||||
abstract: "Zusammenfassung",
|
||||
info: "Info",
|
||||
todo: "Zu erledigen",
|
||||
tip: "Tipp",
|
||||
success: "Erfolg",
|
||||
question: "Frage",
|
||||
warning: "Warnung",
|
||||
failure: "Fehlgeschlagen",
|
||||
danger: "Gefahr",
|
||||
bug: "Fehler",
|
||||
example: "Beispiel",
|
||||
quote: "Zitat",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Backlinks",
|
||||
noBacklinksFound: "Keine Backlinks gefunden",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Heller Modus",
|
||||
darkMode: "Dunkler Modus",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Lesemodus",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorer",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Erstellt mit",
|
||||
},
|
||||
graph: {
|
||||
title: "Graphansicht",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Zuletzt bearbeitete Seiten",
|
||||
seeRemainingMore: ({ remaining }) => `${remaining} weitere ansehen →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Transklusion von ${targetSlug}`,
|
||||
linkToOriginal: "Link zum Original",
|
||||
},
|
||||
search: {
|
||||
title: "Suche",
|
||||
searchBarPlaceholder: "Suche nach etwas",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Inhaltsverzeichnis",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} Min. Lesezeit`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Zuletzt bearbeitete Seiten",
|
||||
lastFewNotes: ({ count }) => `Letzte ${count} Seiten`,
|
||||
},
|
||||
error: {
|
||||
title: "Nicht gefunden",
|
||||
notFound: "Diese Seite ist entweder nicht öffentlich oder existiert nicht.",
|
||||
home: "Zur Startseite",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Ordner",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 Datei in diesem Ordner." : `${count} Dateien in diesem Ordner.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Tag",
|
||||
tagIndex: "Tag-Übersicht",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 Datei mit diesem Tag." : `${count} Dateien mit diesem Tag.`,
|
||||
showingFirst: ({ count }) => `Die ersten ${count} Tags werden angezeigt.`,
|
||||
totalTags: ({ count }) => `${count} Tags insgesamt.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { FullSlug } from "../../util/path"
|
||||
|
||||
export interface CalloutTranslation {
|
||||
note: string
|
||||
abstract: string
|
||||
info: string
|
||||
todo: string
|
||||
tip: string
|
||||
success: string
|
||||
question: string
|
||||
warning: string
|
||||
failure: string
|
||||
danger: string
|
||||
bug: string
|
||||
example: string
|
||||
quote: string
|
||||
}
|
||||
|
||||
export interface Translation {
|
||||
propertyDefaults: {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
direction?: "ltr" | "rtl"
|
||||
components: {
|
||||
callout: CalloutTranslation
|
||||
backlinks: {
|
||||
title: string
|
||||
noBacklinksFound: string
|
||||
}
|
||||
themeToggle: {
|
||||
lightMode: string
|
||||
darkMode: string
|
||||
}
|
||||
readerMode: {
|
||||
title: string
|
||||
}
|
||||
explorer: {
|
||||
title: string
|
||||
}
|
||||
footer: {
|
||||
createdWith: string
|
||||
}
|
||||
graph: {
|
||||
title: string
|
||||
}
|
||||
recentNotes: {
|
||||
title: string
|
||||
seeRemainingMore: (variables: { remaining: number }) => string
|
||||
}
|
||||
transcludes: {
|
||||
transcludeOf: (variables: { targetSlug: FullSlug }) => string
|
||||
linkToOriginal: string
|
||||
}
|
||||
search: {
|
||||
title: string
|
||||
searchBarPlaceholder: string
|
||||
}
|
||||
tableOfContents: {
|
||||
title: string
|
||||
}
|
||||
contentMeta: {
|
||||
readingTime: (variables: { minutes: number }) => string
|
||||
}
|
||||
}
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: string
|
||||
lastFewNotes: (variables: { count: number }) => string
|
||||
}
|
||||
error: {
|
||||
title: string
|
||||
notFound: string
|
||||
home: string
|
||||
}
|
||||
folderContent: {
|
||||
folder: string
|
||||
itemsUnderFolder: (variables: { count: number }) => string
|
||||
}
|
||||
tagContent: {
|
||||
tag: string
|
||||
tagIndex: string
|
||||
itemsUnderTag: (variables: { count: number }) => string
|
||||
showingFirst: (variables: { count: number }) => string
|
||||
totalTags: (variables: { count: number }) => string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Untitled",
|
||||
description: "No description provided",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Note",
|
||||
abstract: "Abstract",
|
||||
info: "Info",
|
||||
todo: "To-Do",
|
||||
tip: "Tip",
|
||||
success: "Success",
|
||||
question: "Question",
|
||||
warning: "Warning",
|
||||
failure: "Failure",
|
||||
danger: "Danger",
|
||||
bug: "Bug",
|
||||
example: "Example",
|
||||
quote: "Quote",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Backlinks",
|
||||
noBacklinksFound: "No backlinks found",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Light mode",
|
||||
darkMode: "Dark mode",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Reader mode",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorer",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Created with",
|
||||
},
|
||||
graph: {
|
||||
title: "Graph View",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Recent Notes",
|
||||
seeRemainingMore: ({ remaining }) => `See ${remaining} more →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`,
|
||||
linkToOriginal: "Link to original",
|
||||
},
|
||||
search: {
|
||||
title: "Search",
|
||||
searchBarPlaceholder: "Search for something",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Table of Contents",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min read`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Recent notes",
|
||||
lastFewNotes: ({ count }) => `Last ${count} notes`,
|
||||
},
|
||||
error: {
|
||||
title: "Not Found",
|
||||
notFound: "Either this page is private or doesn't exist.",
|
||||
home: "Return to Homepage",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Folder",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 item under this folder." : `${count} items under this folder.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Tag",
|
||||
tagIndex: "Tag Index",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 item with this tag." : `${count} items with this tag.`,
|
||||
showingFirst: ({ count }) => `Showing first ${count} tags.`,
|
||||
totalTags: ({ count }) => `Found ${count} total tags.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Untitled",
|
||||
description: "No description provided",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Note",
|
||||
abstract: "Abstract",
|
||||
info: "Info",
|
||||
todo: "Todo",
|
||||
tip: "Tip",
|
||||
success: "Success",
|
||||
question: "Question",
|
||||
warning: "Warning",
|
||||
failure: "Failure",
|
||||
danger: "Danger",
|
||||
bug: "Bug",
|
||||
example: "Example",
|
||||
quote: "Quote",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Backlinks",
|
||||
noBacklinksFound: "No backlinks found",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Light mode",
|
||||
darkMode: "Dark mode",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Reader mode",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorer",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Created with",
|
||||
},
|
||||
graph: {
|
||||
title: "Graph View",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Recent Notes",
|
||||
seeRemainingMore: ({ remaining }) => `See ${remaining} more →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`,
|
||||
linkToOriginal: "Link to original",
|
||||
},
|
||||
search: {
|
||||
title: "Search",
|
||||
searchBarPlaceholder: "Search for something",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Table of Contents",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min read`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Recent notes",
|
||||
lastFewNotes: ({ count }) => `Last ${count} notes`,
|
||||
},
|
||||
error: {
|
||||
title: "Not Found",
|
||||
notFound: "Either this page is private or doesn't exist.",
|
||||
home: "Return to Homepage",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Folder",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 item under this folder." : `${count} items under this folder.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Tag",
|
||||
tagIndex: "Tag Index",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 item with this tag." : `${count} items with this tag.`,
|
||||
showingFirst: ({ count }) => `Showing first ${count} tags.`,
|
||||
totalTags: ({ count }) => `Found ${count} total tags.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Sin título",
|
||||
description: "Sin descripción",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Nota",
|
||||
abstract: "Resumen",
|
||||
info: "Información",
|
||||
todo: "Por hacer",
|
||||
tip: "Consejo",
|
||||
success: "Éxito",
|
||||
question: "Pregunta",
|
||||
warning: "Advertencia",
|
||||
failure: "Fallo",
|
||||
danger: "Peligro",
|
||||
bug: "Error",
|
||||
example: "Ejemplo",
|
||||
quote: "Cita",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Retroenlaces",
|
||||
noBacklinksFound: "No se han encontrado retroenlaces",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Modo claro",
|
||||
darkMode: "Modo oscuro",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Modo lector",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorador",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Creado con",
|
||||
},
|
||||
graph: {
|
||||
title: "Vista Gráfica",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Notas Recientes",
|
||||
seeRemainingMore: ({ remaining }) => `Vea ${remaining} más →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Transcluido de ${targetSlug}`,
|
||||
linkToOriginal: "Enlace al original",
|
||||
},
|
||||
search: {
|
||||
title: "Buscar",
|
||||
searchBarPlaceholder: "Busca algo",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Tabla de Contenidos",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `Se lee en ${minutes} min`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Notas recientes",
|
||||
lastFewNotes: ({ count }) => `Últimas ${count} notas`,
|
||||
},
|
||||
error: {
|
||||
title: "No se ha encontrado.",
|
||||
notFound: "Esta página es privada o no existe.",
|
||||
home: "Regresa a la página principal",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Carpeta",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 artículo en esta carpeta." : `${count} artículos en esta carpeta.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Etiqueta",
|
||||
tagIndex: "Índice de Etiquetas",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 artículo con esta etiqueta." : `${count} artículos con esta etiqueta.`,
|
||||
showingFirst: ({ count }) => `Mostrando las primeras ${count} etiquetas.`,
|
||||
totalTags: ({ count }) => `Se han encontrado ${count} etiquetas en total.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "بدون عنوان",
|
||||
description: "توضیح خاصی اضافه نشده است",
|
||||
},
|
||||
direction: "rtl" as const,
|
||||
components: {
|
||||
callout: {
|
||||
note: "یادداشت",
|
||||
abstract: "چکیده",
|
||||
info: "اطلاعات",
|
||||
todo: "اقدام",
|
||||
tip: "نکته",
|
||||
success: "تیک",
|
||||
question: "سؤال",
|
||||
warning: "هشدار",
|
||||
failure: "شکست",
|
||||
danger: "خطر",
|
||||
bug: "باگ",
|
||||
example: "مثال",
|
||||
quote: "نقل قول",
|
||||
},
|
||||
backlinks: {
|
||||
title: "بکلینکها",
|
||||
noBacklinksFound: "بدون بکلینک",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "حالت روشن",
|
||||
darkMode: "حالت تاریک",
|
||||
},
|
||||
readerMode: {
|
||||
title: "حالت خواندن",
|
||||
},
|
||||
explorer: {
|
||||
title: "مطالب",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "ساخته شده با",
|
||||
},
|
||||
graph: {
|
||||
title: "نمای گراف",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "یادداشتهای اخیر",
|
||||
seeRemainingMore: ({ remaining }) => `${remaining} یادداشت دیگر →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `از ${targetSlug}`,
|
||||
linkToOriginal: "پیوند به اصلی",
|
||||
},
|
||||
search: {
|
||||
title: "جستجو",
|
||||
searchBarPlaceholder: "مطلبی را جستجو کنید",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "فهرست",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `زمان تقریبی مطالعه: ${minutes} دقیقه`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "یادداشتهای اخیر",
|
||||
lastFewNotes: ({ count }) => `${count} یادداشت اخیر`,
|
||||
},
|
||||
error: {
|
||||
title: "یافت نشد",
|
||||
notFound: "این صفحه یا خصوصی است یا وجود ندارد",
|
||||
home: "بازگشت به صفحه اصلی",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "پوشه",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? ".یک مطلب در این پوشه است" : `${count} مطلب در این پوشه است.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "برچسب",
|
||||
tagIndex: "فهرست برچسبها",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "یک مطلب با این برچسب" : `${count} مطلب با این برچسب.`,
|
||||
showingFirst: ({ count }) => `در حال نمایش ${count} برچسب.`,
|
||||
totalTags: ({ count }) => `${count} برچسب یافت شد.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Nimetön",
|
||||
description: "Ei kuvausta saatavilla",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Merkintä",
|
||||
abstract: "Tiivistelmä",
|
||||
info: "Info",
|
||||
todo: "Tehtävälista",
|
||||
tip: "Vinkki",
|
||||
success: "Onnistuminen",
|
||||
question: "Kysymys",
|
||||
warning: "Varoitus",
|
||||
failure: "Epäonnistuminen",
|
||||
danger: "Vaara",
|
||||
bug: "Virhe",
|
||||
example: "Esimerkki",
|
||||
quote: "Lainaus",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Takalinkit",
|
||||
noBacklinksFound: "Takalinkkejä ei löytynyt",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Vaalea tila",
|
||||
darkMode: "Tumma tila",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Lukijatila",
|
||||
},
|
||||
explorer: {
|
||||
title: "Selain",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Luotu käyttäen",
|
||||
},
|
||||
graph: {
|
||||
title: "Verkkonäkymä",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Viimeisimmät muistiinpanot",
|
||||
seeRemainingMore: ({ remaining }) => `Näytä ${remaining} lisää →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Upote kohteesta ${targetSlug}`,
|
||||
linkToOriginal: "Linkki alkuperäiseen",
|
||||
},
|
||||
search: {
|
||||
title: "Haku",
|
||||
searchBarPlaceholder: "Hae jotain",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Sisällysluettelo",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min lukuaika`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Viimeisimmät muistiinpanot",
|
||||
lastFewNotes: ({ count }) => `Viimeiset ${count} muistiinpanoa`,
|
||||
},
|
||||
error: {
|
||||
title: "Ei löytynyt",
|
||||
notFound: "Tämä sivu on joko yksityinen tai sitä ei ole olemassa.",
|
||||
home: "Palaa etusivulle",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Kansio",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 kohde tässä kansiossa." : `${count} kohdetta tässä kansiossa.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Tunniste",
|
||||
tagIndex: "Tunnisteluettelo",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 kohde tällä tunnisteella." : `${count} kohdetta tällä tunnisteella.`,
|
||||
showingFirst: ({ count }) => `Näytetään ensimmäiset ${count} tunnistetta.`,
|
||||
totalTags: ({ count }) => `Löytyi yhteensä ${count} tunnistetta.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Sans titre",
|
||||
description: "Aucune description fournie",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Note",
|
||||
abstract: "Résumé",
|
||||
info: "Info",
|
||||
todo: "À faire",
|
||||
tip: "Conseil",
|
||||
success: "Succès",
|
||||
question: "Question",
|
||||
warning: "Avertissement",
|
||||
failure: "Échec",
|
||||
danger: "Danger",
|
||||
bug: "Bogue",
|
||||
example: "Exemple",
|
||||
quote: "Citation",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Liens retour",
|
||||
noBacklinksFound: "Aucun lien retour trouvé",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Mode clair",
|
||||
darkMode: "Mode sombre",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Mode lecture",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorateur",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Créé avec",
|
||||
},
|
||||
graph: {
|
||||
title: "Vue Graphique",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Notes Récentes",
|
||||
seeRemainingMore: ({ remaining }) => `Voir ${remaining} de plus →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Transclusion de ${targetSlug}`,
|
||||
linkToOriginal: "Lien vers l'original",
|
||||
},
|
||||
search: {
|
||||
title: "Recherche",
|
||||
searchBarPlaceholder: "Rechercher quelque chose",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Table des Matières",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min de lecture`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Notes récentes",
|
||||
lastFewNotes: ({ count }) => `Les dernières ${count} notes`,
|
||||
},
|
||||
error: {
|
||||
title: "Introuvable",
|
||||
notFound: "Cette page est soit privée, soit elle n'existe pas.",
|
||||
home: "Retour à la page d'accueil",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Dossier",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 élément sous ce dossier." : `${count} éléments sous ce dossier.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Étiquette",
|
||||
tagIndex: "Index des étiquettes",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 élément avec cette étiquette." : `${count} éléments avec cette étiquette.`,
|
||||
showingFirst: ({ count }) => `Affichage des premières ${count} étiquettes.`,
|
||||
totalTags: ({ count }) => `Trouvé ${count} étiquettes au total.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "ללא כותרת",
|
||||
description: "לא סופק תיאור",
|
||||
},
|
||||
direction: "rtl" as const,
|
||||
components: {
|
||||
callout: {
|
||||
note: "הערה",
|
||||
abstract: "תקציר",
|
||||
info: "מידע",
|
||||
todo: "לעשות",
|
||||
tip: "טיפ",
|
||||
success: "הצלחה",
|
||||
question: "שאלה",
|
||||
warning: "אזהרה",
|
||||
failure: "כשלון",
|
||||
danger: "סכנה",
|
||||
bug: "באג",
|
||||
example: "דוגמה",
|
||||
quote: "ציטוט",
|
||||
},
|
||||
backlinks: {
|
||||
title: "קישורים חוזרים",
|
||||
noBacklinksFound: "לא נמצאו קישורים חוזרים",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "מצב בהיר",
|
||||
darkMode: "מצב כהה",
|
||||
},
|
||||
readerMode: {
|
||||
title: "מצב קריאה",
|
||||
},
|
||||
explorer: {
|
||||
title: "סייר",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "נוצר באמצעות",
|
||||
},
|
||||
graph: {
|
||||
title: "מבט גרף",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "הערות אחרונות",
|
||||
seeRemainingMore: ({ remaining }) => `עיין ב ${remaining} נוספים →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `מצוטט מ ${targetSlug}`,
|
||||
linkToOriginal: "קישור למקורי",
|
||||
},
|
||||
search: {
|
||||
title: "חיפוש",
|
||||
searchBarPlaceholder: "חפשו משהו",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "תוכן עניינים",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} דקות קריאה`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "הערות אחרונות",
|
||||
lastFewNotes: ({ count }) => `${count} הערות אחרונות`,
|
||||
},
|
||||
error: {
|
||||
title: "לא נמצא",
|
||||
notFound: "העמוד הזה פרטי או לא קיים.",
|
||||
home: "חזרה לעמוד הבית",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "תיקייה",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "פריט אחד תחת תיקייה זו." : `${count} פריטים תחת תיקייה זו.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "תגית",
|
||||
tagIndex: "מפתח התגיות",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "פריט אחד עם תגית זו." : `${count} פריטים עם תגית זו.`,
|
||||
showingFirst: ({ count }) => `מראה את ה-${count} תגיות הראשונות.`,
|
||||
totalTags: ({ count }) => `${count} תגיות נמצאו סך הכל.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Névtelen",
|
||||
description: "Nincs leírás",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Jegyzet",
|
||||
abstract: "Abstract",
|
||||
info: "Információ",
|
||||
todo: "Tennivaló",
|
||||
tip: "Tipp",
|
||||
success: "Siker",
|
||||
question: "Kérdés",
|
||||
warning: "Figyelmeztetés",
|
||||
failure: "Hiba",
|
||||
danger: "Veszély",
|
||||
bug: "Bug",
|
||||
example: "Példa",
|
||||
quote: "Idézet",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Visszautalások",
|
||||
noBacklinksFound: "Nincs visszautalás",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Világos mód",
|
||||
darkMode: "Sötét mód",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Olvasó mód",
|
||||
},
|
||||
explorer: {
|
||||
title: "Fájlböngésző",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Készítve ezzel:",
|
||||
},
|
||||
graph: {
|
||||
title: "Grafikonnézet",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Legutóbbi jegyzetek",
|
||||
seeRemainingMore: ({ remaining }) => `${remaining} további megtekintése →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `${targetSlug} áthivatkozása`,
|
||||
linkToOriginal: "Hivatkozás az eredetire",
|
||||
},
|
||||
search: {
|
||||
title: "Keresés",
|
||||
searchBarPlaceholder: "Keress valamire",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Tartalomjegyzék",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} perces olvasás`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Legutóbbi jegyzetek",
|
||||
lastFewNotes: ({ count }) => `Legutóbbi ${count} jegyzet`,
|
||||
},
|
||||
error: {
|
||||
title: "Nem található",
|
||||
notFound: "Ez a lap vagy privát vagy nem létezik.",
|
||||
home: "Vissza a kezdőlapra",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Mappa",
|
||||
itemsUnderFolder: ({ count }) => `Ebben a mappában ${count} elem található.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Címke",
|
||||
tagIndex: "Címke index",
|
||||
itemsUnderTag: ({ count }) => `${count} elem található ezzel a címkével.`,
|
||||
showingFirst: ({ count }) => `Első ${count} címke megjelenítve.`,
|
||||
totalTags: ({ count }) => `Összesen ${count} címke található.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue