@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 CHANGED
@@ -1,13 +1,13 @@
1
1
  # @odla-ai/blog
2
2
 
3
- > ⚠️ **Experimentalan agentic-coding experiment.** This package is built and
4
- > operated by autonomous coding agents as an experiment in agentic loops. APIs
5
- > change without notice and nothing here is production-hardened. **Use at your
6
- > own risk.**
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://github.com/odla-ai/odla-db) is
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. Preview all four against
47
- the example site with `npm run preview` (from the monorepo root).
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 (see `examples/demo-app` in the
55
- odla-ai monorepo for the full pattern, including odla-db + o11y wiring).
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, themes, and example site work; CLI (`init`/`new`/`dev`/
60
- `check`), feeds, search, deploy scaffolding, and the odla-db islands are in
61
- progress see `PLAN.md` at the repo root.
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 — agent reference (llms.txt)
1
+ # @odla-ai/blog — LLM context
2
2
 
3
- > EXPERIMENTAL an agentic-coding experiment. This package is built and operated
4
- > by autonomous coding agents as an experiment in agentic loops. APIs change
5
- > without notice and nothing here is production-hardened. Use at your own risk.
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
- working example lives at `examples/demo-app` in the odla-ai monorepo):
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.4",
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.1.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
- for (const match of markdown.matchAll(/\[\^([^\]\s]+)\](?!:)/g)) {
42
- if (!footnoteNumbers.has(match[1])) {
43
- footnoteNumbers.set(match[1], footnoteNumbers.size + 1);
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 match = /^\[\^([^\]\s]+)\]:[ \t]*([^\n]*(?:\n[ \t]{2,}[^\n]*)*)/.exec(src);
66
- if (!match) return;
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: match[0],
71
- id: match[1],
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
- const match = /^\[\^([^\]\s]+)\](?!:)/.exec(src);
90
- if (match && footnoteNumbers.has(match[1])) {
91
- return { type: "footnoteRef", raw: match[0], id: match[1] };
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 = slugify(text);
208
+ const id = uniqueHeadingId(text);
139
209
  return `<h${depth} id="${id}">${text}</h${depth}>\n`;
140
210
  },
141
211
  },