@odla-ai/blog 0.0.4 → 0.0.6
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 +23 -12
- package/llms.txt +16 -6
- package/package.json +13 -4
- package/src/markdown.js +83 -13
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# @odla-ai/blog
|
|
2
2
|
|
|
3
|
-
> ⚠️ **
|
|
4
|
-
>
|
|
5
|
-
>
|
|
6
|
-
>
|
|
3
|
+
> ⚠️ **Early access — pre-1.0.** Agents work from bounded runbooks; humans
|
|
4
|
+
> approve credentials, production changes, releases, and merges. APIs and exact
|
|
5
|
+
> package availability can change. Review the documented guarantees and
|
|
6
|
+
> limitations; this software is MIT-licensed and provided without warranty.
|
|
7
7
|
|
|
8
8
|
Minimal-dependency, static-first blogging platform. A site is a directory of
|
|
9
9
|
markdown plus one JSON config; `build()` emits a fully static site that works on
|
|
10
|
-
GitHub Pages or Cloudflare. When [odla-db](https://
|
|
10
|
+
GitHub Pages or Cloudflare. When [odla-db](https://www.npmjs.com/package/@odla-ai/db) is
|
|
11
11
|
configured, interactive features (sign-in, comments, likes) light up as a
|
|
12
12
|
progressive layer — the static site never depends on it.
|
|
13
13
|
|
|
@@ -43,19 +43,30 @@ Pick one with `"theme": "salt"` in `blog.config.json`. Themes ship in the
|
|
|
43
43
|
design system); every built site also gets ui's class-scoped component layer
|
|
44
44
|
(`assets/odla-ui.css` — buttons, form fields, cards, …) theme-matched via the
|
|
45
45
|
theme's `ui.css` token aliases. Override any theme file by placing a
|
|
46
|
-
same-named file in your site's `theme/` directory.
|
|
47
|
-
|
|
46
|
+
same-named file in your site's `theme/` directory. Build your site with the
|
|
47
|
+
package API and preview the emitted static directory with your usual local
|
|
48
|
+
static server.
|
|
49
|
+
|
|
50
|
+
## Input boundaries
|
|
51
|
+
|
|
52
|
+
Footnote identifiers are capped at 256 characters, and malformed `[^`
|
|
53
|
+
introducers are handled by a monotonic scanner so adversarial Markdown cannot
|
|
54
|
+
force repeated whole-suffix regex scans. Markdown still permits authored raw
|
|
55
|
+
HTML by design; it is not a sanitizer. JS pages and collection templates must
|
|
56
|
+
use `escapeHtml(String(value))` for every schema-free frontmatter value or
|
|
57
|
+
filename-derived slug placed into HTML.
|
|
48
58
|
|
|
49
59
|
## Deploy (Cloudflare)
|
|
50
60
|
|
|
51
61
|
`build()` emits a plain static directory; serve it from a Worker assets
|
|
52
62
|
binding — `{ "assets": { "directory": "./dist-site" } }` in `wrangler.jsonc`,
|
|
53
63
|
build before every deploy. Add a `main` worker script only when you want
|
|
54
|
-
`/api/*` routes next to the static site
|
|
55
|
-
|
|
64
|
+
`/api/*` routes next to the static site; the public platform manual describes
|
|
65
|
+
how to combine the assets binding with odla-db and o11y.
|
|
56
66
|
|
|
57
67
|
## Status
|
|
58
68
|
|
|
59
|
-
Early. The build pipeline
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
Early. The build pipeline and themes work; CLI (`init`/`new`/`dev`/`check`),
|
|
70
|
+
feeds, search, deploy scaffolding, and the odla-db islands are in progress.
|
|
71
|
+
Track supported behavior in the public docs and the package's shipped
|
|
72
|
+
`llms.txt` rather than relying on internal planning files.
|
package/llms.txt
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
# @odla-ai/blog —
|
|
1
|
+
# @odla-ai/blog — LLM context
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
|
|
5
|
-
>
|
|
3
|
+
> Minimal-dependency, static-first blogging platform. Files in, site out; odla-db lights up interactivity when configured.
|
|
4
|
+
|
|
5
|
+
> EARLY ACCESS — pre-1.0. Agents work from bounded runbooks; humans approve
|
|
6
|
+
> credentials, production changes, releases, and merges. APIs and exact package
|
|
7
|
+
> availability can change. Review the documented guarantees and limitations;
|
|
8
|
+
> this software is MIT-licensed and provided without warranty.
|
|
6
9
|
|
|
7
10
|
This file ships in the npm package so an agent working in a *site* repo can
|
|
8
11
|
operate the platform from `node_modules/@odla-ai/blog/llms.txt` alone. The
|
|
@@ -84,7 +87,9 @@ package (`import { escapeHtml, renderMarkdown, postList } from "@odla-ai/blog"`)
|
|
|
84
87
|
## Markdown extras
|
|
85
88
|
|
|
86
89
|
- **Footnotes** (GFM `[^1]` / `[^1]: text`) render as numbered superscript
|
|
87
|
-
links plus a `<section class="footnotes">` appended to the content.
|
|
90
|
+
links plus a `<section class="footnotes">` appended to the content. IDs are
|
|
91
|
+
limited to 256 characters, and malformed introducers are handled by a
|
|
92
|
+
monotonic scanner rather than repeated whole-suffix regex matching.
|
|
88
93
|
- **Smart typography**: `--`/`---` become em dashes and straight quotes curl,
|
|
89
94
|
in prose text only — code spans and blocks are never touched.
|
|
90
95
|
- **Excerpts**: the home page (`indexLimit` most recent, default 20) and
|
|
@@ -127,6 +132,11 @@ GitHub-flavored, rendered by `marked`. Fenced code blocks are highlighted at
|
|
|
127
132
|
build time by highlight.js (`lib/common` language set) into `hljs-*` classes —
|
|
128
133
|
no client-side JS. Headings get slugified `id` anchors.
|
|
129
134
|
|
|
135
|
+
Authored raw HTML remains enabled; this renderer is not a sanitizer. Treat
|
|
136
|
+
schema-free collection/frontmatter fields and filename-derived slugs as
|
|
137
|
+
untrusted when writing a JS page, and apply `escapeHtml(String(value))` in the
|
|
138
|
+
correct HTML context.
|
|
139
|
+
|
|
130
140
|
## Theming
|
|
131
141
|
|
|
132
142
|
Bundled themes: `juniper` (default), `salt`, `chalk`, `clay` — pick with
|
|
@@ -176,7 +186,7 @@ to the theme's CSS (after the `ui.css` aliases). Post URLs live under
|
|
|
176
186
|
|
|
177
187
|
The output is a plain static directory — serve it from a Worker's assets
|
|
178
188
|
binding (this is what `blog init --deploy cloudflare` will generate; a
|
|
179
|
-
|
|
189
|
+
the public machine-readable platform runbook documents the same Worker + assets composition):
|
|
180
190
|
|
|
181
191
|
```js
|
|
182
192
|
// build-site.mjs — run before every deploy
|
package/package.json
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odla-ai/blog",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Minimal-dependency, static-first blogging platform. Files in, site out; odla-db lights up interactivity when configured.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"homepage": "https://odla.ai/docs/packages/blog",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/cory/odla-ai.git",
|
|
11
|
+
"directory": "packages/blog"
|
|
12
|
+
},
|
|
7
13
|
"exports": {
|
|
8
14
|
".": "./src/index.js"
|
|
9
15
|
},
|
|
@@ -14,7 +20,8 @@
|
|
|
14
20
|
"config.schema.json"
|
|
15
21
|
],
|
|
16
22
|
"publishConfig": {
|
|
17
|
-
"access": "public"
|
|
23
|
+
"access": "public",
|
|
24
|
+
"provenance": false
|
|
18
25
|
},
|
|
19
26
|
"engines": {
|
|
20
27
|
"node": ">=20"
|
|
@@ -22,10 +29,12 @@
|
|
|
22
29
|
"scripts": {
|
|
23
30
|
"preview": "node scripts/preview.mjs",
|
|
24
31
|
"test": "node --test --test-reporter=spec",
|
|
25
|
-
"coverage": "c8 --src=src --reporter=text --reporter=html node --test"
|
|
32
|
+
"coverage": "c8 --src=src --reporter=text --reporter=html node --test",
|
|
33
|
+
"gen:llms": "node ../../scripts/gen-llms.mjs",
|
|
34
|
+
"prepublishOnly": "npm run gen:llms && npm test"
|
|
26
35
|
},
|
|
27
36
|
"dependencies": {
|
|
28
|
-
"@odla-ai/ui": "^0.
|
|
37
|
+
"@odla-ai/ui": "^0.3.0",
|
|
29
38
|
"highlight.js": "^11.11.0",
|
|
30
39
|
"marked": "^16.0.0",
|
|
31
40
|
"yaml": "^2.8.0"
|
package/src/markdown.js
CHANGED
|
@@ -31,17 +31,84 @@ export function slugify(text) {
|
|
|
31
31
|
// is synchronous, so module-level state is safe.
|
|
32
32
|
let footnoteNumbers = new Map(); // id → number, in first-reference order
|
|
33
33
|
let footnoteBodies = new Map(); // id → rendered inline HTML
|
|
34
|
+
let headingIds = new Map(); // base slug → occurrences in this document
|
|
35
|
+
const MAX_FOOTNOTE_ID_LENGTH = 256;
|
|
36
|
+
const footnoteWhitespace = /\s/u;
|
|
37
|
+
|
|
38
|
+
// Return one syntactically valid reference at or after `from`. Both the search
|
|
39
|
+
// cursor and every candidate cursor move only forward, while malformed IDs are
|
|
40
|
+
// capped, so adversarial runs of "[^" remain linear in the input size.
|
|
41
|
+
function findFootnoteReference(source, from = 0) {
|
|
42
|
+
let cursor = from;
|
|
43
|
+
while (cursor < source.length) {
|
|
44
|
+
const start = source.indexOf("[^", cursor);
|
|
45
|
+
if (start < 0) return;
|
|
46
|
+
const reference = parseFootnoteReference(source, start);
|
|
47
|
+
if (reference) return reference;
|
|
48
|
+
cursor = start + 2;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseFootnoteReference(source, start = 0) {
|
|
53
|
+
if (source[start] !== "[" || source[start + 1] !== "^") return;
|
|
54
|
+
const idEnd = footnoteIdEnd(source, start + 2);
|
|
55
|
+
if (idEnd === undefined || source[idEnd + 1] === ":") return;
|
|
56
|
+
return { index: start, id: source.slice(start + 2, idEnd), raw: source.slice(start, idEnd + 1) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function footnoteIdEnd(source, start) {
|
|
60
|
+
for (let length = 0; start + length < source.length && length <= MAX_FOOTNOTE_ID_LENGTH; length++) {
|
|
61
|
+
const char = source[start + length];
|
|
62
|
+
if (char === "]") return length ? start + length : undefined;
|
|
63
|
+
if (footnoteWhitespace.test(char) || length === MAX_FOOTNOTE_ID_LENGTH) return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Parse a definition's first line and its ≥2-character horizontal-indent
|
|
68
|
+
// continuation lines without a nested, backtracking regular expression.
|
|
69
|
+
function parseFootnoteDefinition(source) {
|
|
70
|
+
if (!source.startsWith("[^")) return;
|
|
71
|
+
const idEnd = footnoteIdEnd(source, 2);
|
|
72
|
+
if (idEnd === undefined || source[idEnd + 1] !== ":") return;
|
|
73
|
+
let cursor = idEnd + 2;
|
|
74
|
+
while (source[cursor] === " " || source[cursor] === "\t") cursor++;
|
|
75
|
+
let lineEnd = source.indexOf("\n", cursor);
|
|
76
|
+
if (lineEnd < 0) lineEnd = source.length;
|
|
77
|
+
const body = [source.slice(cursor, lineEnd)];
|
|
78
|
+
let rawEnd = lineEnd;
|
|
79
|
+
while (lineEnd < source.length) {
|
|
80
|
+
const indentStart = lineEnd + 1;
|
|
81
|
+
let contentStart = indentStart;
|
|
82
|
+
while (source[contentStart] === " " || source[contentStart] === "\t") contentStart++;
|
|
83
|
+
if (contentStart - indentStart < 2) break;
|
|
84
|
+
lineEnd = source.indexOf("\n", contentStart);
|
|
85
|
+
if (lineEnd < 0) lineEnd = source.length;
|
|
86
|
+
body.push(source.slice(contentStart, lineEnd));
|
|
87
|
+
rawEnd = lineEnd;
|
|
88
|
+
}
|
|
89
|
+
return { id: source.slice(2, idEnd), raw: source.slice(0, rawEnd), text: body.join(" ").trim() };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function uniqueHeadingId(text) {
|
|
93
|
+
const base = slugify(text);
|
|
94
|
+
const count = (headingIds.get(base) ?? 0) + 1;
|
|
95
|
+
headingIds.set(base, count);
|
|
96
|
+
return count === 1 ? base : `${base}-${count}`;
|
|
97
|
+
}
|
|
34
98
|
|
|
35
99
|
const footnotes = {
|
|
36
100
|
hooks: {
|
|
37
101
|
preprocess(markdown) {
|
|
38
102
|
footnoteNumbers = new Map();
|
|
39
103
|
footnoteBodies = new Map();
|
|
104
|
+
headingIds = new Map();
|
|
40
105
|
// Number by first reference (definitions like "[^id]:" excluded).
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
106
|
+
let reference = findFootnoteReference(markdown);
|
|
107
|
+
while (reference) {
|
|
108
|
+
if (!footnoteNumbers.has(reference.id)) {
|
|
109
|
+
footnoteNumbers.set(reference.id, footnoteNumbers.size + 1);
|
|
44
110
|
}
|
|
111
|
+
reference = findFootnoteReference(markdown, reference.index + reference.raw.length);
|
|
45
112
|
}
|
|
46
113
|
return markdown;
|
|
47
114
|
},
|
|
@@ -62,14 +129,13 @@ const footnotes = {
|
|
|
62
129
|
level: "block",
|
|
63
130
|
tokenizer(src) {
|
|
64
131
|
// "[^id]: text" plus continuation lines indented ≥2 spaces.
|
|
65
|
-
const
|
|
66
|
-
if (!
|
|
67
|
-
const text = match[2].replace(/\n[ \t]+/g, " ").trim();
|
|
132
|
+
const definition = parseFootnoteDefinition(src);
|
|
133
|
+
if (!definition) return;
|
|
68
134
|
return {
|
|
69
135
|
type: "footnoteDef",
|
|
70
|
-
raw:
|
|
71
|
-
id:
|
|
72
|
-
tokens: this.lexer.inlineTokens(text),
|
|
136
|
+
raw: definition.raw,
|
|
137
|
+
id: definition.id,
|
|
138
|
+
tokens: this.lexer.inlineTokens(definition.text),
|
|
73
139
|
};
|
|
74
140
|
},
|
|
75
141
|
renderer(token) {
|
|
@@ -86,10 +152,14 @@ const footnotes = {
|
|
|
86
152
|
return src.indexOf("[^");
|
|
87
153
|
},
|
|
88
154
|
tokenizer(src) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
155
|
+
if (!src.startsWith("[^")) return;
|
|
156
|
+
const reference = parseFootnoteReference(src);
|
|
157
|
+
if (reference && footnoteNumbers.has(reference.id)) {
|
|
158
|
+
return { type: "footnoteRef", raw: reference.raw, id: reference.id };
|
|
92
159
|
}
|
|
160
|
+
// Consume the introducer as ordinary text. Returning here prevents
|
|
161
|
+
// Marked from retrying every malformed prefix against the whole suffix.
|
|
162
|
+
return { type: "text", raw: "[^", text: "[^" };
|
|
93
163
|
},
|
|
94
164
|
renderer(token) {
|
|
95
165
|
const n = footnoteNumbers.get(token.id);
|
|
@@ -135,7 +205,7 @@ const marked = new Marked(
|
|
|
135
205
|
},
|
|
136
206
|
heading({ tokens, depth }) {
|
|
137
207
|
const text = this.parser.parseInline(tokens);
|
|
138
|
-
const id =
|
|
208
|
+
const id = uniqueHeadingId(text);
|
|
139
209
|
return `<h${depth} id="${id}">${text}</h${depth}>\n`;
|
|
140
210
|
},
|
|
141
211
|
},
|