@oh-my-pi/pi-utils 17.2.10 → 17.2.12

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.11] - 2026-08-07
6
+
7
+ ### Added
8
+
9
+ - Added `repair` and `rawKeys` options to `parseFrontmatter` to support spec-conformant loading (disabling lenient recovery and preserving keys verbatim), and exported `normalizeFrontmatterKeys` for manual key normalization.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed the in-house `marked` list tokenizer incorrectly consuming trailing blank lines at the end of input, ensuring correct list tightness and token generation matching standard `marked` behavior.
14
+
5
15
  ## [17.2.10] - 2026-08-06
6
16
 
7
17
  ### Added
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Recursively normalize object keys from kebab-case to camelCase — the
3
+ * representation convention for frontmatter consumed inside this codebase.
4
+ * Exported for loaders that parse with `rawKeys: true` to validate exact
5
+ * spec-defined keys, then normalize for storage.
6
+ */
7
+ export declare function normalizeFrontmatterKeys<T>(obj: T): T;
1
8
  export declare class FrontmatterError extends Error {
2
9
  readonly source?: unknown;
3
10
  constructor(error: Error, source?: unknown);
@@ -14,6 +21,20 @@ export interface FrontmatterOptions {
14
21
  normalize?: boolean;
15
22
  /** Level of error handling */
16
23
  level?: "off" | "warn" | "fatal";
24
+ /**
25
+ * Attempt lenient recovery of near-miss input before failing: quote
26
+ * ambiguous plain scalars, replace tabs with spaces, and strip leading HTML
27
+ * comments ahead of the opening delimiter. Default `true`. Spec-conformant
28
+ * loaders set `false` so malformed input is rejected instead of silently
29
+ * repaired (CRLF newline normalization still applies).
30
+ */
31
+ repair?: boolean;
32
+ /**
33
+ * Preserve frontmatter keys verbatim instead of normalizing kebab-case to
34
+ * camelCase. Default `false`. Strict spec loaders use this so a standard
35
+ * key (e.g. `allowed-tools`) is never aliased with its camelCase form.
36
+ */
37
+ rawKeys?: boolean;
17
38
  }
18
39
  /**
19
40
  * Parse YAML frontmatter from markdown content
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "17.2.10",
4
+ "version": "17.2.12",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "17.2.10"
34
+ "@oh-my-pi/pi-natives": "17.2.12"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/bun": "^1.3.14"
@@ -12,15 +12,20 @@ function kebabToCamel(key: string): string {
12
12
  return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
13
13
  }
14
14
 
15
- /** Recursively normalize object keys from kebab-case to camelCase */
16
- function normalizeKeys<T>(obj: T): T {
15
+ /**
16
+ * Recursively normalize object keys from kebab-case to camelCase — the
17
+ * representation convention for frontmatter consumed inside this codebase.
18
+ * Exported for loaders that parse with `rawKeys: true` to validate exact
19
+ * spec-defined keys, then normalize for storage.
20
+ */
21
+ export function normalizeFrontmatterKeys<T>(obj: T): T {
17
22
  if (obj === null || typeof obj !== "object") return obj;
18
23
  if (Array.isArray(obj)) {
19
24
  let changed = false;
20
25
  const out: unknown[] = new Array(obj.length);
21
26
  for (let i = 0; i < obj.length; i++) {
22
27
  const v = obj[i];
23
- const nv = normalizeKeys(v);
28
+ const nv = normalizeFrontmatterKeys(v);
24
29
  out[i] = nv;
25
30
  if (nv !== v) changed = true;
26
31
  }
@@ -30,7 +35,7 @@ function normalizeKeys<T>(obj: T): T {
30
35
  const result: Record<string, unknown> = {};
31
36
  for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
32
37
  const nk = key.includes("-") ? kebabToCamel(key) : key;
33
- const nv = normalizeKeys(value);
38
+ const nv = normalizeFrontmatterKeys(value);
34
39
  result[nk] = nv;
35
40
  if (nk !== key || nv !== value) changed = true;
36
41
  }
@@ -55,8 +60,8 @@ function quoteAmbiguousPlainScalars(metadata: string): string | undefined {
55
60
  return changed ? lines.join("\n") : undefined;
56
61
  }
57
62
 
58
- function parseYamlRecord(metadata: string): Record<string, unknown> | null {
59
- const loaded = YAML.parse(metadata.replaceAll("\t", " "));
63
+ function parseYamlRecord(metadata: string, repairTabs: boolean): Record<string, unknown> | null {
64
+ const loaded = YAML.parse(repairTabs ? metadata.replaceAll("\t", " ") : metadata);
60
65
  if (loaded === null || loaded === undefined) return null;
61
66
  if (typeof loaded !== "object" || Array.isArray(loaded)) return null;
62
67
  return loaded as Record<string, unknown>;
@@ -97,6 +102,20 @@ export interface FrontmatterOptions {
97
102
  normalize?: boolean;
98
103
  /** Level of error handling */
99
104
  level?: "off" | "warn" | "fatal";
105
+ /**
106
+ * Attempt lenient recovery of near-miss input before failing: quote
107
+ * ambiguous plain scalars, replace tabs with spaces, and strip leading HTML
108
+ * comments ahead of the opening delimiter. Default `true`. Spec-conformant
109
+ * loaders set `false` so malformed input is rejected instead of silently
110
+ * repaired (CRLF newline normalization still applies).
111
+ */
112
+ repair?: boolean;
113
+ /**
114
+ * Preserve frontmatter keys verbatim instead of normalizing kebab-case to
115
+ * camelCase. Default `false`. Strict spec loaders use this so a standard
116
+ * key (e.g. `allowed-tools`) is never aliased with its camelCase form.
117
+ */
118
+ rawKeys?: boolean;
100
119
  }
101
120
 
102
121
  /**
@@ -107,11 +126,22 @@ export function parseFrontmatter(
107
126
  content: string,
108
127
  options?: FrontmatterOptions,
109
128
  ): { frontmatter: Record<string, unknown>; body: string } {
110
- const { location, source, fallback, normalize = true, level = "warn" } = options ?? {};
129
+ const {
130
+ location,
131
+ source,
132
+ fallback,
133
+ normalize = true,
134
+ level = "warn",
135
+ repair = true,
136
+ rawKeys = false,
137
+ } = options ?? {};
138
+ const finalizeKeys = (fm: Record<string, unknown>): Record<string, unknown> =>
139
+ rawKeys ? fm : normalizeFrontmatterKeys(fm);
111
140
  const loc = location ?? source;
112
141
  const frontmatter: Record<string, unknown> = { ...fallback };
113
142
 
114
- const normalized = normalize ? stripHtmlComments(content.replace(/\r\n?/g, "\n")) : content;
143
+ const newlineNormalized = normalize ? content.replace(/\r\n?/g, "\n") : content;
144
+ const normalized = normalize && repair ? stripHtmlComments(newlineNormalized) : newlineNormalized;
115
145
  if (!normalized.startsWith("---")) {
116
146
  return { frontmatter, body: normalized };
117
147
  }
@@ -125,14 +155,14 @@ export function parseFrontmatter(
125
155
  const body = normalized.slice(endIndex + 4).trim();
126
156
 
127
157
  try {
128
- const loaded = parseYamlRecord(metadata);
129
- return { frontmatter: normalizeKeys({ ...frontmatter, ...loaded }), body };
158
+ const loaded = parseYamlRecord(metadata, repair);
159
+ return { frontmatter: finalizeKeys({ ...frontmatter, ...loaded }), body };
130
160
  } catch (error) {
131
- const quotedMetadata = quoteAmbiguousPlainScalars(metadata);
161
+ const quotedMetadata = repair ? quoteAmbiguousPlainScalars(metadata) : undefined;
132
162
  if (quotedMetadata) {
133
163
  try {
134
- const loaded = parseYamlRecord(quotedMetadata);
135
- return { frontmatter: normalizeKeys({ ...frontmatter, ...loaded }), body };
164
+ const loaded = parseYamlRecord(quotedMetadata, true);
165
+ return { frontmatter: finalizeKeys({ ...frontmatter, ...loaded }), body };
136
166
  } catch {
137
167
  // Fall through to the existing warning + simple key/value fallback.
138
168
  }
@@ -170,6 +200,6 @@ export function parseFrontmatter(
170
200
  frontmatter[match[1]] = value;
171
201
  }
172
202
 
173
- return { frontmatter: normalizeKeys(frontmatter) as Record<string, unknown>, body };
203
+ return { frontmatter: finalizeKeys(frontmatter), body };
174
204
  }
175
205
  }
@@ -826,23 +826,23 @@ function parseList(lines: string[], index: number, lexer: Lexer): { token: Token
826
826
  if (/^\s*\n$/.test(next)) {
827
827
  let lookahead = cursor + 1;
828
828
  while (lookahead < lines.length && /^\s*\n$/.test(lines[lookahead]!)) lookahead++;
829
- if (lookahead < lines.length) {
830
- // A blank line closes the list unless the next top-level line is a
831
- // compatible item (same bullet char / ordered delimiter) or indented
832
- // item content. The blank must stay OUTSIDE the list raw (it becomes
833
- // a `space` token) so token shape never depends on what follows —
834
- // real marked does the same, and the TUI's streaming freeze relies
835
- // on that append-stability.
836
- const following = lines[lookahead]!;
837
- const followingIndent = /^ */.exec(following)![0].length;
838
- if (followingIndent <= first[1]!.length) {
839
- const followingList = isList(following);
840
- const compatible =
841
- followingList !== null &&
842
- /^\d/.test(followingList[2]!) === ordered &&
843
- (ordered ? followingList[2]!.at(-1) === delimiter : followingList[2] === delimiter);
844
- if (!compatible) break;
845
- }
829
+ // A blank line closes the list unless the next top-level line is a
830
+ // compatible item (same bullet char / ordered delimiter) or indented
831
+ // item content. The blank must stay OUTSIDE the list raw (it becomes
832
+ // a `space` token) so token shape never depends on what follows
833
+ // real marked does the same, and the TUI's streaming freeze relies
834
+ // on that append-stability. A blank run at end of input closes the
835
+ // list the same way, keeping it tight and its raw blank-free.
836
+ if (lookahead >= lines.length) break;
837
+ const following = lines[lookahead]!;
838
+ const followingIndent = /^ */.exec(following)![0].length;
839
+ if (followingIndent <= first[1]!.length) {
840
+ const followingList = isList(following);
841
+ const compatible =
842
+ followingList !== null &&
843
+ /^\d/.test(followingList[2]!) === ordered &&
844
+ (ordered ? followingList[2]!.at(-1) === delimiter : followingList[2] === delimiter);
845
+ if (!compatible) break;
846
846
  }
847
847
  text += next;
848
848
  } else {