@odla-ai/blog 0.0.1
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.md +44 -0
- package/config.schema.json +108 -0
- package/llms.txt +161 -0
- package/package.json +31 -0
- package/src/build.js +206 -0
- package/src/content.js +151 -0
- package/src/excerpt.js +66 -0
- package/src/feeds.js +81 -0
- package/src/index.js +5 -0
- package/src/markdown.js +148 -0
- package/src/templates/archive.js +31 -0
- package/src/templates/base.js +153 -0
- package/src/templates/helpers.js +55 -0
- package/src/templates/index.js +9 -0
- package/src/templates/page.js +20 -0
- package/src/templates/post.js +18 -0
- package/src/templates/tags.js +39 -0
- package/src/themes/chalk/styles.css +720 -0
- package/src/themes/chalk/theme.json +6 -0
- package/src/themes/clay/styles.css +726 -0
- package/src/themes/clay/theme.json +6 -0
- package/src/themes/juniper/styles.css +660 -0
- package/src/themes/juniper/theme.json +6 -0
- package/src/themes/salt/styles.css +728 -0
- package/src/themes/salt/theme.json +6 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @odla-ai/blog
|
|
2
|
+
|
|
3
|
+
Minimal-dependency, static-first blogging platform. A site is a directory of
|
|
4
|
+
markdown plus one JSON config; `build()` emits a fully static site that works on
|
|
5
|
+
GitHub Pages or Cloudflare. When [odla-db](https://github.com/odla-ai/odla-db) is
|
|
6
|
+
configured, interactive features (sign-in, comments, likes) light up as a
|
|
7
|
+
progressive layer — the static site never depends on it.
|
|
8
|
+
|
|
9
|
+
Three runtime dependencies, each with zero dependencies of its own: `marked`
|
|
10
|
+
(markdown), `yaml` (frontmatter), `highlight.js` (build-time syntax highlighting —
|
|
11
|
+
no client JS ships).
|
|
12
|
+
|
|
13
|
+
## Site layout
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
my-blog/
|
|
17
|
+
├── blog.config.json # the only config
|
|
18
|
+
├── posts/2026/2026-07-03-hello.md
|
|
19
|
+
├── pages/about.md
|
|
20
|
+
├── public/ # copied verbatim
|
|
21
|
+
└── theme/ # optional file-by-file overrides of the chosen theme
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Frontmatter: `title` and `date` required; `description`, `tags`, `draft`, `image`
|
|
25
|
+
optional. `draft: true` or a future `date` keeps a post out of the build.
|
|
26
|
+
|
|
27
|
+
## Themes
|
|
28
|
+
|
|
29
|
+
Four bundled themes, all with light + dark modes (system preference + ☾/☀ toggle):
|
|
30
|
+
|
|
31
|
+
- **juniper** (default) — quiet long-form reading; Lora serif, violet/lavender
|
|
32
|
+
- **salt** — warm cream editorial; Cormorant Garamond, moss/rust/teal
|
|
33
|
+
- **chalk** — chalkboard on graph paper; copper/cerulean, mono chrome
|
|
34
|
+
- **clay** — sand paper and terracotta; system fonts only, zero external requests
|
|
35
|
+
|
|
36
|
+
Pick one with `"theme": "salt"` in `blog.config.json`. Override any theme file by
|
|
37
|
+
placing a same-named file in your site's `theme/` directory. Preview all four
|
|
38
|
+
against the example site with `npm run preview` (from the monorepo root).
|
|
39
|
+
|
|
40
|
+
## Status
|
|
41
|
+
|
|
42
|
+
Early. The build pipeline, themes, and example site work; CLI (`init`/`new`/`dev`/
|
|
43
|
+
`check`), feeds, search, deploy scaffolding, and the odla-db islands are in
|
|
44
|
+
progress — see `PLAN.md` at the repo root.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "@odla-ai/blog site configuration (blog.config.json)",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["title", "url"],
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"$schema": { "type": "string" },
|
|
9
|
+
"title": { "type": "string", "description": "Site title, shown in the header and feeds" },
|
|
10
|
+
"url": { "type": "string", "format": "uri", "description": "Canonical origin, e.g. https://cory.news" },
|
|
11
|
+
"description": { "type": "string" },
|
|
12
|
+
"author": { "type": "string" },
|
|
13
|
+
"lang": { "type": "string", "default": "en" },
|
|
14
|
+
"basePath": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"pattern": "^/([^/].*/)?$",
|
|
17
|
+
"default": "/",
|
|
18
|
+
"description": "URL prefix all links use; must start and end with '/'. Set for GitHub project pages."
|
|
19
|
+
},
|
|
20
|
+
"theme": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"enum": ["juniper", "salt", "chalk", "clay"],
|
|
23
|
+
"default": "juniper"
|
|
24
|
+
},
|
|
25
|
+
"sidebarRecentPosts": {
|
|
26
|
+
"type": "integer",
|
|
27
|
+
"default": 0,
|
|
28
|
+
"description": "Show a recent-posts sidebar column (wide viewports) with this many posts; 0 = no sidebar"
|
|
29
|
+
},
|
|
30
|
+
"indexLimit": {
|
|
31
|
+
"type": "integer",
|
|
32
|
+
"default": 20,
|
|
33
|
+
"description": "How many recent posts the home page shows with excerpts; 0 = all"
|
|
34
|
+
},
|
|
35
|
+
"nav": {
|
|
36
|
+
"type": "array",
|
|
37
|
+
"items": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"required": ["label", "href"],
|
|
40
|
+
"additionalProperties": false,
|
|
41
|
+
"properties": {
|
|
42
|
+
"label": { "type": "string" },
|
|
43
|
+
"href": { "type": "string" }
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"description": "Explicit header navigation; omit for automatic Archive/Tags/pages"
|
|
47
|
+
},
|
|
48
|
+
"footerLinks": {
|
|
49
|
+
"type": "array",
|
|
50
|
+
"items": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"required": ["label", "href"],
|
|
53
|
+
"additionalProperties": false,
|
|
54
|
+
"properties": {
|
|
55
|
+
"label": { "type": "string" },
|
|
56
|
+
"href": { "type": "string" }
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"description": "Links shown before the copyright in the footer (e.g. a Colophon link)"
|
|
60
|
+
},
|
|
61
|
+
"copyright": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"description": "Text appended after the author in the footer, e.g. 'All rights reserved.'"
|
|
64
|
+
},
|
|
65
|
+
"postsPath": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"default": "posts",
|
|
68
|
+
"description": "URL prefix for post pages, e.g. 'updates' → /updates/<slug>/ (preserve existing URLs when migrating)"
|
|
69
|
+
},
|
|
70
|
+
"slugFormat": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"enum": ["clean", "filename"],
|
|
73
|
+
"default": "clean",
|
|
74
|
+
"description": "'clean' strips the YYYY-MM-DD- filename prefix from post URLs; 'filename' keeps it (preserve existing URLs when migrating)"
|
|
75
|
+
},
|
|
76
|
+
"extraFields": {
|
|
77
|
+
"type": "array",
|
|
78
|
+
"items": { "type": "string" },
|
|
79
|
+
"description": "Frontmatter keys beyond the core set that `blog check` should accept without warning"
|
|
80
|
+
},
|
|
81
|
+
"odla": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"description": "Optional odla-db integration (not yet active in this version)",
|
|
84
|
+
"additionalProperties": false,
|
|
85
|
+
"properties": {
|
|
86
|
+
"endpoint": { "type": "string", "format": "uri" },
|
|
87
|
+
"appId": { "type": "string" },
|
|
88
|
+
"auth": {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"additionalProperties": false,
|
|
91
|
+
"properties": {
|
|
92
|
+
"provider": { "type": "string", "enum": ["clerk", "oidc"] },
|
|
93
|
+
"publishableKey": { "type": "string" },
|
|
94
|
+
"issuer": { "type": "string", "format": "uri" }
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"features": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"additionalProperties": false,
|
|
100
|
+
"properties": {
|
|
101
|
+
"comments": { "type": "boolean", "default": false },
|
|
102
|
+
"likes": { "type": "boolean", "default": false }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
package/llms.txt
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# @odla-ai/blog — agent reference (llms.txt)
|
|
2
|
+
|
|
3
|
+
This file ships in the npm package so an agent working in a *site* repo can
|
|
4
|
+
operate the platform from `node_modules/@odla-ai/blog/llms.txt` alone. The
|
|
5
|
+
source in `src/` is small and dependency-light — read it when in doubt; it is
|
|
6
|
+
the authority.
|
|
7
|
+
|
|
8
|
+
## Site anatomy
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
my-blog/
|
|
12
|
+
├── blog.config.json # the only config (schema: ./config.schema.json)
|
|
13
|
+
├── posts/<year>/<YYYY-MM-DD-slug>.md
|
|
14
|
+
├── pages/<slug>.md # standalone pages; auto-added to nav
|
|
15
|
+
├── public/ # copied verbatim into the output root
|
|
16
|
+
└── theme/ # optional overrides (see Theming)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Frontmatter
|
|
20
|
+
|
|
21
|
+
```yaml
|
|
22
|
+
---
|
|
23
|
+
title: Required # string, required
|
|
24
|
+
date: 2026-07-03 # required; parseable date
|
|
25
|
+
description: One-liner # optional; used on index, meta description, RSS
|
|
26
|
+
tags: [a, b] # optional list of strings
|
|
27
|
+
draft: true # optional; excluded from builds
|
|
28
|
+
image: /images/x.png # optional
|
|
29
|
+
---
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Publication rules (both enforced in `src/content.js`):
|
|
33
|
+
- `draft: true` → excluded.
|
|
34
|
+
- `date` in the future → excluded ("scheduled") until that date passes.
|
|
35
|
+
- Slugs come from the filename with the `YYYY-MM-DD-` prefix stripped
|
|
36
|
+
(config `"slugFormat": "filename"` keeps the prefix — use when migrating a
|
|
37
|
+
site whose existing URLs include it) and must be unique across ALL years —
|
|
38
|
+
collisions are a build error, so check before naming a new post.
|
|
39
|
+
|
|
40
|
+
Pages support one extra frontmatter key: `listTag: Sea Stories` appends the
|
|
41
|
+
excerpted list of posts carrying that tag after the page body — the pattern
|
|
42
|
+
for curated collections.
|
|
43
|
+
|
|
44
|
+
## Collections (build-time data)
|
|
45
|
+
|
|
46
|
+
Every directory under `<site>/collections/` loads at build time as
|
|
47
|
+
`collections.<name>` — an array of `{ slug, file, data, body }` with raw
|
|
48
|
+
frontmatter (schema-free) and the unrendered markdown body. Collections are
|
|
49
|
+
data, not pages: templates and JS pages decide what to render from them.
|
|
50
|
+
This is how a site carries an entity dataset (companies, books, resources…)
|
|
51
|
+
while staying fully static.
|
|
52
|
+
|
|
53
|
+
## JS pages (bespoke pages and static endpoints)
|
|
54
|
+
|
|
55
|
+
A page can be a `.js` module instead of markdown:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
// pages/map.js → /map/
|
|
59
|
+
export const title = "Map"; // optional; joins nav
|
|
60
|
+
export function render({ config, posts, pages, collections, excerpts }) {
|
|
61
|
+
return "<h1>…</h1>"; // wrapped in the site shell
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
If the filename keeps a real extension after stripping `.js`
|
|
66
|
+
(`pages/api/data.json.js` → `/api/data.json`), the output is written verbatim
|
|
67
|
+
with no HTML shell — a static data endpoint. Import shared helpers from the
|
|
68
|
+
package (`import { escapeHtml, renderMarkdown, postList } from "@odla-ai/blog"`).
|
|
69
|
+
|
|
70
|
+
## Page chrome (automatic)
|
|
71
|
+
|
|
72
|
+
- Head: canonical URL, OpenGraph + Twitter meta (per-post `image` frontmatter,
|
|
73
|
+
home falls back to the newest post's image), favicon link (if
|
|
74
|
+
`public/favicon.svg|ico` exists), RSS/Atom/sitemap links.
|
|
75
|
+
- Sticky nav bar with a scroll-position indicator (accent line + dot).
|
|
76
|
+
- Optional recent-posts sidebar: `"sidebarRecentPosts": 30` in config shows a
|
|
77
|
+
280px column of recent post links on viewports ≥1200px.
|
|
78
|
+
- Output includes `rss.xml`, `atom.xml`, `sitemap.xml`.
|
|
79
|
+
|
|
80
|
+
## Markdown extras
|
|
81
|
+
|
|
82
|
+
- **Footnotes** (GFM `[^1]` / `[^1]: text`) render as numbered superscript
|
|
83
|
+
links plus a `<section class="footnotes">` appended to the content.
|
|
84
|
+
- **Smart typography**: `--`/`---` become em dashes and straight quotes curl,
|
|
85
|
+
in prose text only — code spans and blocks are never touched.
|
|
86
|
+
- **Excerpts**: the home page (`indexLimit` most recent, default 20) and
|
|
87
|
+
`listTag` pages show each post's content truncated at a block boundary
|
|
88
|
+
after ~200 words with a `[continue reading]` link (`src/excerpt.js`).
|
|
89
|
+
|
|
90
|
+
## Footer
|
|
91
|
+
|
|
92
|
+
`config.footerLinks` (e.g. `[{"label": "Colophon", "href": "about/"}]`) render
|
|
93
|
+
first, then `© <year links into the archive> <author>` plus optional
|
|
94
|
+
`config.copyright` text. Years come from the posts automatically.
|
|
95
|
+
|
|
96
|
+
## Build API
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import { build } from "@odla-ai/blog";
|
|
100
|
+
const result = await build({
|
|
101
|
+
siteDir: ".", // directory containing blog.config.json
|
|
102
|
+
outDir: "dist",
|
|
103
|
+
now: new Date(), // optional; drives date-gating
|
|
104
|
+
includeDrafts: false, // optional; true = render drafts + scheduled
|
|
105
|
+
configOverrides: {}, // optional; shallow-merged over blog.config.json
|
|
106
|
+
});
|
|
107
|
+
// → { posts, pages, theme }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Output: `index.html`, `posts/<slug>/index.html`, `<page>/index.html`,
|
|
111
|
+
`archive/`, `tags/`, `tags/<tag>/`, `rss.xml`, `sitemap.xml`,
|
|
112
|
+
`assets/styles.css`, plus everything from `public/`. All URLs are prefixed
|
|
113
|
+
with `config.basePath` (default `/`) — set it when deploying under a subpath
|
|
114
|
+
(e.g. GitHub project pages).
|
|
115
|
+
|
|
116
|
+
Also exported: `loadPosts`, `loadPages`, `parseFrontmatter`, `slugFromFilename`,
|
|
117
|
+
`renderMarkdown`, `slugify`, `escapeHtml`, `resolveThemeFile`.
|
|
118
|
+
|
|
119
|
+
## Markdown
|
|
120
|
+
|
|
121
|
+
GitHub-flavored, rendered by `marked`. Fenced code blocks are highlighted at
|
|
122
|
+
build time by highlight.js (`lib/common` language set) into `hljs-*` classes —
|
|
123
|
+
no client-side JS. Headings get slugified `id` anchors.
|
|
124
|
+
|
|
125
|
+
## Theming
|
|
126
|
+
|
|
127
|
+
Bundled themes: `juniper` (default), `salt`, `chalk`, `clay` — pick with
|
|
128
|
+
`"theme": "<name>"` in config. Each theme is a single self-contained
|
|
129
|
+
`src/themes/<name>/styles.css` plus `theme.json` metadata.
|
|
130
|
+
|
|
131
|
+
Invariants every theme upholds (keep them when editing or adding one):
|
|
132
|
+
- Light tokens on `:root`; dark tokens duplicated on BOTH `[data-theme="dark"]`
|
|
133
|
+
and `@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) }`.
|
|
134
|
+
The two dark blocks must stay identical (tested in `test/themes.test.js`).
|
|
135
|
+
- Common token names: `--bg`, `--text`, `--accent`, `--border`, `--code-bg`,
|
|
136
|
+
and the highlight set `--hl-keyword`, `--hl-string`, `--hl-comment`,
|
|
137
|
+
`--hl-number`, `--hl-title`, `--hl-attr`, `--hl-type`, `--hl-meta`,
|
|
138
|
+
`--hl-addition`, `--hl-deletion`.
|
|
139
|
+
- Selector contract (emitted by `src/templates/*.js`): `.site-header`,
|
|
140
|
+
`.site-title`, `.site-nav`, `.theme-toggle`, `.post-list`, `.post-item`,
|
|
141
|
+
`.post-title`, `.post-meta`, `.post-tag`, `.prose`, `.page-title`,
|
|
142
|
+
`.archive-year`, `.archive-list`, `.archive-item`, `.tag-cloud`, `.hljs-*`.
|
|
143
|
+
|
|
144
|
+
**Site-local overrides:** any file placed in the site's `theme/` directory
|
|
145
|
+
shadows the packaged theme file of the same name (`styles.css`, or any
|
|
146
|
+
template like `post.js` or `index.js`). Copy the packaged file out, edit,
|
|
147
|
+
done. Templates are plain JS functions returning template-literal HTML
|
|
148
|
+
strings; they may import shared helpers from the package
|
|
149
|
+
(`import { postList } from "@odla-ai/blog"`). To ADD styles without forking
|
|
150
|
+
the whole stylesheet, put them in `theme/custom.css` — the build appends it
|
|
151
|
+
to the theme's CSS. Post URLs live under `config.postsPath` (default
|
|
152
|
+
"posts").
|
|
153
|
+
|
|
154
|
+
## Gotchas
|
|
155
|
+
|
|
156
|
+
- `blog.config.json` is plain JSON — no comments, no trailing commas.
|
|
157
|
+
- `basePath` must start and end with `/`.
|
|
158
|
+
- Posts render markdown; changing shared HTML structure means editing
|
|
159
|
+
`src/templates/` (or shadowing them), never the emitted HTML.
|
|
160
|
+
- The odla-db interactive layer (sign-in, comments, likes) is planned but not
|
|
161
|
+
yet in this package version; an `odla` config block is currently inert.
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@odla-ai/blog",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Minimal-dependency, static-first blogging platform. Files in, site out; odla-db lights up interactivity when configured.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"llms.txt",
|
|
14
|
+
"config.schema.json"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"preview": "node scripts/preview.mjs",
|
|
24
|
+
"test": "node --test --test-reporter=spec"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"highlight.js": "^11.11.0",
|
|
28
|
+
"marked": "^16.0.0",
|
|
29
|
+
"yaml": "^2.8.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/build.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Static build: content + theme → dist/. Pure pipeline, top to bottom.
|
|
2
|
+
//
|
|
3
|
+
// Theme resolution (file-by-file shadowing):
|
|
4
|
+
// 1. <site>/theme/<file> — site-local override
|
|
5
|
+
// 2. src/themes/<name>/<file> — the chosen packaged theme
|
|
6
|
+
// 3. src/templates/<file> — shared default templates
|
|
7
|
+
// Themes usually only provide styles.css; templates come from the shared set.
|
|
8
|
+
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
|
+
import { loadPosts, loadPages, loadJsPages, loadCollections } from "./content.js";
|
|
13
|
+
import { renderMarkdown, slugify } from "./markdown.js";
|
|
14
|
+
import { excerpt } from "./excerpt.js";
|
|
15
|
+
import { atom, rss, sitemap } from "./feeds.js";
|
|
16
|
+
|
|
17
|
+
const SRC = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
export function resolveThemeFile(siteDir, themeName, file) {
|
|
20
|
+
const candidates = [
|
|
21
|
+
join(siteDir, "theme", file),
|
|
22
|
+
join(SRC, "themes", themeName, file),
|
|
23
|
+
join(SRC, "templates", file),
|
|
24
|
+
];
|
|
25
|
+
return candidates.find((c) => existsSync(c)) ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function loadTemplates(siteDir, themeName) {
|
|
29
|
+
const templates = {};
|
|
30
|
+
for (const name of ["base", "index", "post", "page", "archive", "tags"]) {
|
|
31
|
+
const file = resolveThemeFile(siteDir, themeName, `${name}.js`);
|
|
32
|
+
templates[name] = await import(pathToFileURL(file).href);
|
|
33
|
+
}
|
|
34
|
+
return templates;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function build({
|
|
38
|
+
siteDir,
|
|
39
|
+
outDir,
|
|
40
|
+
now = new Date(),
|
|
41
|
+
includeDrafts = false,
|
|
42
|
+
configOverrides = {},
|
|
43
|
+
}) {
|
|
44
|
+
const config = {
|
|
45
|
+
...JSON.parse(await readFile(join(siteDir, "blog.config.json"), "utf8")),
|
|
46
|
+
...configOverrides,
|
|
47
|
+
};
|
|
48
|
+
config.basePath ??= "/";
|
|
49
|
+
const themeName = config.theme ?? "juniper";
|
|
50
|
+
|
|
51
|
+
const postsPath = config.postsPath ?? "posts";
|
|
52
|
+
const posts = await loadPosts(join(siteDir, "posts"), {
|
|
53
|
+
now,
|
|
54
|
+
includeDrafts,
|
|
55
|
+
slugFormat: config.slugFormat,
|
|
56
|
+
postsPath,
|
|
57
|
+
});
|
|
58
|
+
const pages = await loadPages(join(siteDir, "pages"));
|
|
59
|
+
const jsPages = await loadJsPages(join(siteDir, "pages"));
|
|
60
|
+
const collections = await loadCollections(join(siteDir, "collections"));
|
|
61
|
+
for (const jp of jsPages) {
|
|
62
|
+
if (!jp.raw && pages.some((p) => p.slug === jp.slug)) {
|
|
63
|
+
throw new Error(`Page collision: "${jp.slug}" exists as both markdown and JS page`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Markdown pages and titled JS pages join the automatic nav.
|
|
67
|
+
config.pages = [
|
|
68
|
+
...pages.map((p) => ({ title: p.title, url: p.url })),
|
|
69
|
+
...jsPages.filter((p) => !p.raw && p.title).map((p) => ({ title: p.title, url: p.url })),
|
|
70
|
+
];
|
|
71
|
+
config.years = [...new Set(posts.map((p) => p.date.getUTCFullYear()))].sort((a, b) => b - a);
|
|
72
|
+
const sidebarCount = config.sidebarRecentPosts ?? 0;
|
|
73
|
+
config.recentPosts = sidebarCount > 0
|
|
74
|
+
? posts.slice(0, sidebarCount).map((p) => ({ title: p.title, url: p.url, date: p.date }))
|
|
75
|
+
: [];
|
|
76
|
+
config.defaultImage = posts.find((p) => p.image)?.image ?? null;
|
|
77
|
+
config.favicon = ["favicon.svg", "favicon.ico"].find((f) =>
|
|
78
|
+
existsSync(join(siteDir, "public", f))
|
|
79
|
+
) ?? null;
|
|
80
|
+
|
|
81
|
+
// Clean output so renamed/deleted content can't leave stale pages behind.
|
|
82
|
+
// Guard: never wipe a directory that looks like a site source.
|
|
83
|
+
if (existsSync(join(outDir, "blog.config.json"))) {
|
|
84
|
+
throw new Error(`outDir ${outDir} contains blog.config.json — refusing to clean it`);
|
|
85
|
+
}
|
|
86
|
+
await rm(outDir, { recursive: true, force: true });
|
|
87
|
+
|
|
88
|
+
const t = await loadTemplates(siteDir, themeName);
|
|
89
|
+
const shell = (opts) => t.base.base({ config, ...opts });
|
|
90
|
+
const write = async (relPath, content) => {
|
|
91
|
+
const file = join(outDir, relPath);
|
|
92
|
+
await mkdir(dirname(file), { recursive: true });
|
|
93
|
+
await writeFile(file, content);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Render every post once; excerpts (for index and listTag pages) come
|
|
97
|
+
// from the same rendered HTML.
|
|
98
|
+
const excerpts = new Map();
|
|
99
|
+
for (const post of posts) {
|
|
100
|
+
const html = renderMarkdown(post.body);
|
|
101
|
+
excerpts.set(
|
|
102
|
+
post.slug,
|
|
103
|
+
excerpt(html, { minWords: 200, moreUrl: `${config.basePath}${post.url}` }).html
|
|
104
|
+
);
|
|
105
|
+
await write(
|
|
106
|
+
join(postsPath, post.slug, "index.html"),
|
|
107
|
+
shell({
|
|
108
|
+
title: post.title,
|
|
109
|
+
description: post.description,
|
|
110
|
+
path: post.url,
|
|
111
|
+
image: post.image,
|
|
112
|
+
content: t.post.post({ config, post, html, collections, now }),
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await write(
|
|
118
|
+
"index.html",
|
|
119
|
+
shell({
|
|
120
|
+
description: config.description,
|
|
121
|
+
path: "",
|
|
122
|
+
image: config.defaultImage,
|
|
123
|
+
content: t.index.index({ config, posts, excerpts, collections, now }),
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
for (const page of pages) {
|
|
128
|
+
await write(
|
|
129
|
+
join(page.slug, "index.html"),
|
|
130
|
+
shell({
|
|
131
|
+
title: page.title,
|
|
132
|
+
description: page.description,
|
|
133
|
+
path: page.url,
|
|
134
|
+
content: t.page.page({ config, page, html: renderMarkdown(page.body), posts, excerpts, collections, now }),
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// JS pages render with full build context; raw ones (e.g. api/data.json)
|
|
140
|
+
// skip the HTML shell.
|
|
141
|
+
for (const jp of jsPages) {
|
|
142
|
+
const body = await jp.module.render({ config, posts, pages, collections, excerpts, now });
|
|
143
|
+
if (jp.raw) {
|
|
144
|
+
await write(jp.outPath, body);
|
|
145
|
+
} else {
|
|
146
|
+
await write(
|
|
147
|
+
jp.outPath,
|
|
148
|
+
shell({
|
|
149
|
+
title: jp.title ?? undefined,
|
|
150
|
+
description: jp.description ?? undefined,
|
|
151
|
+
path: jp.url,
|
|
152
|
+
content: body,
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await write(
|
|
159
|
+
join("archive", "index.html"),
|
|
160
|
+
shell({ title: "Archive", path: "archive/", content: t.archive.archive({ config, posts, collections, now }) })
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
await write(
|
|
164
|
+
join("tags", "index.html"),
|
|
165
|
+
shell({ title: "Tags", path: "tags/", content: t.tags.tagIndex({ config, posts }) })
|
|
166
|
+
);
|
|
167
|
+
// Tag pages are keyed by slug so case variants merge into one page.
|
|
168
|
+
const tagGroups = new Map();
|
|
169
|
+
for (const post of posts) {
|
|
170
|
+
for (const tag of post.tags) {
|
|
171
|
+
const slug = slugify(tag);
|
|
172
|
+
if (!tagGroups.has(slug)) tagGroups.set(slug, { label: tag, posts: [] });
|
|
173
|
+
tagGroups.get(slug).posts.push(post);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const [slug, group] of tagGroups) {
|
|
177
|
+
await write(
|
|
178
|
+
join("tags", slug, "index.html"),
|
|
179
|
+
shell({
|
|
180
|
+
title: group.label,
|
|
181
|
+
path: `tags/${slug}/`,
|
|
182
|
+
content: t.tags.tagPage({ config, tag: group.label, posts: group.posts }),
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await write("rss.xml", rss({ config, posts }));
|
|
188
|
+
await write("atom.xml", atom({ config, posts }));
|
|
189
|
+
await write("sitemap.xml", sitemap({ config, posts, pages }));
|
|
190
|
+
|
|
191
|
+
// theme/custom.css is appended to the theme stylesheet — additions without
|
|
192
|
+
// forking the whole file (full replacement: shadow styles.css itself).
|
|
193
|
+
const styles = resolveThemeFile(siteDir, themeName, "styles.css");
|
|
194
|
+
if (!styles) throw new Error(`Theme "${themeName}" has no styles.css`);
|
|
195
|
+
let css = await readFile(styles, "utf8");
|
|
196
|
+
const customCss = join(siteDir, "theme", "custom.css");
|
|
197
|
+
if (existsSync(customCss)) {
|
|
198
|
+
css += `\n/* ─── site custom.css ─── */\n${await readFile(customCss, "utf8")}`;
|
|
199
|
+
}
|
|
200
|
+
await write(join("assets", "styles.css"), css);
|
|
201
|
+
|
|
202
|
+
const publicDir = join(siteDir, "public");
|
|
203
|
+
if (existsSync(publicDir)) await cp(publicDir, outDir, { recursive: true });
|
|
204
|
+
|
|
205
|
+
return { posts: posts.length, pages: pages.length, theme: themeName };
|
|
206
|
+
}
|