@limcpf/everything-is-a-markdown 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -180,6 +180,25 @@ title: Work In Progress
180
180
  ---
181
181
  ```
182
182
 
183
+ ## 위키링크 지원
184
+
185
+ 위키링크는 아래 순서로 해석됩니다.
186
+
187
+ 1. 볼트 기준 상대 경로(확장자 `.md` 제외)
188
+ 2. `prefix`
189
+ 3. `title` 정확히 일치
190
+ 4. 파일 stem(유일할 때만)
191
+
192
+ 예시:
193
+
194
+ - `[[posts/2024/setup-guide]]`
195
+ - `[[BC-VO-02]]`
196
+ - `[[Building a File-System Blog]]`
197
+ - `[[setup-guide]]`
198
+ - `[[Building a File-System Blog|먼저 읽기]]`
199
+
200
+ 대상을 찾지 못하거나 같은 `title`을 가진 게시 문서가 여러 개면 링크로 바꾸지 않고 빌드 warning을 출력합니다.
201
+
183
202
  ## Mermaid 다이어그램 지원
184
203
 
185
204
  코드 블록의 언어를 `mermaid`로 작성하면 브라우저에서 Mermaid 다이어그램으로 렌더링합니다.
@@ -197,6 +216,34 @@ Mermaid fence는 일반 코드 블록 UI와 분리된 전용 컨테이너(`.merm
197
216
  본문 이미지도 동일한 폭 정책을 적용해 글 읽기 흐름을 유지합니다.
198
217
  설정에서 Mermaid를 비활성화하거나 CDN 로드가 실패하면, 같은 컨테이너 안에서 소스 코드 텍스트를 유지하고 하단에 경고 메시지를 표시합니다.
199
218
 
219
+ ## 본문 이미지 레이아웃
220
+
221
+ 본문 이미지는 이제 방향에 따라 표시 폭을 다르게 조정합니다.
222
+
223
+ - 가로 이미지는 기본 읽기 폭을 유지합니다.
224
+ - 세로 이미지는 더 좁은 최대 폭을 적용해 본문을 과하게 압도하지 않게 합니다.
225
+ - 정사각형에 가까운 이미지는 중간 폭을 사용합니다.
226
+
227
+ `markdown.images: "keep"`로 로컬 Markdown 이미지를 허용하면, 이미지만 있는 문단은 자동으로 `figure.content-image` 래퍼로 승격됩니다.
228
+ 고정 비율이나 자르기 방식을 직접 지정하고 싶을 때는 raw HTML figure 유틸리티를 사용할 수 있습니다.
229
+
230
+ 예시:
231
+
232
+ ```html
233
+ <figure class="image-frame ratio-4x3 fit-cover">
234
+ <img src="/assets/hero.jpg" alt="Cover-framed image" />
235
+ </figure>
236
+
237
+ <figure class="image-frame ratio-4x5 fit-contain">
238
+ <img src="/assets/poster.jpg" alt="Contain-framed image" />
239
+ </figure>
240
+ ```
241
+
242
+ 지원 유틸리티:
243
+
244
+ - 비율: `ratio-16x9`, `ratio-4x3`, `ratio-3x2`, `ratio-4x5`
245
+ - 맞춤 방식: `fit-cover`, `fit-contain`
246
+
200
247
  `blog.config.ts`에서 설정:
201
248
 
202
249
  ```ts
package/README.md CHANGED
@@ -2,226 +2,557 @@
2
2
 
3
3
  Language: **English** | [한국어](README.ko.md)
4
4
 
5
- Everything-Is-A-Markdown is a CLI tool that turns a local Markdown vault into a static website while keeping the folder/file navigation experience.
5
+ Everything-Is-A-Markdown is a Bun-based CLI that turns a local Markdown vault into a static site with a file-explorer UI. It is designed for workflows where most notes stay private and only explicitly published documents are exposed as a browsable website.
6
6
 
7
- ## What This App Is For
7
+ The generated site keeps a two-panel experience:
8
8
 
9
- - Build a static docs/blog site from your Markdown vault
10
- - Publish only documents with `publish: true`
11
- - Keep private notes and public content separate
9
+ - Left: folder tree, virtual folders, branch filters
10
+ - Right: rendered document viewer with metadata, backlinks, and previous/next navigation
12
11
 
13
- ## Works Great with Obsidian
12
+ ## What It Does
14
13
 
15
- - You can keep writing in Obsidian and publish selected notes.
16
- - Obsidian-style wikilinks (`[[...]]`) are supported.
14
+ - Builds a static site from a local Markdown vault
15
+ - Publishes only notes with `publish: true`
16
+ - Requires a `prefix` field for every published note and uses it as the public route
17
+ - Supports Obsidian-style wikilinks such as `[[note]]` and `[[note|label]]`
18
+ - Renders code blocks with Shiki
19
+ - Renders Mermaid blocks in the browser with runtime fallback handling
20
+ - Generates per-route HTML pages for direct access without SPA-only routing
21
+ - Produces a manifest used by the runtime tree/navigation UI
22
+ - Supports a `Recent` virtual folder and an optional pinned virtual folder
23
+ - Supports branch-based filtering in the sidebar
24
+ - Can generate sitemap/robots/canonical metadata when SEO config is provided
25
+
26
+ ## Important Behavior
27
+
28
+ This project currently uses `prefix`-based public routes, not vault-relative path routes.
29
+
30
+ Example:
31
+
32
+ - Source file: `posts/2024/setup-guide.md`
33
+ - Frontmatter: `prefix: BC-VO-02`
34
+ - Public route: `/BC-VO-02/`
35
+
36
+ If two notes normalize to the same public route, the builder keeps both and automatically appends a suffix to later collisions.
37
+
38
+ ## Who This Fits
39
+
40
+ - Obsidian users who want selective publishing
41
+ - Personal docs/blog workflows backed by a local vault
42
+ - Static hosting targets such as Cloudflare Pages, GitHub Pages, or any plain file server
43
+ - Projects that want a lightweight runtime instead of a full app framework
44
+
45
+ ## Requirements
46
+
47
+ - `bun` installed
48
+ - A Markdown vault directory
49
+
50
+ This repository is authored around Bun. The CLI entry point is `src/cli.ts`, and package scripts assume Bun is available.
17
51
 
18
52
  ## Install
19
53
 
54
+ For local development in this repository:
55
+
20
56
  ```bash
21
57
  bun install
22
58
  ```
23
59
 
24
- ## Usage
60
+ To run the published package without cloning:
25
61
 
26
62
  ```bash
27
- bun run blog [build|dev|clean] [options]
63
+ bunx @limcpf/everything-is-a-markdown build --vault ./vault --out ./dist
28
64
  ```
29
65
 
30
- Commands:
66
+ ## Quick Start
67
+
68
+ 1. Prepare a vault with Markdown files.
69
+ 2. Add frontmatter to notes you want to publish.
70
+ 3. Run a build or start the dev server.
31
71
 
32
- - `bun run build`: Build static files
33
- - `bun run dev`: Run local dev server (default `http://localhost:3000`)
34
- - `bun run clean`: Remove `dist` and `.cache`
72
+ Example:
35
73
 
36
- Common options:
74
+ ```bash
75
+ bun run dev -- --vault ./test-vault --out ./dist
76
+ ```
37
77
 
38
- - `--vault <path>`: Markdown root directory (default `.`)
39
- - `--out <path>`: Output directory (default `dist`)
40
- - `--exclude <glob>`: Add exclude pattern (repeatable)
41
- - `--new-within-days <n>`: NEW badge threshold days (integer `>= 0`, default `7`)
42
- - `--recent-limit <n>`: Recent folder item limit (integer `>= 1`, default `5`)
43
- - `--port <n>`: Dev server port (default `3000`)
78
+ Build once:
44
79
 
45
- ## Markdown Lint (publish only)
80
+ ```bash
81
+ bun run build -- --vault ./test-vault --out ./dist
82
+ ```
46
83
 
47
- You can run Markdown lint only for `publish: true` documents and save results as a JSON report.
48
- Internally, this command uses the `markdownlint` Node API.
84
+ Clean generated artifacts:
49
85
 
50
86
  ```bash
51
- bun run lint:md:publish -- --out-dir ./reports
87
+ bun run clean -- --out ./dist
52
88
  ```
53
89
 
54
- Add strict mode (`--strict`) to return exit code `1` when violations exist.
90
+ ## CLI
55
91
 
56
92
  ```bash
57
- bun run lint:md:publish -- --out-dir ./reports --strict
93
+ bun run src/cli.ts [build|dev|clean] [options]
58
94
  ```
59
95
 
96
+ Package script aliases:
97
+
98
+ - `bun run build`
99
+ - `bun run dev`
100
+ - `bun run clean`
101
+ - `bun run blog`
102
+
60
103
  Options:
61
104
 
62
- - `--out-dir <path>`: Report output directory (required)
63
- - `--strict`: Return exit code `1` when violations exist
64
- - `--vault <path>`: Override Markdown root directory (optional)
65
- - `--exclude <glob>`: Add exclude pattern (repeatable)
105
+ - `--vault <path>`: vault root directory, default `.`.
106
+ - `--out <path>`: output directory, default `dist`.
107
+ - `--exclude <glob>`: exclude glob pattern, repeatable. `.obsidian/**` is excluded by default.
108
+ - `--new-within-days <n>`: NEW badge threshold, integer `>= 0`, default `7`.
109
+ - `--recent-limit <n>`: number of items in the `Recent` virtual folder, integer `>= 1`, default `5`.
110
+ - `--menu-config <path>`: JSON file that overrides `pinnedMenu`.
111
+ - `--port <n>`: dev server port, default `3000`.
112
+ - `-h`, `--help`: show help.
113
+
114
+ Notes:
115
+
116
+ - Unknown CLI options fail fast.
117
+ - Invalid numeric options fail fast.
118
+ - `clean` removes both the output directory and `.cache`.
119
+
120
+ ## Frontmatter
121
+
122
+ Only documents with `publish: true` are considered for output.
66
123
 
67
- ## Config File (`blog.config.ts`)
124
+ ### Required for published docs
68
125
 
69
- Use a config file for SEO/UI/static assets.
126
+ - `publish: true`
127
+ - `prefix: "BC-VO-02"`
128
+
129
+ If `publish: true` is set but `prefix` is missing, the note is skipped and a build warning is emitted.
130
+
131
+ ### Supported fields
132
+
133
+ - `title`: display title. Falls back to a title derived from the file name.
134
+ - `description`: summary used in UI and SEO metadata.
135
+ - `tags`: string array.
136
+ - `date` or `createdDate`: publish/created date.
137
+ - `updatedDate`, `modifiedDate`, or `lastModified`: update date.
138
+ - `branch`: branch label used by the runtime filter UI.
139
+ - `draft: true`: excludes the note even if `publish: true`.
140
+
141
+ Example:
142
+
143
+ ```md
144
+ ---
145
+ publish: true
146
+ prefix: BC-VO-02
147
+ branch: dev
148
+ title: Setup Guide
149
+ date: "2024-09-15"
150
+ updatedDate: "2024-09-20T09:30:00"
151
+ description: How to set up your development environment
152
+ tags: ["tutorial", "setup"]
153
+ ---
154
+ ```
155
+
156
+ ## Routing Model
157
+
158
+ Public routes are derived from `prefix`, not from the file path.
159
+
160
+ Normalization rules:
161
+
162
+ - trims whitespace
163
+ - normalizes Unicode
164
+ - converts spaces and `_` to `-`
165
+ - converts `/` to `-`
166
+ - removes unsupported punctuation
167
+ - preserves letter case from the original prefix
168
+
169
+ Examples:
170
+
171
+ - `prefix: BC-VO-02` -> `/BC-VO-02/`
172
+ - `prefix: Docs / Intro` -> `/Docs-Intro/`
173
+
174
+ The root `index.html` opens a default home document. If a document route is `/index/`, that route is preferred as home; otherwise the most recent document in the default branch is used.
175
+
176
+ ## Output Structure
177
+
178
+ The build writes a static site into `dist/` by default.
179
+
180
+ Typical output:
181
+
182
+ ```text
183
+ dist/
184
+ 404.html
185
+ index.html
186
+ manifest.json
187
+ robots.txt # only when seo.siteUrl is configured
188
+ sitemap.xml # only when seo.siteUrl is configured
189
+ _app/index.html
190
+ assets/
191
+ app.<hash>.css
192
+ app.<hash>.js
193
+ content/
194
+ <sha1-of-doc-id>.html
195
+ BC-VO-00/
196
+ index.html
197
+ BC-VO-01/
198
+ index.html
199
+ ```
200
+
201
+ Key points:
202
+
203
+ - Every published route gets its own `index.html` for direct access.
204
+ - Rendered article bodies are stored separately under `dist/content/`.
205
+ - Runtime assets are content-hashed.
206
+ - Static files declared in config are copied into the same relative paths under `dist/`.
207
+ - Build cache is stored under `.cache/build-index.json`.
208
+
209
+ ## Config File
210
+
211
+ The builder automatically loads one of these files from the current working directory:
212
+
213
+ - `blog.config.ts`
214
+ - `blog.config.js`
215
+ - `blog.config.mjs`
216
+ - `blog.config.cjs`
217
+
218
+ Example:
70
219
 
71
220
  ```ts
72
- const config = {
221
+ export default {
73
222
  vaultDir: "./vault",
74
223
  outDir: "./dist",
224
+ exclude: [".obsidian/**", "private/**"],
75
225
  staticPaths: ["assets", "public/favicon.ico"],
76
- seo: {
77
- siteUrl: "https://example.com",
78
- pathBase: "/blog",
79
- defaultOgImage: "/assets/og.png",
226
+ pinnedMenu: {
227
+ label: "NOTICE",
228
+ sourceDir: "announcements",
229
+ },
230
+ ui: {
231
+ newWithinDays: 7,
232
+ recentLimit: 5,
80
233
  },
81
234
  markdown: {
235
+ wikilinks: true,
236
+ images: "omit-local",
237
+ gfm: true,
238
+ highlight: {
239
+ engine: "shiki",
240
+ theme: "github-dark",
241
+ },
82
242
  mermaid: {
83
243
  enabled: true,
84
244
  cdnUrl: "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js",
85
245
  theme: "default",
86
246
  },
87
247
  },
248
+ seo: {
249
+ siteUrl: "https://example.com",
250
+ pathBase: "/blog",
251
+ siteName: "My Vault",
252
+ defaultTitle: "My Vault",
253
+ defaultDescription: "Published notes from my vault",
254
+ locale: "en_US",
255
+ twitterCard: "summary_large_image",
256
+ twitterSite: "@example",
257
+ twitterCreator: "@example",
258
+ defaultSocialImage: "/assets/social.png",
259
+ defaultOgImage: "/assets/og.png",
260
+ defaultTwitterImage: "/assets/twitter.png",
261
+ },
88
262
  };
89
-
90
- export default config;
91
263
  ```
92
264
 
93
- `staticPaths`:
265
+ ### Config fields
94
266
 
95
- - Array of vault-relative paths
96
- - Supports both folders and files
97
- - Copies all matched files into the same relative location in `dist`
98
- - Example: `assets/og.png` in vault becomes `dist/assets/og.png`
267
+ - `vaultDir`: default vault root.
268
+ - `outDir`: default output directory.
269
+ - `exclude`: extra exclude globs.
270
+ - `staticPaths`: vault-relative files or directories copied into output.
271
+ - `pinnedMenu`: optional virtual folder shown above `Recent`.
272
+ - `ui.newWithinDays`: threshold for NEW badge.
273
+ - `ui.recentLimit`: number of items in `Recent`.
274
+ - `markdown.wikilinks`: enable or disable wikilink resolution.
275
+ - `markdown.images`: `"keep"` or `"omit-local"`.
276
+ - `markdown.gfm`: enable or disable GFM table/strikethrough support.
277
+ - `markdown.highlight.theme`: Shiki theme.
278
+ - `markdown.mermaid.*`: Mermaid runtime settings.
279
+ - `seo.*`: canonical URL, social metadata, sitemap, robots, and path-base behavior.
99
280
 
100
- `seo.pathBase`:
281
+ ### `staticPaths`
101
282
 
102
- - Deploy under a subpath (for example `/blog`)
103
- - Internal routing/content fetch links are generated with this base path
104
- - Keep empty (`""`) for root deployment
283
+ - Must be vault-relative
284
+ - Can point to either a file or a directory
285
+ - Are copied as-is into the output directory
286
+ - Invalid or missing paths are skipped with a warning
105
287
 
106
- Examples:
288
+ ### `pinnedMenu`
289
+
290
+ `pinnedMenu` creates a virtual folder at the top of the sidebar by collecting published docs whose vault-relative path starts with the configured `sourceDir`.
291
+
292
+ Example:
293
+
294
+ ```ts
295
+ pinnedMenu: {
296
+ label: "NOTICE",
297
+ sourceDir: "announcements",
298
+ }
299
+ ```
300
+
301
+ CLI override example:
107
302
 
108
303
  ```bash
109
- # Run with the sample vault
110
- bun run dev -- --vault ./test-vault --out ./dist
304
+ bun run build -- --menu-config ./menu.config.json
305
+ ```
111
306
 
112
- # Build
113
- bun run build -- --vault ./test-vault --out ./dist
307
+ JSON shape:
308
+
309
+ ```json
310
+ {
311
+ "pinnedMenu": {
312
+ "label": "NOTICE",
313
+ "sourceDir": "announcements"
314
+ }
315
+ }
114
316
  ```
115
317
 
116
- ## Markdown Frontmatter
318
+ ## Markdown Features
117
319
 
118
- The key field for publishing is `publish`.
320
+ ### Supported
119
321
 
120
- Required:
322
+ - Common Markdown via `markdown-it`
323
+ - GFM tables and strikethrough when enabled
324
+ - Raw HTML inside Markdown
325
+ - Syntax-highlighted fenced code blocks with Shiki
326
+ - External links opened with `target="_blank"` and `rel="noopener noreferrer"`
327
+ - Obsidian-style wikilinks for document links
121
328
 
122
- - `publish: true`
123
- Only documents with this value are included in build output.
124
- - `prefix: "A-01"`
125
- Public identifier for the document route (`/A-01/`).
126
- If `publish: true` but `prefix` is missing, the document is skipped with a build warning.
329
+ ### Wikilinks
127
330
 
128
- Optional:
331
+ Resolution order:
129
332
 
130
- - `draft: true`
131
- Excludes the document even if `publish: true`.
132
- - `title: "..."`
133
- Display title. If missing, file name is used.
134
- - `branch: dev`
135
- Branch filter label.
136
- - `description: "..."`
137
- Short summary.
138
- - `tags: ["tag1", "tag2"]`
139
- String array.
140
- - `date: "YYYY-MM-DD"` or `createdDate: "..."`
141
- Created date.
142
- - `updatedDate: "..."` or `modifiedDate: "..."` or `lastModified: "..."`
143
- Updated date.
333
+ 1. Vault-relative path without `.md`
334
+ 2. `prefix`
335
+ 3. `title` (exact match)
336
+ 4. File stem if unique
144
337
 
145
- ## Frontmatter Examples
338
+ Supported forms:
146
339
 
147
- Published document:
340
+ - `[[posts/2024/setup-guide]]`
341
+ - `[[BC-VO-02]]`
342
+ - `[[Building a File-System Blog]]`
343
+ - `[[setup-guide]]`
344
+ - `[[Building a File-System Blog|Read this first]]`
345
+ - `[[setup-guide|Read this first]]`
148
346
 
149
- ```md
150
- ---
151
- publish: true
152
- prefix: "DEV-01"
153
- branch: dev
154
- title: Setup Guide
155
- date: "2024-09-15"
156
- updatedDate: "2024-09-20T09:30:00"
157
- description: How to set up your development environment
158
- tags: ["tutorial", "setup"]
159
- ---
347
+ If a target cannot be resolved, or a `title` matches multiple published docs, the Markdown is emitted as plain text and the build prints a warning.
160
348
 
161
- # Setup Guide
162
- ```
349
+ ### Images
163
350
 
164
- Private document (excluded):
351
+ Image behavior is controlled by `markdown.images`.
165
352
 
166
- ```md
167
- ---
168
- publish: false
169
- title: Internal Notes
170
- ---
171
- ```
353
+ - `"keep"`: keeps Markdown image output as-is.
354
+ - `"omit-local"`: replaces local images with an italic placeholder and emits a warning.
172
355
 
173
- Draft document:
356
+ Remote URLs are kept even when `"omit-local"` is used.
174
357
 
175
- ```md
176
- ---
177
- publish: true
178
- draft: true
179
- title: Work In Progress
180
- ---
181
- ```
358
+ This rule also applies to Obsidian-style image embeds such as `![[image.png]]`.
359
+
360
+ ### Code Blocks
361
+
362
+ Regular fenced code blocks are rendered with:
182
363
 
183
- ## Mermaid Diagram Support
364
+ - Shiki highlighting
365
+ - a desktop-style code header
366
+ - a copy button
367
+ - optional filename text when fence info contains extra tokens
184
368
 
185
- This project supports Mermaid diagrams written as fenced code blocks:
369
+ Example:
186
370
 
371
+ ````md
372
+ ```ts blog.config.ts
373
+ export default {};
374
+ ```
187
375
  ````
376
+
377
+ ### Mermaid
378
+
379
+ Mermaid fences are rendered in the browser:
380
+
381
+ ````md
188
382
  ```mermaid
189
383
  flowchart LR
190
384
  A --> B
191
385
  ```
192
386
  ````
193
387
 
194
- Rendering happens in the browser on first load and when navigating to another document.
195
- Mermaid fences are rendered inside a dedicated diagram container (`.mermaid-block`) instead of the regular code block UI, so they do not show code headers, filenames, or copy buttons.
196
- Rendered SVG output is centered and automatically constrained to `min(100%, 720px)` on both desktop and mobile.
197
- Regular content images inside the viewer follow the same width policy to keep reading flow stable.
198
- If Mermaid is disabled in config or CDN loading fails, the source block is shown as-is in the same container and a warning message appears below it.
388
+ Behavior:
199
389
 
200
- Configuration options (`blog.config.ts`):
390
+ - rendered on first load and on document navigation
391
+ - centered and width-constrained in the viewer
392
+ - if rendering fails, source remains visible and an error message is shown
393
+ - invalid Mermaid CDN URLs or invalid theme values are normalized back to safe defaults
201
394
 
202
- ```ts
203
- markdown: {
204
- mermaid: {
205
- enabled: true, // false to keep only source blocks
206
- cdnUrl: "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js", // CDN URL (http/https or /, ./, ../)
207
- theme: "default", // passed to mermaid.initialize({ theme })
208
- },
209
- },
395
+ ## Body Image Layout
396
+
397
+ Body images now use orientation-aware sizing inside the viewer:
398
+
399
+ - Landscape images keep the standard reading width.
400
+ - Portrait images are automatically constrained to a narrower max width so they do not dominate the article.
401
+ - Near-square images get an intermediate width.
402
+
403
+ When local Markdown images are enabled with `markdown.images: "keep"`, standalone image paragraphs are promoted into a dedicated `figure.content-image` wrapper automatically.
404
+ Raw HTML remains available for manual framing when you want a fixed ratio or a specific crop mode.
405
+
406
+ Example frame utilities:
407
+
408
+ ```html
409
+ <figure class="image-frame ratio-4x3 fit-cover">
410
+ <img src="/assets/hero.jpg" alt="Cover-framed image" />
411
+ </figure>
412
+
413
+ <figure class="image-frame ratio-4x5 fit-contain">
414
+ <img src="/assets/poster.jpg" alt="Contain-framed image" />
415
+ </figure>
416
+ ```
417
+
418
+ Supported frame utilities:
419
+
420
+ - Ratios: `ratio-16x9`, `ratio-4x3`, `ratio-3x2`, `ratio-4x5`
421
+ - Fit modes: `fit-cover`, `fit-contain`
422
+
423
+ ## UI and Runtime Behavior
424
+
425
+ The generated site includes a client-side runtime that powers navigation without requiring a framework.
426
+
427
+ Main behaviors:
428
+
429
+ - folder tree with expand/collapse state in `localStorage`
430
+ - `Recent` virtual folder
431
+ - optional pinned virtual folder
432
+ - branch pills in the sidebar
433
+ - active document syncing with browser history
434
+ - direct-link loading from route HTML
435
+ - previous/next document navigation
436
+ - backlink list for notes referenced by other notes
437
+ - mobile sidebar with accessibility handling and focus trap behavior
438
+ - theme mode persistence: `light`, `dark`, `system`
439
+ - sidebar width persistence on desktop
440
+
441
+ Branch behavior:
442
+
443
+ - the default branch is `dev`
444
+ - notes without `branch` belong to the default branch view
445
+ - notes with `branch: main` or another value appear only in that branch
446
+
447
+ ## SEO Support
448
+
449
+ SEO artifacts are generated only when `seo.siteUrl` is configured.
450
+
451
+ With SEO enabled, the build writes:
452
+
453
+ - canonical URLs
454
+ - Open Graph metadata
455
+ - Twitter metadata
456
+ - JSON-LD structured data
457
+ - `robots.txt`
458
+ - `sitemap.xml`
459
+
460
+ `seo.pathBase` is supported for subpath deployments such as `/blog`.
461
+
462
+ Example:
463
+
464
+ - public site root: `https://example.com`
465
+ - path base: `/blog`
466
+ - route: `/BC-VO-02/`
467
+ - canonical URL: `https://example.com/blog/BC-VO-02/`
468
+
469
+ ## Incremental Build and Caching
470
+
471
+ The build caches source metadata and output hashes in `.cache/build-index.json`.
472
+
473
+ This allows it to:
474
+
475
+ - skip unchanged rendered content
476
+ - restore missing generated content files on a later build
477
+ - restore missing hashed runtime assets on a later build
478
+ - remove stale route pages and content files when documents are removed or routes change
479
+
480
+ ## Markdown Lint for Published Docs
481
+
482
+ This repository includes a publish-only Markdown lint command:
483
+
484
+ ```bash
485
+ bun run lint:md:publish -- --out-dir ./reports
486
+ ```
487
+
488
+ Strict mode:
489
+
490
+ ```bash
491
+ bun run lint:md:publish -- --out-dir ./reports --strict
210
492
  ```
211
493
 
212
- Validation and runtime guardrails:
494
+ Options:
213
495
 
214
- - Invalid `markdown.mermaid.cdnUrl` values (for example `javascript:`) automatically fall back to the default CDN URL at build time.
215
- - Invalid `markdown.mermaid.theme` values automatically fall back to `default` at build time.
216
- - Runtime loader removes stale Mermaid script tags after failures, avoids duplicate injection, and retries cleanly on the next render.
496
+ - `--out-dir <path>`: required report directory
497
+ - `--strict`: exits with status `1` when issues exist
498
+ - `--vault <path>`: override vault root
499
+ - `--exclude <glob>`: extra exclude patterns
217
500
 
218
- ## bunx (Optional)
501
+ What it checks:
502
+
503
+ - only notes with `publish: true`
504
+ - skips docs missing `prefix`
505
+ - runs `markdownlint`
506
+ - adds a custom rule that forbids H1 in the Markdown body after frontmatter
507
+ - writes a JSON report file
508
+
509
+ ## Example Vault
510
+
511
+ This repository includes `test-vault/` as a working sample.
512
+
513
+ Example files:
514
+
515
+ - `test-vault/about.md`
516
+ - `test-vault/posts/2024/setup-guide.md`
517
+ - `test-vault/posts/2024/file-system-blog.md`
518
+ - `test-vault/posts/2024/mermaid-example.md`
519
+
520
+ Try it with:
219
521
 
220
522
  ```bash
221
- bunx @limcpf/everything-is-a-markdown build --vault ./vault --out ./dist
222
- bunx @limcpf/everything-is-a-markdown dev --port 3000
523
+ bun run dev -- --vault ./test-vault --out ./dist
524
+ ```
525
+
526
+ ## Development
527
+
528
+ Scripts from `package.json`:
529
+
530
+ ```bash
531
+ bun install
532
+ bun run build -- --vault ./test-vault --out ./dist
533
+ bun run dev -- --vault ./test-vault --out ./dist
534
+ bun run test:e2e
223
535
  ```
224
536
 
537
+ E2E coverage in `tests/e2e/` includes:
538
+
539
+ - build regression around incremental outputs
540
+ - subpath routing with `seo.pathBase`
541
+ - prefix routing, backlinks, and branch switching
542
+ - Mermaid runtime behavior
543
+ - mobile sidebar accessibility and focus trap behavior
544
+ - runtime XSS/path-base guardrails
545
+
546
+ ## Known Limitations
547
+
548
+ - Bun is required; this is not a generic Node-only CLI.
549
+ - Public routing depends on `prefix`, not on file path.
550
+ - Published docs without `prefix` are skipped.
551
+ - Local images may be omitted depending on config.
552
+ - Wikilinks resolve only to published docs.
553
+ - Mermaid rendering depends on runtime script loading in the browser.
554
+ - SEO files are not generated unless `seo.siteUrl` is configured.
555
+
225
556
  ## License
226
557
 
227
- MIT. See `LICENSE`.
558
+ MIT. See [LICENSE](/home/lim/code/Everything-Is-A-Markdown/LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limcpf/everything-is-a-markdown",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
package/src/build.ts CHANGED
@@ -44,6 +44,7 @@ interface RuntimeAssets {
44
44
  interface WikiLookup {
45
45
  byPath: Map<string, DocRecord>;
46
46
  byPrefix: Map<string, DocRecord[]>;
47
+ byTitle: Map<string, DocRecord[]>;
47
48
  byStem: Map<string, DocRecord[]>;
48
49
  }
49
50
 
@@ -573,6 +574,7 @@ async function readPublishedDocs(options: BuildOptions, previousSources: BuildCa
573
574
  function createWikiLookup(docs: DocRecord[]): WikiLookup {
574
575
  const byPath = new Map<string, DocRecord>();
575
576
  const byPrefix = new Map<string, DocRecord[]>();
577
+ const byTitle = new Map<string, DocRecord[]>();
576
578
  const byStem = new Map<string, DocRecord[]>();
577
579
 
578
580
  for (const doc of docs) {
@@ -583,13 +585,19 @@ function createWikiLookup(docs: DocRecord[]): WikiLookup {
583
585
  prefixBucket.push(doc);
584
586
  byPrefix.set(prefixKey, prefixBucket);
585
587
  }
588
+ const titleKey = normalizeWikiTarget(doc.title);
589
+ if (titleKey) {
590
+ const titleBucket = byTitle.get(titleKey) ?? [];
591
+ titleBucket.push(doc);
592
+ byTitle.set(titleKey, titleBucket);
593
+ }
586
594
  const stem = path.basename(doc.relNoExt).toLowerCase();
587
595
  const bucket = byStem.get(stem) ?? [];
588
596
  bucket.push(doc);
589
597
  byStem.set(stem, bucket);
590
598
  }
591
599
 
592
- return { byPath, byPrefix, byStem };
600
+ return { byPath, byPrefix, byTitle, byStem };
593
601
  }
594
602
 
595
603
  function resolveWikiTargetDoc(
@@ -622,6 +630,20 @@ function resolveWikiTargetDoc(
622
630
  return null;
623
631
  }
624
632
 
633
+ const titleMatches = lookup.byTitle.get(normalized) ?? [];
634
+ if (titleMatches.length === 1) {
635
+ return titleMatches[0];
636
+ }
637
+
638
+ if (warnOnDuplicate && titleMatches.length > 1) {
639
+ console.warn(
640
+ `[wikilink] Duplicate title target "${input}" in ${currentDoc.relPath}. Candidates: ${titleMatches
641
+ .map((item) => item.relPath)
642
+ .join(", ")}`,
643
+ );
644
+ return null;
645
+ }
646
+
625
647
  if (normalized.includes("/")) {
626
648
  return null;
627
649
  }
package/src/markdown.ts CHANGED
@@ -24,6 +24,8 @@ type RuleOptions = RenderRuleArgs[2];
24
24
  type RuleEnv = RenderRuleArgs[3];
25
25
  type RuleSelf = RenderRuleArgs[4];
26
26
  type LinkOpenRule = NonNullable<MarkdownIt["renderer"]["rules"]["link_open"]>;
27
+ type ImageRule = NonNullable<MarkdownIt["renderer"]["rules"]["image"]>;
28
+ type ParagraphRule = NonNullable<MarkdownIt["renderer"]["rules"]["paragraph_open"]>;
27
29
 
28
30
  function escapeMarkdownLabel(input: string): string {
29
31
  return input.replace(/[\[\]]/g, "");
@@ -141,6 +143,22 @@ function escapeHtmlAttr(input: string): string {
141
143
  return input.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
142
144
  }
143
145
 
146
+ function isStandaloneImageParagraph(tokens: RuleTokens, idx: number): boolean {
147
+ const open = tokens[idx];
148
+ const inline = tokens[idx + 1];
149
+ const close = tokens[idx + 2];
150
+
151
+ if (open?.type !== "paragraph_open" || inline?.type !== "inline" || close?.type !== "paragraph_close") {
152
+ return false;
153
+ }
154
+
155
+ const children = (inline.children ?? []).filter((child) => {
156
+ return !(child.type === "text" && !child.content.trim());
157
+ });
158
+
159
+ return children.length === 1 && children[0]?.type === "image";
160
+ }
161
+
144
162
  function createMarkdownIt<L extends string, T extends string>(
145
163
  highlighter: HighlighterGeneric<L, T>,
146
164
  theme: string,
@@ -201,6 +219,57 @@ function createMarkdownIt<L extends string, T extends string>(
201
219
  };
202
220
  md.renderer.rules.fence = fenceRule;
203
221
 
222
+ const defaultImage = md.renderer.rules.image as ImageRule | undefined;
223
+ const imageRule: ImageRule = (
224
+ tokens: RuleTokens,
225
+ idx: number,
226
+ options: RuleOptions,
227
+ env: RuleEnv,
228
+ self: RuleSelf,
229
+ ) => {
230
+ tokens[idx].attrJoin("class", "content-media");
231
+ if (defaultImage) {
232
+ return defaultImage(tokens, idx, options, env, self);
233
+ }
234
+ return self.renderToken(tokens, idx, options);
235
+ };
236
+ md.renderer.rules.image = imageRule;
237
+
238
+ const defaultParagraphOpen = md.renderer.rules.paragraph_open as ParagraphRule | undefined;
239
+ const defaultParagraphClose = md.renderer.rules.paragraph_close as ParagraphRule | undefined;
240
+ const paragraphOpenRule: ParagraphRule = (
241
+ tokens: RuleTokens,
242
+ idx: number,
243
+ options: RuleOptions,
244
+ env: RuleEnv,
245
+ self: RuleSelf,
246
+ ) => {
247
+ if (isStandaloneImageParagraph(tokens, idx)) {
248
+ return `<figure class="content-image">`;
249
+ }
250
+ if (defaultParagraphOpen) {
251
+ return defaultParagraphOpen(tokens, idx, options, env, self);
252
+ }
253
+ return self.renderToken(tokens, idx, options);
254
+ };
255
+ const paragraphCloseRule: ParagraphRule = (
256
+ tokens: RuleTokens,
257
+ idx: number,
258
+ options: RuleOptions,
259
+ env: RuleEnv,
260
+ self: RuleSelf,
261
+ ) => {
262
+ if (isStandaloneImageParagraph(tokens, idx - 2)) {
263
+ return `</figure>`;
264
+ }
265
+ if (defaultParagraphClose) {
266
+ return defaultParagraphClose(tokens, idx, options, env, self);
267
+ }
268
+ return self.renderToken(tokens, idx, options);
269
+ };
270
+ md.renderer.rules.paragraph_open = paragraphOpenRule;
271
+ md.renderer.rules.paragraph_close = paragraphCloseRule;
272
+
204
273
  const defaultLinkOpen = md.renderer.rules.link_open as LinkOpenRule | undefined;
205
274
  const linkOpenRule: LinkOpenRule = (
206
275
  tokens: RuleTokens,
@@ -67,6 +67,11 @@
67
67
  --content-heading-accent-soft: rgba(136, 57, 239, 0.32);
68
68
  --content-heading-subtle: var(--latte-subtext1);
69
69
  --content-visual-max-width: 720px;
70
+ --content-image-landscape-max-width: 720px;
71
+ --content-image-square-max-width: 640px;
72
+ --content-image-portrait-max-width: 560px;
73
+ --content-image-radius: 8px;
74
+ --content-image-frame-bg: linear-gradient(180deg, var(--latte-base), var(--latte-mantle));
70
75
  --mermaid-wide-max-width: 640px;
71
76
  --mermaid-tall-max-height: 560px;
72
77
  --desktop-sidebar-default: 420px;
@@ -1212,14 +1217,91 @@ body.mobile-toggle-left .mobile-menu-toggle {
1212
1217
  }
1213
1218
 
1214
1219
  /* Images */
1220
+ .viewer-content .content-image,
1221
+ .viewer-content .image-frame {
1222
+ margin: 1.5rem auto;
1223
+ }
1224
+
1225
+ .viewer-content .content-image {
1226
+ width: fit-content;
1227
+ max-width: min(100%, var(--content-image-landscape-max-width));
1228
+ }
1229
+
1230
+ .viewer-content .content-image.is-square {
1231
+ max-width: min(100%, var(--content-image-square-max-width));
1232
+ }
1233
+
1234
+ .viewer-content .content-image.is-portrait {
1235
+ max-width: min(100%, var(--content-image-portrait-max-width));
1236
+ }
1237
+
1215
1238
  .viewer-content img {
1216
1239
  display: block;
1217
1240
  width: auto;
1218
- max-width: min(100%, var(--content-visual-max-width));
1241
+ max-width: min(100%, var(--content-image-landscape-max-width));
1219
1242
  height: auto;
1220
1243
  margin: 0 auto;
1221
- border-radius: 8px;
1244
+ border-radius: var(--content-image-radius);
1245
+ border: 1px solid var(--latte-surface0);
1246
+ background: var(--content-image-frame-bg);
1247
+ }
1248
+
1249
+ .viewer-content img.is-square {
1250
+ max-width: min(100%, var(--content-image-square-max-width));
1251
+ }
1252
+
1253
+ .viewer-content img.is-portrait {
1254
+ max-width: min(100%, var(--content-image-portrait-max-width));
1255
+ }
1256
+
1257
+ .viewer-content .image-frame {
1258
+ width: min(100%, var(--content-image-landscape-max-width));
1259
+ overflow: hidden;
1260
+ border-radius: 12px;
1222
1261
  border: 1px solid var(--latte-surface0);
1262
+ background: var(--content-image-frame-bg);
1263
+ }
1264
+
1265
+ .viewer-content .image-frame.is-square {
1266
+ width: min(100%, var(--content-image-square-max-width));
1267
+ }
1268
+
1269
+ .viewer-content .image-frame.is-portrait {
1270
+ width: min(100%, var(--content-image-portrait-max-width));
1271
+ }
1272
+
1273
+ .viewer-content .image-frame > img {
1274
+ width: 100%;
1275
+ max-width: none;
1276
+ height: 100%;
1277
+ margin: 0;
1278
+ border: 0;
1279
+ border-radius: 0;
1280
+ background: transparent;
1281
+ }
1282
+
1283
+ .viewer-content .image-frame.fit-cover > img {
1284
+ object-fit: cover;
1285
+ }
1286
+
1287
+ .viewer-content .image-frame.fit-contain > img {
1288
+ object-fit: contain;
1289
+ }
1290
+
1291
+ .viewer-content .image-frame.ratio-16x9 {
1292
+ aspect-ratio: 16 / 9;
1293
+ }
1294
+
1295
+ .viewer-content .image-frame.ratio-4x3 {
1296
+ aspect-ratio: 4 / 3;
1297
+ }
1298
+
1299
+ .viewer-content .image-frame.ratio-3x2 {
1300
+ aspect-ratio: 3 / 2;
1301
+ }
1302
+
1303
+ .viewer-content .image-frame.ratio-4x5 {
1304
+ aspect-ratio: 4 / 5;
1223
1305
  }
1224
1306
 
1225
1307
  /* Horizontal rule */
@@ -26,6 +26,12 @@ const MERMAID_WIDE_RATIO = 2.4;
26
26
  const MERMAID_TALL_RATIO = 0.85;
27
27
  const MERMAID_BLOCK_WIDE_CLASS = "is-wide";
28
28
  const MERMAID_BLOCK_TALL_CLASS = "is-tall";
29
+ const CONTENT_IMAGE_LANDSCAPE_CLASS = "is-landscape";
30
+ const CONTENT_IMAGE_PORTRAIT_CLASS = "is-portrait";
31
+ const CONTENT_IMAGE_SQUARE_CLASS = "is-square";
32
+ const CONTENT_IMAGE_LANDSCAPE_THRESHOLD = 1.1;
33
+ const CONTENT_IMAGE_PORTRAIT_THRESHOLD = 0.9;
34
+ const contentImageDimensionCache = new Map();
29
35
  const mermaidRuntime = {
30
36
  initialized: false,
31
37
  loadingPromise: null,
@@ -156,6 +162,170 @@ function normalizeRenderedMermaidSvg(block) {
156
162
  }
157
163
  }
158
164
 
165
+ function clearContentImageClasses(target) {
166
+ if (!(target instanceof Element)) {
167
+ return;
168
+ }
169
+
170
+ target.classList.remove(
171
+ CONTENT_IMAGE_LANDSCAPE_CLASS,
172
+ CONTENT_IMAGE_PORTRAIT_CLASS,
173
+ CONTENT_IMAGE_SQUARE_CLASS,
174
+ );
175
+ }
176
+
177
+ function syncContentImageClasses(image, className) {
178
+ if (!(image instanceof HTMLImageElement)) {
179
+ return;
180
+ }
181
+
182
+ clearContentImageClasses(image);
183
+ image.classList.add(className);
184
+
185
+ const figure = image.closest("figure");
186
+ if (
187
+ figure instanceof HTMLElement &&
188
+ (figure.classList.contains("content-image") || figure.classList.contains("image-frame"))
189
+ ) {
190
+ clearContentImageClasses(figure);
191
+ figure.classList.add(className);
192
+ }
193
+ }
194
+
195
+ function readIntrinsicImageDimensions(imageLike) {
196
+ if (!(imageLike instanceof HTMLImageElement)) {
197
+ return null;
198
+ }
199
+
200
+ const width =
201
+ imageLike.naturalWidth > 0
202
+ ? imageLike.naturalWidth
203
+ : Number.parseFloat(imageLike.getAttribute("width") ?? "");
204
+ const height =
205
+ imageLike.naturalHeight > 0
206
+ ? imageLike.naturalHeight
207
+ : Number.parseFloat(imageLike.getAttribute("height") ?? "");
208
+
209
+ if (!(width > 0) || !(height > 0)) {
210
+ return null;
211
+ }
212
+
213
+ return { width, height };
214
+ }
215
+
216
+ function classifyContentImageByDimensions(image, dimensions) {
217
+ if (!(image instanceof HTMLImageElement) || !dimensions) {
218
+ return;
219
+ }
220
+
221
+ const aspectRatio = dimensions.width / dimensions.height;
222
+ if (aspectRatio >= CONTENT_IMAGE_LANDSCAPE_THRESHOLD) {
223
+ syncContentImageClasses(image, CONTENT_IMAGE_LANDSCAPE_CLASS);
224
+ return;
225
+ }
226
+
227
+ if (aspectRatio <= CONTENT_IMAGE_PORTRAIT_THRESHOLD) {
228
+ syncContentImageClasses(image, CONTENT_IMAGE_PORTRAIT_CLASS);
229
+ return;
230
+ }
231
+
232
+ syncContentImageClasses(image, CONTENT_IMAGE_SQUARE_CLASS);
233
+ }
234
+
235
+ function resolveContentImageDimensions(image) {
236
+ const immediate = readIntrinsicImageDimensions(image);
237
+ if (immediate) {
238
+ return Promise.resolve(immediate);
239
+ }
240
+
241
+ const source = image.currentSrc || image.getAttribute("src") || "";
242
+ if (!source) {
243
+ return Promise.resolve(null);
244
+ }
245
+
246
+ const cached = contentImageDimensionCache.get(source);
247
+ if (cached) {
248
+ return cached;
249
+ }
250
+
251
+ const pending = new Promise((resolve) => {
252
+ const probe = new Image();
253
+ const finalize = () => {
254
+ resolve(readIntrinsicImageDimensions(probe));
255
+ };
256
+
257
+ probe.addEventListener("load", finalize, { once: true });
258
+ probe.addEventListener("error", () => resolve(null), { once: true });
259
+ probe.src = source;
260
+
261
+ if (probe.complete) {
262
+ if (typeof probe.decode === "function") {
263
+ probe.decode().then(finalize).catch(finalize);
264
+ } else {
265
+ finalize();
266
+ }
267
+ }
268
+ });
269
+
270
+ contentImageDimensionCache.set(source, pending);
271
+ return pending;
272
+ }
273
+
274
+ function prepareContentImage(image) {
275
+ if (!(image instanceof HTMLImageElement) || image.closest(".mermaid-block")) {
276
+ return;
277
+ }
278
+
279
+ const figure = image.closest("figure");
280
+ if (
281
+ figure instanceof HTMLElement &&
282
+ (figure.classList.contains("content-image") || figure.classList.contains("image-frame"))
283
+ ) {
284
+ clearContentImageClasses(figure);
285
+ }
286
+ clearContentImageClasses(image);
287
+
288
+ const handleLoad = () => {
289
+ resolveContentImageDimensions(image)
290
+ .then((dimensions) => {
291
+ if (!dimensions) {
292
+ handleError();
293
+ return;
294
+ }
295
+ classifyContentImageByDimensions(image, dimensions);
296
+ })
297
+ .catch(() => {
298
+ handleError();
299
+ });
300
+ };
301
+ const handleError = () => {
302
+ clearContentImageClasses(image);
303
+ if (
304
+ figure instanceof HTMLElement &&
305
+ (figure.classList.contains("content-image") || figure.classList.contains("image-frame"))
306
+ ) {
307
+ clearContentImageClasses(figure);
308
+ }
309
+ };
310
+
311
+ image.addEventListener("load", handleLoad, { once: true });
312
+ image.addEventListener("error", handleError, { once: true });
313
+
314
+ if (image.complete) {
315
+ handleLoad();
316
+ }
317
+ }
318
+
319
+ function enhanceContentImages(root) {
320
+ if (!(root instanceof HTMLElement)) {
321
+ return;
322
+ }
323
+
324
+ for (const image of root.querySelectorAll("img")) {
325
+ prepareContentImage(image);
326
+ }
327
+ }
328
+
159
329
  function parseMermaidNodes() {
160
330
  const contentEl = document.getElementById("viewer-content");
161
331
  if (!(contentEl instanceof HTMLElement)) {
@@ -1769,6 +1939,7 @@ async function start() {
1769
1939
  metaEl.innerHTML = renderMeta(doc);
1770
1940
  updateBacklinks(doc);
1771
1941
  navEl.innerHTML = renderNav(view.docs, view.docIndexById, id, pathBase);
1942
+ enhanceContentImages(contentEl);
1772
1943
  await renderMermaidBlocks(mermaidConfig);
1773
1944
  document.title = composeDocumentTitle(doc.title, siteTitle);
1774
1945
  if (viewerEl instanceof HTMLElement) {
@@ -1795,6 +1966,7 @@ async function start() {
1795
1966
 
1796
1967
  updateBacklinks(doc);
1797
1968
  navEl.innerHTML = renderNav(view.docs, view.docIndexById, id, pathBase);
1969
+ enhanceContentImages(contentEl);
1798
1970
  await renderMermaidBlocks(mermaidConfig);
1799
1971
 
1800
1972
  document.title = composeDocumentTitle(doc.title, siteTitle);