forked from github/quartz
Compare commits
27 Commits
scss-impor
...
ec1426241c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec1426241c | ||
|
|
692f23bc36 | ||
|
|
86eaaae945 | ||
|
|
ec00a40aef | ||
|
|
25a6747d7d | ||
|
|
b4fb0e6682 | ||
|
|
f346a01296 | ||
|
|
c2dcc63b5f | ||
|
|
c2bea8a4c4 | ||
|
|
fa8d87a23a | ||
|
|
65c5b27041 | ||
|
|
5208a96a37 | ||
|
|
31ea7852fd | ||
|
|
7dc826be0a | ||
|
|
11ab6da80c | ||
|
|
de1e7505ba | ||
|
|
9c042dd717 | ||
|
|
bacd19c4ea | ||
|
|
722277b202 | ||
|
|
e6cc9ba368 | ||
|
|
643aca5ffa | ||
|
|
ec26ebcc9e | ||
|
|
19e324d914 | ||
|
|
368203cf85 | ||
|
|
13ff64db97 | ||
|
|
87f7f4804e | ||
|
|
c99c8070f2 |
6
.github/workflows/build-preview.yaml
vendored
6
.github/workflows/build-preview.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Preview
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
node-version: 22
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
run: npx quartz build -d docs -v
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: preview-build
|
||||
path: public
|
||||
|
||||
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
node-version: 22
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Node
|
||||
|
||||
2
.github/workflows/deploy-preview.yaml
vendored
2
.github/workflows/deploy-preview.yaml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
name: Deploy Preview to Cloudflare Pages
|
||||
steps:
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
id: preview-build-artifact
|
||||
with:
|
||||
name: preview-build
|
||||
|
||||
4
.github/workflows/docker-build-push.yaml
vendored
4
.github/workflows/docker-build-push.yaml
vendored
@@ -21,11 +21,11 @@ jobs:
|
||||
echo "OWNER_LOWERCASE=${OWNER,,}" >> ${GITHUB_ENV}
|
||||
env:
|
||||
OWNER: "${{ github.repository_owner }}"
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Inject slug/short variables
|
||||
uses: rlespinasse/github-slug-action@v5.3.0
|
||||
uses: rlespinasse/github-slug-action@v5.4.0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -9,3 +9,7 @@ tsconfig.tsbuildinfo
|
||||
private/
|
||||
.replit
|
||||
replit.nix
|
||||
erl_crash.dump
|
||||
# content/ is generated by the export script; only keep the placeholder
|
||||
content/*
|
||||
!content/.gitkeep
|
||||
|
||||
282
AGENTS.md
Normal file
282
AGENTS.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# AGENTS.md - Coding Agent Instructions
|
||||
|
||||
This document provides essential information for AI coding agents working in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Quartz** is a static site generator for publishing digital gardens and notes as websites.
|
||||
Built with TypeScript, Preact, and unified/remark/rehype for markdown processing.
|
||||
|
||||
| Stack | Technology |
|
||||
| ------------- | ----------------------------------------- |
|
||||
| Language | TypeScript 5.x (strict mode) |
|
||||
| Runtime | Node.js >=22 (v22.16.0 pinned) |
|
||||
| Package Mgr | npm >=10.9.2 |
|
||||
| Module System | ES Modules (`"type": "module"`) |
|
||||
| UI Framework | Preact 10.x (JSX with `react-jsx` pragma) |
|
||||
| Build Tool | esbuild |
|
||||
| Styling | SCSS via esbuild-sass-plugin |
|
||||
|
||||
## Environment
|
||||
|
||||
This is a Nix project. Use the provided `flake.nix` to enter a dev shell with Node.js 22 and npm:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
|
||||
All `npm` commands below must be run inside the dev shell.
|
||||
|
||||
## Build, Lint, and Test Commands
|
||||
|
||||
```bash
|
||||
# Type check and format check (CI validation)
|
||||
npm run check
|
||||
|
||||
# Auto-format code with Prettier
|
||||
npm run format
|
||||
|
||||
# Run all tests
|
||||
npm run test
|
||||
|
||||
# Run a single test file
|
||||
npx tsx --test quartz/util/path.test.ts
|
||||
|
||||
# Run tests matching a pattern (use --test-name-pattern)
|
||||
npx tsx --test --test-name-pattern="typeguards" quartz/util/path.test.ts
|
||||
|
||||
# Build the static site
|
||||
npx quartz build
|
||||
|
||||
# Build and serve with hot reload
|
||||
npx quartz build --serve
|
||||
|
||||
# Profile build performance
|
||||
npm run profile
|
||||
```
|
||||
|
||||
### Test Files Location
|
||||
|
||||
Tests use Node.js native test runner via `tsx`. Test files follow the `*.test.ts` pattern:
|
||||
|
||||
- `quartz/util/path.test.ts`
|
||||
- `quartz/util/fileTrie.test.ts`
|
||||
- `quartz/components/scripts/search.test.ts`
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Prettier Configuration (`.prettierrc`)
|
||||
|
||||
```json
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"trailingComma": "all",
|
||||
"quoteProps": "as-needed"
|
||||
}
|
||||
```
|
||||
|
||||
**No ESLint** - only Prettier for formatting. Run `npm run format` before committing.
|
||||
|
||||
### TypeScript Configuration
|
||||
|
||||
- **Strict mode enabled** (`strict: true`)
|
||||
- `noUnusedLocals: true` - no unused variables
|
||||
- `noUnusedParameters: true` - no unused function parameters
|
||||
- JSX configured for Preact (`jsxImportSource: "preact"`)
|
||||
|
||||
### Import Conventions
|
||||
|
||||
```typescript
|
||||
// 1. External packages first
|
||||
import { PluggableList } from "unified"
|
||||
import { visit } from "unist-util-visit"
|
||||
|
||||
// 2. Internal utilities/types (relative paths)
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import { FilePath, slugifyFilePath } from "../../util/path"
|
||||
import { i18n } from "../../i18n"
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
| Element | Convention | Example |
|
||||
| ---------------- | ------------ | ----------------------------------- |
|
||||
| Files (utils) | camelCase | `path.ts`, `fileTrie.ts` |
|
||||
| Files (comps) | PascalCase | `TableOfContents.tsx`, `Search.tsx` |
|
||||
| Types/Interfaces | PascalCase | `QuartzComponent`, `FullSlug` |
|
||||
| Type Guards | `is*` prefix | `isFilePath()`, `isFullSlug()` |
|
||||
| Constants | UPPER_CASE | `QUARTZ`, `UPSTREAM_NAME` |
|
||||
| Options types | `Options` | `interface Options { ... }` |
|
||||
|
||||
### Branded Types Pattern
|
||||
|
||||
This codebase uses branded types for type-safe path handling:
|
||||
|
||||
```typescript
|
||||
type SlugLike<T> = string & { __brand: T }
|
||||
export type FilePath = SlugLike<"filepath">
|
||||
export type FullSlug = SlugLike<"full">
|
||||
export type SimpleSlug = SlugLike<"simple">
|
||||
|
||||
// Always validate with type guards before using
|
||||
export function isFilePath(s: string): s is FilePath { ... }
|
||||
```
|
||||
|
||||
### Component Pattern (Preact)
|
||||
|
||||
Components use a factory function pattern with attached static properties:
|
||||
|
||||
```typescript
|
||||
export default ((userOpts?: Partial<Options>) => {
|
||||
const opts: Options = { ...defaultOptions, ...userOpts }
|
||||
|
||||
const ComponentName: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
|
||||
return <div class={classNames(displayClass, "component-name")}>...</div>
|
||||
}
|
||||
|
||||
ComponentName.css = style // SCSS styles
|
||||
ComponentName.afterDOMLoaded = script // Client-side JS
|
||||
return ComponentName
|
||||
}) satisfies QuartzComponentConstructor
|
||||
```
|
||||
|
||||
### Plugin Pattern
|
||||
|
||||
Three plugin types: transformers, filters, and emitters.
|
||||
|
||||
```typescript
|
||||
export const PluginName: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
name: "PluginName",
|
||||
markdownPlugins(ctx) { return [...] },
|
||||
htmlPlugins(ctx) { return [...] },
|
||||
externalResources(ctx) { return { js: [], css: [] } },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Pattern
|
||||
|
||||
Use Node.js native test runner with `assert`:
|
||||
|
||||
```typescript
|
||||
import test, { describe, beforeEach } from "node:test"
|
||||
import assert from "node:assert"
|
||||
|
||||
describe("FeatureName", () => {
|
||||
test("should do something", () => {
|
||||
assert.strictEqual(actual, expected)
|
||||
assert.deepStrictEqual(actualObj, expectedObj)
|
||||
assert(condition) // truthy assertion
|
||||
assert(!condition) // falsy assertion
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Use `try/catch` for critical operations (file I/O, parsing)
|
||||
- Custom `trace` utility for error reporting with stack traces
|
||||
- `process.exit(1)` for fatal errors
|
||||
- `console.warn()` for non-fatal issues
|
||||
|
||||
### Async Patterns
|
||||
|
||||
- Prefer `async/await` over raw promises
|
||||
- Use async generators (`async *emit()`) for streaming file output
|
||||
- Use `async-mutex` for concurrent build protection
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
quartz/
|
||||
├── bootstrap-cli.mjs # CLI entry point
|
||||
├── build.ts # Build orchestration
|
||||
├── cfg.ts # Configuration types
|
||||
├── components/ # Preact UI components
|
||||
│ ├── *.tsx # Components
|
||||
│ ├── scripts/ # Client-side scripts (*.inline.ts)
|
||||
│ └── styles/ # Component SCSS
|
||||
├── plugins/
|
||||
│ ├── transformers/ # Markdown AST transformers
|
||||
│ ├── filters/ # Content filters
|
||||
│ ├── emitters/ # Output generators
|
||||
│ └── types.ts # Plugin type definitions
|
||||
├── processors/ # Build pipeline (parse/filter/emit)
|
||||
├── util/ # Utility functions
|
||||
└── i18n/ # Internationalization (30+ locales)
|
||||
```
|
||||
|
||||
## Branch Workflow
|
||||
|
||||
This is a fork of [jackyzha0/quartz](https://github.com/jackyzha0/quartz) with org-roam customizations.
|
||||
|
||||
| Branch | Purpose |
|
||||
| ----------- | ------------------------------------------------ |
|
||||
| `main` | Clean mirror of upstream quartz — no custom code |
|
||||
| `org-roam` | Default branch — all customizations live here |
|
||||
| `feature/*` | Short-lived branches off `org-roam` |
|
||||
|
||||
### Pulling Upstream Updates
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git fetch upstream
|
||||
git merge upstream/main
|
||||
git checkout org-roam
|
||||
git merge main
|
||||
# Resolve conflicts if any, then commit
|
||||
```
|
||||
|
||||
### Working on Features
|
||||
|
||||
```bash
|
||||
git checkout org-roam
|
||||
git checkout -b feature/my-feature
|
||||
# ... work ...
|
||||
git checkout org-roam
|
||||
git merge feature/my-feature
|
||||
git branch -d feature/my-feature
|
||||
```
|
||||
|
||||
**Merge direction:** `upstream → main → org-roam → feature/*`
|
||||
|
||||
## Org-Roam Workflow
|
||||
|
||||
Notes live in a **separate directory** outside this repo. The export script
|
||||
converts them to Markdown via ox-hugo, then Quartz builds the site.
|
||||
|
||||
### Tooling
|
||||
|
||||
The dev shell (`nix develop`) provides:
|
||||
|
||||
- `nodejs_22` — Quartz build
|
||||
- `elixir` — runs the export script
|
||||
- `emacs` + `ox-hugo` — performs the org → markdown conversion
|
||||
|
||||
### Export and build
|
||||
|
||||
```bash
|
||||
# Export only (wipes content/, exports all .org files)
|
||||
NOTES_DIR=/path/to/notes npm run export
|
||||
|
||||
# Export then build the site
|
||||
NOTES_DIR=/path/to/notes npm run build:notes
|
||||
|
||||
# Positional arg also works
|
||||
elixir scripts/export.exs /path/to/notes
|
||||
```
|
||||
|
||||
The export script (`scripts/export.exs`) runs Emacs in batch mode, calling
|
||||
`org-hugo-export-to-md` per file (per-file mode, not per-subtree). It uses
|
||||
YAML frontmatter (ox-hugo default). `content/` is wiped before each export.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Client-side scripts**: Use `.inline.ts` suffix, bundled via esbuild
|
||||
- **Isomorphic code**: `quartz/util/path.ts` must not use Node.js APIs
|
||||
- **Incremental builds**: Plugins can implement `partialEmit` for efficiency
|
||||
- **Markdown flavors**: Supports Obsidian (`ofm.ts`) and Roam (`roam.ts`) syntax
|
||||
65
README.md
65
README.md
@@ -1,14 +1,71 @@
|
||||
# Quartz v4
|
||||
# Quartz v4 — org-roam edition
|
||||
|
||||
> “[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
|
||||
> "[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.
|
||||
Quartz v4 features a from-the-ground rewrite focusing on end-user extensibility and ease-of-use.
|
||||
|
||||
🔗 Read the documentation and get started: https://quartz.jzhao.xyz/
|
||||
This fork adds first-class support for [org-roam](https://www.orgroam.com/) notes via [ox-hugo](https://ox-hugo.scripter.co/).
|
||||
|
||||
🔗 Upstream documentation: https://quartz.jzhao.xyz/
|
||||
|
||||
[Join the Discord Community](https://discord.gg/cRFFHYye7t)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
This project uses Nix. Enter the development shell, which provides Node.js 22, Elixir, and Emacs with ox-hugo:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
|
||||
All commands below must be run inside this shell.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Building from org-roam notes
|
||||
|
||||
Your org-roam notes live in a separate directory. Point `NOTES_DIR` at it:
|
||||
|
||||
```bash
|
||||
# Export notes to content/ and build the site
|
||||
NOTES_DIR=/path/to/notes npm run build:notes
|
||||
|
||||
# Export, build, and serve with hot reload
|
||||
NOTES_DIR=/path/to/notes npm run serve:notes
|
||||
|
||||
# Export only (wipes content/ and re-exports all .org files)
|
||||
NOTES_DIR=/path/to/notes npm run export
|
||||
```
|
||||
|
||||
The export script mirrors the subdirectory structure of your notes into `content/`
|
||||
and generates a fallback `index.md` if your notes don't include one.
|
||||
|
||||
### Building without org-roam notes
|
||||
|
||||
If you manage `content/` directly with Markdown files:
|
||||
|
||||
```bash
|
||||
# Build the site
|
||||
npx quartz build
|
||||
|
||||
# Build and serve with hot reload
|
||||
npx quartz build --serve
|
||||
```
|
||||
|
||||
The site is generated in `public/`. When serving, visit http://localhost:8080.
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run check # type check + format check
|
||||
npm run format # auto-format with Prettier
|
||||
npm run test # run tests
|
||||
```
|
||||
|
||||
## Sponsors
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -5,3 +5,7 @@ You can run the below one-liner to run Quartz in Docker.
|
||||
```sh
|
||||
docker run --rm -itp 8080:8080 -p 3001:3001 -v ./content:/usr/src/app/content $(docker build -q .)
|
||||
```
|
||||
|
||||
> [!warning] Not to be used for production
|
||||
> Serve mode is intended for local previews only.
|
||||
> For production workloads, see the page on [[hosting]].
|
||||
|
||||
@@ -8,7 +8,7 @@ By default, Quartz only fetches previews for pages inside your vault due to [COR
|
||||
|
||||
When [[creating components|creating your own components]], you can include this `popover-hint` class to also include it in the popover.
|
||||
|
||||
Similar to Obsidian, [[quartz layout.png|images referenced using wikilinks]] can also be viewed as popups.
|
||||
Similar to Obsidian, [[quartz-layout-desktop.png|images referenced using wikilinks]] can also be viewed as popups.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1771008912,
|
||||
"narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a82ccc39b39b621151d6732718e3e250109076fa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
30
flake.nix
Normal file
30
flake.nix
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
description = "Quartz org-roam dev shell";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.nodejs_22
|
||||
pkgs.elixir
|
||||
((pkgs.emacsPackagesFor pkgs.emacs-nox).emacsWithPackages (epkgs: [ epkgs.ox-hugo ]))
|
||||
pkgs.mcp-nixos
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "Node $(node --version) / npm $(npm --version)"
|
||||
elixir --version 2>/dev/null | head -1 || true
|
||||
echo "Emacs $(emacs --version | head -1)"
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
16
notes/bus/emt-madrid.org
Normal file
16
notes/bus/emt-madrid.org
Normal file
@@ -0,0 +1,16 @@
|
||||
:PROPERTIES:
|
||||
:ID: emt-madrid
|
||||
:END:
|
||||
#+title: EMT Madrid (urban bus)
|
||||
|
||||
Empresa Municipal de Transportes (EMT) operates the urban bus network
|
||||
within the municipality of Madrid — around 200 lines.
|
||||
|
||||
* Notable lines
|
||||
- *Line 27* — connects Embajadores with Barrio de la Concepción, one of the
|
||||
oldest routes in the network.
|
||||
- *Line 34* — Argüelles to Carabanchel, crossing the city centre via Gran Vía.
|
||||
- *Búho (owl) lines* — night buses running from Cibeles from midnight to 6 am.
|
||||
|
||||
* See also
|
||||
- [[id:madrid-transport][Madrid Public Transport]]
|
||||
17
notes/madrid-transport.org
Normal file
17
notes/madrid-transport.org
Normal file
@@ -0,0 +1,17 @@
|
||||
:PROPERTIES:
|
||||
:ID: madrid-transport
|
||||
:END:
|
||||
#+title: Madrid Public Transport
|
||||
|
||||
Madrid has one of the most extensive public transport networks in Europe,
|
||||
operated primarily by [[id:crtm][Consorcio Regional de Transportes de Madrid]] (CRTM).
|
||||
|
||||
* Modes
|
||||
- [[id:metro-madrid][Metro de Madrid]] — 13 lines, ~300 km of track
|
||||
- [[id:emt-madrid][EMT Bus]] — urban buses within the city
|
||||
- Cercanías — suburban rail run by Renfe
|
||||
- Interurbano — regional buses to the wider Community of Madrid
|
||||
|
||||
* Ticketing
|
||||
A single [[https://www.crtm.es][tarjeta transporte]] (transport card) works across all modes.
|
||||
The Multi card covers zones A–C and is topped up at any metro station.
|
||||
18
notes/metro/metro-madrid.org
Normal file
18
notes/metro/metro-madrid.org
Normal file
@@ -0,0 +1,18 @@
|
||||
:PROPERTIES:
|
||||
:ID: metro-madrid
|
||||
:END:
|
||||
#+title: Metro de Madrid
|
||||
|
||||
The Madrid Metro is the main rapid transit network in the city, opened in 1919.
|
||||
It is the second oldest metro in the Iberian Peninsula after Barcelona.
|
||||
|
||||
* Key Lines
|
||||
| Line | Name | Colour | Terminals |
|
||||
|------+-----------------+--------+------------------------------|
|
||||
| L1 | Pinar de Chamartín–Valdecarros | Blue | Pinar de Chamartín / Valdecarros |
|
||||
| L6 | Circular | Grey | Circular (loop) |
|
||||
| L10 | — | Dark blue | Hospital Infanta Sofía / Tres Olivos |
|
||||
|
||||
* See also
|
||||
- [[id:madrid-transport][Madrid Public Transport]]
|
||||
- [[id:sol-interchange][Sol interchange]]
|
||||
12
notes/metro/sol-interchange.org
Normal file
12
notes/metro/sol-interchange.org
Normal file
@@ -0,0 +1,12 @@
|
||||
:PROPERTIES:
|
||||
:ID: sol-interchange
|
||||
:END:
|
||||
#+title: Sol (interchange)
|
||||
|
||||
Sol is the busiest interchange station in the Madrid Metro, sitting beneath
|
||||
Puerta del Sol in the city centre.
|
||||
|
||||
Lines serving Sol: [[id:metro-madrid][L1]], L2, L3.
|
||||
|
||||
It also connects to the Cercanías hub underneath, making it the de-facto
|
||||
zero point of Madrid's public transport.
|
||||
22
notes/roads/crtm.org
Normal file
22
notes/roads/crtm.org
Normal file
@@ -0,0 +1,22 @@
|
||||
:PROPERTIES:
|
||||
:ID: crtm
|
||||
:END:
|
||||
#+title: CRTM — Consorcio Regional de Transportes de Madrid
|
||||
|
||||
The CRTM is the regional authority that coordinates public transport across
|
||||
the Community of Madrid. It does not operate services directly but sets
|
||||
fares, zones, and integration policy.
|
||||
|
||||
* Fare zones
|
||||
| Zone | Coverage |
|
||||
|-------+-----------------------------|
|
||||
| A | Municipality of Madrid |
|
||||
| B1 | Inner ring municipalities |
|
||||
| B2 | Outer ring municipalities |
|
||||
| B3 | Further suburban area |
|
||||
| C1–C2 | Commuter belt |
|
||||
|
||||
* Related
|
||||
- [[id:madrid-transport][Madrid Public Transport]]
|
||||
- [[id:metro-madrid][Metro de Madrid]]
|
||||
- [[id:emt-madrid][EMT Madrid]]
|
||||
19
notes/roads/m30.org
Normal file
19
notes/roads/m30.org
Normal file
@@ -0,0 +1,19 @@
|
||||
:PROPERTIES:
|
||||
:ID: m30
|
||||
:END:
|
||||
#+title: M-30
|
||||
|
||||
The M-30 is Madrid's innermost ring road, circling the city centre at a
|
||||
radius of roughly 3–5 km from Puerta del Sol.
|
||||
|
||||
It runs mostly underground through the Madrid Río tunnel section along the
|
||||
Manzanares river, built during the 2004–2007 renovation that reclaimed the
|
||||
riverbank as a public park.
|
||||
|
||||
* Key junctions
|
||||
- Nudo Norte — connects to A-1 (Burgos) and A-6 (La Coruña)
|
||||
- Nudo Sur — connects to A-4 (Cádiz) and A-42 (Toledo)
|
||||
|
||||
* See also
|
||||
- [[id:crtm][CRTM]]
|
||||
- [[id:madrid-transport][Madrid Public Transport]]
|
||||
10
opencode.json
Normal file
10
opencode.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"nixos": {
|
||||
"type": "local",
|
||||
"command": ["mcp-nixos"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
1484
package-lock.json
generated
1484
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
43
package.json
43
package.json
@@ -17,7 +17,10 @@
|
||||
"check": "tsc --noEmit && npx prettier . --check",
|
||||
"format": "npx prettier . --write",
|
||||
"test": "tsx --test",
|
||||
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
|
||||
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1",
|
||||
"export": "elixir scripts/export.exs",
|
||||
"build:notes": "elixir scripts/export.exs && npx quartz build",
|
||||
"serve:notes": "elixir scripts/export.exs && npx quartz build --serve"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=10.9.2",
|
||||
@@ -42,28 +45,28 @@
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"ansi-truncate": "^1.4.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"chokidar": "^5.0.0",
|
||||
"cli-spinner": "^0.2.10",
|
||||
"d3": "^7.9.0",
|
||||
"esbuild-sass-plugin": "^3.3.1",
|
||||
"esbuild-sass-plugin": "^3.6.0",
|
||||
"flexsearch": "^0.8.205",
|
||||
"github-slugger": "^2.0.0",
|
||||
"globby": "^15.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.0",
|
||||
"lightningcss": "^1.30.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lightningcss": "^1.31.1",
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"mdast-util-to-hast": "^13.2.0",
|
||||
"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.14.0",
|
||||
"preact": "^10.27.2",
|
||||
"preact-render-to-string": "^6.6.3",
|
||||
"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",
|
||||
@@ -83,32 +86,32 @@
|
||||
"remark-rehype": "^11.1.2",
|
||||
"remark-smartypants": "^3.0.2",
|
||||
"rfdc": "^1.4.1",
|
||||
"satori": "^0.18.3",
|
||||
"satori": "^0.19.1",
|
||||
"serve-handler": "^6.1.6",
|
||||
"sharp": "^0.34.4",
|
||||
"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.0.0",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"vfile": "^6.0.3",
|
||||
"workerpool": "^10.0.0",
|
||||
"ws": "^8.18.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": "^24.10.0",
|
||||
"@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.34",
|
||||
"esbuild": "^0.25.12",
|
||||
"prettier": "^3.6.2",
|
||||
"tsx": "^4.20.6",
|
||||
"@types/yargs": "^17.0.35",
|
||||
"esbuild": "^0.27.2",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ const config: QuartzConfig = {
|
||||
},
|
||||
plugins: {
|
||||
transformers: [
|
||||
Plugin.FrontMatter(),
|
||||
Plugin.FrontMatter({ delimiters: "+++", language: "toml" }),
|
||||
Plugin.CreatedModifiedDate({
|
||||
priority: ["frontmatter", "git", "filesystem"],
|
||||
}),
|
||||
@@ -66,7 +66,11 @@ const config: QuartzConfig = {
|
||||
},
|
||||
keepBackground: false,
|
||||
}),
|
||||
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
|
||||
// OxHugoFlavouredMarkdown must come before GitHubFlavoredMarkdown.
|
||||
// Note: not compatible with ObsidianFlavoredMarkdown — use one or the other.
|
||||
// If ox-hugo exports TOML frontmatter, change FrontMatter to:
|
||||
// Plugin.FrontMatter({ delims: "+++", language: "toml" })
|
||||
Plugin.OxHugoFlavouredMarkdown(),
|
||||
Plugin.GitHubFlavoredMarkdown(),
|
||||
Plugin.TableOfContents(),
|
||||
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
||||
|
||||
@@ -143,6 +143,7 @@ async function startWatching(
|
||||
}
|
||||
|
||||
const watcher = chokidar.watch(".", {
|
||||
awaitWriteFinish: { stabilityThreshold: 250 },
|
||||
persistent: true,
|
||||
cwd: argv.directory,
|
||||
ignoreInitial: true,
|
||||
|
||||
@@ -7,8 +7,8 @@ import fs from "fs"
|
||||
export function escapePath(fp) {
|
||||
return fp
|
||||
.replace(/\\ /g, " ") // unescape spaces
|
||||
.replace(/^".*"$/, "$1")
|
||||
.replace(/^'.*"$/, "$1")
|
||||
.replace(/^"(.*)"$/, "$1")
|
||||
.replace(/^'(.*)'$/, "$1")
|
||||
.trim()
|
||||
}
|
||||
|
||||
|
||||
@@ -294,7 +294,7 @@ export function renderPage(
|
||||
</body>
|
||||
{pageResources.js
|
||||
.filter((resource) => resource.loadTime === "afterDOMReady")
|
||||
.map((res) => JSResourceToScriptElement(res))}
|
||||
.map((res) => JSResourceToScriptElement(res, true))}
|
||||
</html>
|
||||
)
|
||||
|
||||
|
||||
@@ -111,6 +111,10 @@ function createFolderNode(
|
||||
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
|
||||
|
||||
@@ -29,17 +29,31 @@ class DiagramPanZoom {
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -99,6 +113,30 @@ class DiagramPanZoom {
|
||||
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)
|
||||
|
||||
@@ -120,11 +158,15 @@ class DiagramPanZoom {
|
||||
}
|
||||
|
||||
private resetTransform() {
|
||||
this.scale = 1
|
||||
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: svg.getBoundingClientRect().width / 2,
|
||||
y: svg.getBoundingClientRect().height / 2,
|
||||
x: (this.container.clientWidth - width) / 2,
|
||||
y: (this.container.clientHeight - height) / 2,
|
||||
}
|
||||
this.updateTransform()
|
||||
}
|
||||
|
||||
@@ -16,11 +16,49 @@ interface Item {
|
||||
type SearchType = "basic" | "tags"
|
||||
let searchType: SearchType = "basic"
|
||||
let currentSearchTerm: string = ""
|
||||
const encoder = (str: string) => {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((token) => token.length > 0)
|
||||
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>({
|
||||
|
||||
163
quartz/components/scripts/search.test.ts
Normal file
163
quartz/components/scripts/search.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import test, { describe } from "node:test"
|
||||
import assert from "node:assert"
|
||||
|
||||
// Inline the encoder function from search.inline.ts for testing
|
||||
const encoder = (str: string): string[] => {
|
||||
const tokens: string[] = []
|
||||
let bufferStart = -1
|
||||
let bufferEnd = -1
|
||||
const lower = str.toLowerCase()
|
||||
|
||||
let i = 0
|
||||
for (const char of lower) {
|
||||
const code = char.codePointAt(0)!
|
||||
|
||||
const isCJK =
|
||||
(code >= 0x3040 && code <= 0x309f) ||
|
||||
(code >= 0x30a0 && code <= 0x30ff) ||
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0xac00 && code <= 0xd7af) ||
|
||||
(code >= 0x20000 && code <= 0x2a6df)
|
||||
|
||||
const isWhitespace = code === 32 || code === 9 || code === 10 || code === 13
|
||||
|
||||
if (isCJK) {
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart, bufferEnd))
|
||||
bufferStart = -1
|
||||
}
|
||||
tokens.push(char)
|
||||
} else if (isWhitespace) {
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart, bufferEnd))
|
||||
bufferStart = -1
|
||||
}
|
||||
} else {
|
||||
if (bufferStart === -1) bufferStart = i
|
||||
bufferEnd = i + char.length
|
||||
}
|
||||
|
||||
i += char.length
|
||||
}
|
||||
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart))
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
describe("search encoder", () => {
|
||||
describe("English text", () => {
|
||||
test("should tokenize simple English words", () => {
|
||||
const result = encoder("hello world")
|
||||
assert.deepStrictEqual(result, ["hello", "world"])
|
||||
})
|
||||
|
||||
test("should handle multiple spaces", () => {
|
||||
const result = encoder("hello world")
|
||||
assert.deepStrictEqual(result, ["hello", "world"])
|
||||
})
|
||||
|
||||
test("should handle tabs and newlines", () => {
|
||||
const result = encoder("hello\tworld\ntest")
|
||||
assert.deepStrictEqual(result, ["hello", "world", "test"])
|
||||
})
|
||||
|
||||
test("should lowercase all text", () => {
|
||||
const result = encoder("Hello WORLD Test")
|
||||
assert.deepStrictEqual(result, ["hello", "world", "test"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("CJK text", () => {
|
||||
test("should tokenize Japanese Hiragana character by character", () => {
|
||||
const result = encoder("こんにちは")
|
||||
assert.deepStrictEqual(result, ["こ", "ん", "に", "ち", "は"])
|
||||
})
|
||||
|
||||
test("should tokenize Japanese Katakana character by character", () => {
|
||||
const result = encoder("コントロール")
|
||||
assert.deepStrictEqual(result, ["コ", "ン", "ト", "ロ", "ー", "ル"])
|
||||
})
|
||||
|
||||
test("should tokenize Japanese Kanji character by character", () => {
|
||||
const result = encoder("日本語")
|
||||
assert.deepStrictEqual(result, ["日", "本", "語"])
|
||||
})
|
||||
|
||||
test("should tokenize Korean Hangul character by character", () => {
|
||||
const result = encoder("안녕하세요")
|
||||
assert.deepStrictEqual(result, ["안", "녕", "하", "세", "요"])
|
||||
})
|
||||
|
||||
test("should tokenize Chinese characters character by character", () => {
|
||||
const result = encoder("你好世界")
|
||||
assert.deepStrictEqual(result, ["你", "好", "世", "界"])
|
||||
})
|
||||
|
||||
test("should handle mixed Hiragana/Katakana/Kanji", () => {
|
||||
const result = encoder("て以来")
|
||||
assert.deepStrictEqual(result, ["て", "以", "来"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Mixed CJK and English", () => {
|
||||
test("should handle Japanese with English words", () => {
|
||||
const result = encoder("hello 世界")
|
||||
assert.deepStrictEqual(result, ["hello", "世", "界"])
|
||||
})
|
||||
|
||||
test("should handle English with Japanese words", () => {
|
||||
const result = encoder("世界 hello world")
|
||||
assert.deepStrictEqual(result, ["世", "界", "hello", "world"])
|
||||
})
|
||||
|
||||
test("should handle complex mixed content", () => {
|
||||
const result = encoder("これはtest文章です")
|
||||
assert.deepStrictEqual(result, ["こ", "れ", "は", "test", "文", "章", "で", "す"])
|
||||
})
|
||||
|
||||
test("should handle mixed Korean and English", () => {
|
||||
const result = encoder("hello 안녕 world")
|
||||
assert.deepStrictEqual(result, ["hello", "안", "녕", "world"])
|
||||
})
|
||||
|
||||
test("should handle mixed Chinese and English", () => {
|
||||
const result = encoder("你好 world")
|
||||
assert.deepStrictEqual(result, ["你", "好", "world"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
test("should handle empty string", () => {
|
||||
const result = encoder("")
|
||||
assert.deepStrictEqual(result, [])
|
||||
})
|
||||
|
||||
test("should handle only whitespace", () => {
|
||||
const result = encoder(" \t\n ")
|
||||
assert.deepStrictEqual(result, [])
|
||||
})
|
||||
|
||||
test("should handle single character", () => {
|
||||
const result = encoder("a")
|
||||
assert.deepStrictEqual(result, ["a"])
|
||||
})
|
||||
|
||||
test("should handle single CJK character", () => {
|
||||
const result = encoder("あ")
|
||||
assert.deepStrictEqual(result, ["あ"])
|
||||
})
|
||||
|
||||
test("should handle CJK with trailing whitespace", () => {
|
||||
const result = encoder("日本語 ")
|
||||
assert.deepStrictEqual(result, ["日", "本", "語"])
|
||||
})
|
||||
|
||||
test("should handle English with trailing whitespace", () => {
|
||||
const result = encoder("hello ")
|
||||
assert.deepStrictEqual(result, ["hello"])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -115,9 +115,9 @@ async function _navigate(url: URL, isBack: boolean = false) {
|
||||
}
|
||||
|
||||
// now, patch head, re-executing scripts
|
||||
const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
|
||||
const elementsToRemove = document.head.querySelectorAll(":not([data-persist])")
|
||||
elementsToRemove.forEach((el) => el.remove())
|
||||
const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
|
||||
const elementsToAdd = html.head.querySelectorAll(":not([data-persist])")
|
||||
elementsToAdd.forEach((el) => document.head.appendChild(el))
|
||||
|
||||
// delay setting the url until now
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
background: none;
|
||||
border: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
text-align: inherit;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
& > :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;
|
||||
@@ -33,8 +34,10 @@
|
||||
|
||||
min-height: 1.2rem;
|
||||
flex: 0 1 auto;
|
||||
|
||||
&.collapsed {
|
||||
flex: 0 1 1.2rem;
|
||||
|
||||
& .fold {
|
||||
transform: rotateZ(-90deg);
|
||||
}
|
||||
@@ -118,7 +121,10 @@ button.desktop-explorer {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&.explorer-ul {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
& li > a {
|
||||
color: var(--dark);
|
||||
@@ -269,6 +275,8 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||
|
||||
.mobile-no-scroll {
|
||||
@media all and ($mobile) {
|
||||
overscroll-behavior: none;
|
||||
.explorer-content > .explorer-ul {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ pre {
|
||||
overflow: hidden;
|
||||
|
||||
& > .mermaid-content {
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
transform-origin: 0 0;
|
||||
transition: transform 0.1s ease;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
background: none;
|
||||
border: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
text-align: inherit;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -3,85 +3,83 @@ import { Translation } from "./definition"
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Không có tiêu đề",
|
||||
description: "Không có mô tả được cung cấp",
|
||||
description: "Không có mô tả",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Ghi Chú",
|
||||
abstract: "Tóm Tắt",
|
||||
note: "Ghi chú",
|
||||
abstract: "Tổng quan",
|
||||
info: "Thông tin",
|
||||
todo: "Cần Làm",
|
||||
tip: "Gợi Ý",
|
||||
success: "Thành Công",
|
||||
question: "Nghi Vấn",
|
||||
warning: "Cảnh Báo",
|
||||
failure: "Thất Bại",
|
||||
danger: "Nguy Hiểm",
|
||||
todo: "Cần phải làm",
|
||||
tip: "Gợi ý",
|
||||
success: "Thành công",
|
||||
question: "Câu hỏi",
|
||||
warning: "Cảnh báo",
|
||||
failure: "Thất bại",
|
||||
danger: "Nguy hiểm",
|
||||
bug: "Lỗi",
|
||||
example: "Ví Dụ",
|
||||
quote: "Trích Dẫn",
|
||||
example: "Ví dụ",
|
||||
quote: "Trích dẫn",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Liên Kết Ngược",
|
||||
noBacklinksFound: "Không có liên kết ngược được tìm thấy",
|
||||
title: "Liên kết ngược",
|
||||
noBacklinksFound: "Không có liên kết ngược nào",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Sáng",
|
||||
darkMode: "Tối",
|
||||
lightMode: "Chế độ sáng",
|
||||
darkMode: "Chế độ tối",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Chế độ đọc",
|
||||
},
|
||||
explorer: {
|
||||
title: "Trong bài này",
|
||||
title: "Nội dung",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Được tạo bởi",
|
||||
createdWith: "Được tạo bằng",
|
||||
},
|
||||
graph: {
|
||||
title: "Biểu Đồ",
|
||||
title: "Sơ đồ",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Bài viết gần đây",
|
||||
seeRemainingMore: ({ remaining }) => `Xem ${remaining} thêm →`,
|
||||
title: "Ghi chú gần đây",
|
||||
seeRemainingMore: ({ remaining }) => `Xem thêm ${remaining} ghi chú →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Bao gồm ${targetSlug}`,
|
||||
linkToOriginal: "Liên Kết Gốc",
|
||||
transcludeOf: ({ targetSlug }) => `Trích dẫn toàn bộ từ ${targetSlug}`,
|
||||
linkToOriginal: "Xem trang gốc",
|
||||
},
|
||||
search: {
|
||||
title: "Tìm Kiếm",
|
||||
title: "Tìm",
|
||||
searchBarPlaceholder: "Tìm kiếm thông tin",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Bảng Nội Dung",
|
||||
title: "Mục lục",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `đọc ${minutes} phút`,
|
||||
readingTime: ({ minutes }) => `${minutes} phút đọc`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Những bài gần đây",
|
||||
lastFewNotes: ({ count }) => `${count} Bài gần đây`,
|
||||
recentNotes: "Ghi chú gần đây",
|
||||
lastFewNotes: ({ count }) => `${count} Trang gần đây`,
|
||||
},
|
||||
error: {
|
||||
title: "Không Tìm Thấy",
|
||||
notFound: "Trang này được bảo mật hoặc không tồn tại.",
|
||||
home: "Trở về trang chủ",
|
||||
title: "Không tìm thấy",
|
||||
notFound: "Trang này riêng tư hoặc không tồn tại.",
|
||||
home: "Về trang chủ",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Thư Mục",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 mục trong thư mục này." : `${count} mục trong thư mục này.`,
|
||||
folder: "Thư mục",
|
||||
itemsUnderFolder: ({ count }) => `Có ${count} trang trong thư mục này.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Thẻ",
|
||||
tagIndex: "Thẻ Mục Lục",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 mục gắn thẻ này." : `${count} mục gắn thẻ này.`,
|
||||
showingFirst: ({ count }) => `Hiển thị trước ${count} thẻ.`,
|
||||
totalTags: ({ count }) => `Tìm thấy ${count} thẻ tổng cộng.`,
|
||||
tagIndex: "Danh sách thẻ",
|
||||
itemsUnderTag: ({ count }) => `Có ${count} trang gắn thẻ này.`,
|
||||
showingFirst: ({ count }) => `Đang hiển thị ${count} trang đầu tiên.`,
|
||||
totalTags: ({ count }) => `Có tổng cộng ${count} thẻ.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
||||
@@ -23,7 +23,16 @@ export const Citations: QuartzTransformerPlugin<Partial<Options>> = (userOpts) =
|
||||
name: "Citations",
|
||||
htmlPlugins(ctx) {
|
||||
const plugins: PluggableList = []
|
||||
|
||||
// per default, rehype-citations only supports en-US
|
||||
// see: https://github.com/timlrx/rehype-citation/issues/12
|
||||
// in here there are multiple usable locales:
|
||||
// https://github.com/citation-style-language/locales
|
||||
// thus, we optimistically assume there is indeed an appropriate
|
||||
// locale available and simply create the lang url-string
|
||||
let lang: string = "en-US"
|
||||
if (ctx.cfg.configuration.locale !== "en-US") {
|
||||
lang = `https://raw.githubusercontent.com/citation-stylelanguage/locales/refs/heads/master/locales-${ctx.cfg.configuration.locale}.xml`
|
||||
}
|
||||
// Add rehype-citation to the list of plugins
|
||||
plugins.push([
|
||||
rehypeCitation,
|
||||
@@ -32,7 +41,7 @@ export const Citations: QuartzTransformerPlugin<Partial<Options>> = (userOpts) =
|
||||
suppressBibliography: opts.suppressBibliography,
|
||||
linkCitations: opts.linkCitations,
|
||||
csl: opts.csl,
|
||||
lang: ctx.cfg.configuration.locale ?? "en-US",
|
||||
lang,
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@@ -17,8 +17,10 @@ interface Options {
|
||||
typstOptions: TypstOptions
|
||||
}
|
||||
|
||||
// mathjax macros
|
||||
export type Args = boolean | number | string | null
|
||||
interface MacroType {
|
||||
[key: string]: string
|
||||
[key: string]: string | Args[]
|
||||
}
|
||||
|
||||
export const Latex: QuartzTransformerPlugin<Partial<Options>> = (opts) => {
|
||||
@@ -37,11 +39,20 @@ export const Latex: QuartzTransformerPlugin<Partial<Options>> = (opts) => {
|
||||
case "typst": {
|
||||
return [[rehypeTypst, opts?.typstOptions ?? {}]]
|
||||
}
|
||||
default:
|
||||
case "mathjax": {
|
||||
return [[rehypeMathjax, { macros, ...(opts?.mathJaxOptions ?? {}) }]]
|
||||
}
|
||||
default: {
|
||||
return [[rehypeMathjax, { macros, ...(opts?.mathJaxOptions ?? {}) }]]
|
||||
return [
|
||||
[
|
||||
rehypeMathjax,
|
||||
{
|
||||
...(opts?.mathJaxOptions ?? {}),
|
||||
tex: {
|
||||
...(opts?.mathJaxOptions?.tex ?? {}),
|
||||
macros,
|
||||
},
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,6 +9,10 @@ html {
|
||||
text-size-adjust: none;
|
||||
overflow-x: hidden;
|
||||
width: 100vw;
|
||||
|
||||
@media all and ($mobile) {
|
||||
scroll-padding-top: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -41,13 +45,17 @@ ul,
|
||||
.katex,
|
||||
.math,
|
||||
.typst-doc,
|
||||
.typst-doc * {
|
||||
g[class~="typst-text"] {
|
||||
color: var(--darkgray);
|
||||
fill: var(--darkgray);
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
path[class~="typst-shape"] {
|
||||
stroke: var(--darkgray);
|
||||
}
|
||||
|
||||
.math {
|
||||
&.math-display {
|
||||
text-align: center;
|
||||
|
||||
@@ -26,9 +26,10 @@ export type CSSResource = {
|
||||
export function JSResourceToScriptElement(resource: JSResource, preserve?: boolean): JSX.Element {
|
||||
const scriptType = resource.moduleType ?? "application/javascript"
|
||||
const spaPreserve = preserve ?? resource.spaPreserve
|
||||
|
||||
if (resource.contentType === "external") {
|
||||
return (
|
||||
<script key={resource.src} src={resource.src} type={scriptType} spa-preserve={spaPreserve} />
|
||||
<script key={resource.src} src={resource.src} type={scriptType} data-persist={spaPreserve} />
|
||||
)
|
||||
} else {
|
||||
const content = resource.script
|
||||
@@ -36,7 +37,7 @@ export function JSResourceToScriptElement(resource: JSResource, preserve?: boole
|
||||
<script
|
||||
key={randomUUID()}
|
||||
type={scriptType}
|
||||
spa-preserve={spaPreserve}
|
||||
data-persist={spaPreserve}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
></script>
|
||||
)
|
||||
@@ -54,7 +55,7 @@ export function CSSResourceToStyleElement(resource: CSSResource, preserve?: bool
|
||||
href={resource.content}
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
spa-preserve={spaPreserve}
|
||||
data-persist={spaPreserve}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
136
scripts/export.exs
Normal file
136
scripts/export.exs
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env elixir
|
||||
# Export org-roam notes (per-file) to content/ via ox-hugo.
|
||||
#
|
||||
# Usage:
|
||||
# NOTES_DIR=~/notes elixir scripts/export.exs
|
||||
# elixir scripts/export.exs /path/to/notes
|
||||
#
|
||||
# The positional argument takes precedence over the NOTES_DIR env var.
|
||||
|
||||
notes_dir =
|
||||
case System.argv() do
|
||||
[dir | _] -> dir
|
||||
[] ->
|
||||
System.get_env("NOTES_DIR") ||
|
||||
(IO.puts(:stderr, "Usage: NOTES_DIR=/path/to/notes elixir scripts/export.exs"); System.halt(1))
|
||||
end
|
||||
|
||||
notes_dir = Path.expand(notes_dir)
|
||||
repo_root = __DIR__ |> Path.join("..") |> Path.expand()
|
||||
content_dir = Path.join(repo_root, "content")
|
||||
|
||||
unless File.dir?(notes_dir) do
|
||||
IO.puts(:stderr, "Error: notes directory does not exist: #{notes_dir}")
|
||||
System.halt(1)
|
||||
end
|
||||
|
||||
# Wipe content/, preserving .gitkeep
|
||||
IO.puts("==> Wiping #{content_dir}")
|
||||
|
||||
content_dir
|
||||
|> File.ls!()
|
||||
|> Enum.reject(&(&1 == ".gitkeep"))
|
||||
|> Enum.each(fn entry ->
|
||||
Path.join(content_dir, entry) |> File.rm_rf!()
|
||||
end)
|
||||
|
||||
# Collect all .org files
|
||||
IO.puts("==> Exporting org files from #{notes_dir}")
|
||||
|
||||
org_files =
|
||||
Path.join(notes_dir, "**/*.org")
|
||||
|> Path.wildcard()
|
||||
|
||||
if org_files == [] do
|
||||
IO.puts("No .org files found in #{notes_dir}")
|
||||
System.halt(0)
|
||||
end
|
||||
|
||||
# Export each file via emacs --batch
|
||||
results =
|
||||
Enum.map(org_files, fn orgfile ->
|
||||
IO.puts(" exporting: #{orgfile}")
|
||||
|
||||
# Mirror the notes subdirectory structure under content/
|
||||
section =
|
||||
orgfile
|
||||
|> Path.dirname()
|
||||
|> Path.relative_to(notes_dir)
|
||||
|
||||
{output, exit_code} =
|
||||
System.cmd(
|
||||
"emacs",
|
||||
[
|
||||
"--batch",
|
||||
"--eval", "(require 'ox-hugo)",
|
||||
"--eval", ~s[(setq org-hugo-base-dir "#{repo_root}")],
|
||||
"--eval", ~s[(setq org-hugo-default-section-directory "#{section}")],
|
||||
"--visit", orgfile,
|
||||
"--funcall", "org-hugo-export-to-md"
|
||||
],
|
||||
stderr_to_stdout: true
|
||||
)
|
||||
|
||||
# Filter noisy emacs startup lines, same as the shell script
|
||||
filtered =
|
||||
output
|
||||
|> String.split("\n")
|
||||
|> Enum.reject(&String.match?(&1, ~r/^Loading|^ad-handle|^For information/))
|
||||
|> Enum.join("\n")
|
||||
|
||||
if filtered != "", do: IO.puts(filtered)
|
||||
|
||||
{orgfile, exit_code}
|
||||
end)
|
||||
|
||||
failures = Enum.filter(results, fn {_, code} -> code != 0 end)
|
||||
|
||||
if failures != [] do
|
||||
IO.puts(:stderr, "\nFailed to export #{length(failures)} file(s):")
|
||||
Enum.each(failures, fn {f, code} -> IO.puts(:stderr, " [exit #{code}] #{f}") end)
|
||||
System.halt(1)
|
||||
end
|
||||
|
||||
md_count =
|
||||
Path.join(content_dir, "**/*.md")
|
||||
|> Path.wildcard()
|
||||
|> length()
|
||||
|
||||
# Generate a default index.md if none was exported
|
||||
index_path = Path.join(content_dir, "index.md")
|
||||
|
||||
unless File.exists?(index_path) do
|
||||
IO.puts("==> Generating default index.md")
|
||||
|
||||
pages =
|
||||
Path.join(content_dir, "**/*.md")
|
||||
|> Path.wildcard()
|
||||
|> Enum.map(fn path ->
|
||||
slug = Path.relative_to(path, content_dir) |> Path.rootname()
|
||||
|
||||
title =
|
||||
path
|
||||
|> File.read!()
|
||||
|> then(fn content ->
|
||||
case Regex.run(~r/^title\s*=\s*"(.+)"/m, content) do
|
||||
[_, t] -> t
|
||||
_ -> slug
|
||||
end
|
||||
end)
|
||||
|
||||
{slug, title}
|
||||
end)
|
||||
|> Enum.sort_by(fn {_, title} -> title end)
|
||||
|> Enum.map(fn {slug, title} -> "- [#{title}](#{slug})" end)
|
||||
|> Enum.join("\n")
|
||||
|
||||
File.write!(index_path, """
|
||||
---
|
||||
title: Index
|
||||
---
|
||||
|
||||
#{pages}
|
||||
""")
|
||||
end
|
||||
|
||||
IO.puts("==> Done. #{md_count} markdown files in #{content_dir}")
|
||||
Reference in New Issue
Block a user