pi-code 1.0.5 → 1.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.
@@ -16,6 +16,9 @@ import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
16
16
 
17
17
  import { splitSegments } from './shell-split.js'
18
18
 
19
+ /** The pi file tools a Claude path rule can govern. */
20
+ export type PathRuleTool = 'read' | 'edit' | 'write'
21
+
19
22
  export interface ParsedCommand {
20
23
  description: string
21
24
  argumentHint?: string
@@ -23,7 +26,7 @@ export interface ParsedCommand {
23
26
  /** Claude `Bash(...)` specifiers, present only when every bash grant is scoped. */
24
27
  bashRules?: string[]
25
28
  /** Claude path rules per pi file tool, from Read(...)/Edit(...)/Write(...) grants. */
26
- pathRules?: Partial<Record<'read' | 'edit' | 'write', string[]>>
29
+ pathRules?: Partial<Record<PathRuleTool, string[]>>
27
30
  /** Names from the `arguments:` frontmatter list, mapped to positions in order. */
28
31
  argumentNames?: string[]
29
32
  /** Tools removed from the pool while the command's turn runs. */
@@ -66,6 +69,10 @@ const CLAUDE_TOOL_MAP: Record<string, string> = {
66
69
  task: 'subagent',
67
70
  askuserquestion: 'question',
68
71
  exitplanmode: 'plan_mode_complete',
72
+ // Claude's name for the tool this package registers so the model can run user slash
73
+ // commands; without it `allowed-tools: SlashCommand` matched nothing and the grant
74
+ // could neither keep nor drop the tool.
75
+ slashcommand: 'slash_command',
69
76
  }
70
77
 
71
78
  /**
@@ -118,18 +125,78 @@ export interface ToolGrants {
118
125
  /** Claude path rules per pi file tool, absent for a tool with an unscoped grant.
119
126
  * Edit scopes govern writes too, as Claude documents; Write scopes are honored
120
127
  * rather than Claude's accept-and-warn-then-ignore, which would fail open here. */
121
- pathRules?: Partial<Record<'read' | 'edit' | 'write', string[]>>
128
+ pathRules?: Partial<Record<PathRuleTool, string[]>>
122
129
  /** Entries that carried an argument scope, in their original spelling. */
123
130
  scopedEntries: string[]
124
131
  }
125
132
 
126
133
  /** The pi file tools one Claude path-ruled entry governs. */
127
- const PATH_RULE_TOOLS: Record<string, Array<'read' | 'edit' | 'write'>> = {
134
+ const PATH_RULE_TOOLS: Record<string, Array<PathRuleTool>> = {
128
135
  read: ['read'],
129
136
  edit: ['edit', 'write'],
130
137
  write: ['write'],
131
138
  }
132
139
 
140
+ /** The tools, scopes, and path rules accumulated while scanning one grant list. */
141
+ interface GrantAccumulator {
142
+ tools: string[]
143
+ scopedEntries: string[]
144
+ bashRules: string[]
145
+ bashUnscoped: boolean
146
+ pathScopes: Record<PathRuleTool, string[]>
147
+ pathUnscoped: Set<PathRuleTool>
148
+ }
149
+
150
+ function createGrantAccumulator(): GrantAccumulator {
151
+ return { tools: [], scopedEntries: [], bashRules: [], bashUnscoped: false, pathScopes: { read: [], edit: [], write: [] }, pathUnscoped: new Set() }
152
+ }
153
+
154
+ /** Coerce a raw grant value to its string entries: a YAML list stays a list, a
155
+ * comma-separated string is split, and anything else (or a list with a non-string
156
+ * member) is rejected as undefined, the same "not a grant" signal as an absent field. */
157
+ function coerceGrantItems(raw: unknown): string[] | undefined {
158
+ let items: unknown[]
159
+ if (Array.isArray(raw)) items = raw
160
+ else if (typeof raw === 'string') items = toolEntries(raw)
161
+ else return undefined
162
+ if (items.some((item) => typeof item !== 'string')) return undefined
163
+ return items as string[]
164
+ }
165
+
166
+ /** Fold one grant entry into the accumulator: the base tool is granted, an unscoped
167
+ * entry marks its tools wide, and a scoped entry records the scope for bash and the
168
+ * file tools it governs. */
169
+ function addGrantEntry(acc: GrantAccumulator, item: string): void {
170
+ const entry = item.trim()
171
+ const name = normalizeToolName(entry)
172
+ if (!name) return
173
+ if (!acc.tools.includes(name)) acc.tools.push(name)
174
+ const open = entry.indexOf('(')
175
+ if (open === -1) {
176
+ if (name === 'bash') acc.bashUnscoped = true
177
+ for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathUnscoped.add(tool)
178
+ return
179
+ }
180
+ acc.scopedEntries.push(entry)
181
+ const scope = entry.slice(open + 1, entry.endsWith(')') ? -1 : undefined).trim()
182
+ // An empty specifier (`Bash()`, `Read()`) matches nothing and must not read as
183
+ // the unscoped grant it explicitly is not: it is recorded so the tool stays
184
+ // restricted, and the matchers treat an empty rule as matching no input.
185
+ if (name === 'bash') acc.bashRules.push(scope)
186
+ for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathScopes[tool].push(scope)
187
+ }
188
+
189
+ /** The per-tool path rules from a scan: a tool with any unscoped grant is omitted
190
+ * (it is wide), one with only scoped grants keeps them, and the whole map is absent
191
+ * when no tool carries a rule. */
192
+ function buildPathRules(acc: GrantAccumulator): ToolGrants['pathRules'] {
193
+ const pathRules: NonNullable<ToolGrants['pathRules']> = {}
194
+ for (const tool of ['read', 'edit', 'write'] as const) {
195
+ if (!acc.pathUnscoped.has(tool) && acc.pathScopes[tool].length > 0) pathRules[tool] = acc.pathScopes[tool]
196
+ }
197
+ return Object.keys(pathRules).length > 0 ? pathRules : undefined
198
+ }
199
+
133
200
  /**
134
201
  * A tool grant is either a comma-separated string or a YAML list, and the two mean the
135
202
  * same thing. An empty list is not the same as an absent one: it says no tools, so it
@@ -143,47 +210,15 @@ const PATH_RULE_TOOLS: Record<string, Array<'read' | 'edit' | 'write'>> = {
143
210
  * whose widening reaches everything, so it is the one enforced.
144
211
  */
145
212
  export function parseToolGrants(raw: unknown): ToolGrants | undefined {
146
- if (raw === undefined || raw === null) return undefined
147
- let items: unknown[]
148
- if (Array.isArray(raw)) items = raw
149
- else if (typeof raw === 'string') items = toolEntries(raw)
150
- else return undefined
151
- if (items.some((item) => typeof item !== 'string')) return undefined
152
-
153
- const tools: string[] = []
154
- const scopedEntries: string[] = []
155
- const bashRules: string[] = []
156
- let bashUnscoped = false
157
- const pathScopes: Record<'read' | 'edit' | 'write', string[]> = { read: [], edit: [], write: [] }
158
- const pathUnscoped = new Set<'read' | 'edit' | 'write'>()
159
- for (const item of items as string[]) {
160
- const entry = item.trim()
161
- const name = normalizeToolName(entry)
162
- if (!name) continue
163
- if (!tools.includes(name)) tools.push(name)
164
- const open = entry.indexOf('(')
165
- if (open === -1) {
166
- if (name === 'bash') bashUnscoped = true
167
- for (const tool of PATH_RULE_TOOLS[name] ?? []) pathUnscoped.add(tool)
168
- continue
169
- }
170
- scopedEntries.push(entry)
171
- const scope = entry.slice(open + 1, entry.endsWith(')') ? -1 : undefined).trim()
172
- // An empty specifier (`Bash()`, `Read()`) matches nothing and must not read as
173
- // the unscoped grant it explicitly is not: it is recorded so the tool stays
174
- // restricted, and the matchers treat an empty rule as matching no input.
175
- if (name === 'bash') bashRules.push(scope)
176
- for (const tool of PATH_RULE_TOOLS[name] ?? []) pathScopes[tool].push(scope)
177
- }
178
- const pathRules: ToolGrants['pathRules'] = {}
179
- for (const tool of ['read', 'edit', 'write'] as const) {
180
- if (!pathUnscoped.has(tool) && pathScopes[tool].length > 0) pathRules[tool] = pathScopes[tool]
181
- }
213
+ const items = coerceGrantItems(raw)
214
+ if (items === undefined) return undefined
215
+ const acc = createGrantAccumulator()
216
+ for (const item of items) addGrantEntry(acc, item)
182
217
  return {
183
- tools,
184
- scopedEntries,
185
- bashRules: !bashUnscoped && bashRules.length > 0 ? bashRules : undefined,
186
- pathRules: Object.keys(pathRules).length > 0 ? pathRules : undefined,
218
+ tools: acc.tools,
219
+ scopedEntries: acc.scopedEntries,
220
+ bashRules: !acc.bashUnscoped && acc.bashRules.length > 0 ? acc.bashRules : undefined,
221
+ pathRules: buildPathRules(acc),
187
222
  }
188
223
  }
189
224
 
@@ -193,17 +228,25 @@ const text = (value: unknown): string => {
193
228
  return typeof value === 'number' || typeof value === 'boolean' ? String(value) : ''
194
229
  }
195
230
 
231
+ /** YAML's affirmative boolean spellings. Claude documents `disable-model-invocation:
232
+ * true`, but a command file is hand-written YAML where `yes`, `on`, and `1` are all
233
+ * ordinary spellings of true, and pi's parser hands those back as the raw string or
234
+ * number rather than a boolean. A flag that gates a command off from the model has to
235
+ * honor them, or a command the user marked off-limits is silently offered to it. */
236
+ const YAML_TRUE = new Set(['true', 'yes', 'on', 'y', '1'])
237
+ const isFlagEnabled = (value: unknown): boolean => value === true || YAML_TRUE.has(text(value).toLowerCase())
238
+
196
239
  /** Claude writes `argument-hint: [pr]`, which YAML reads as a list; render it back. */
197
240
  const hint = (value: unknown): string => (Array.isArray(value) ? `[${value.join(', ')}]` : text(value))
198
241
 
199
- const ARGUMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
242
+ const ARGUMENT_NAME = /^[A-Za-z_]\w*$/
200
243
 
201
244
  /** The `arguments:` frontmatter: a YAML list or a space- or comma-separated string
202
245
  * of names mapping to positions in order. Invalid names are dropped, and ARGUMENTS
203
246
  * itself is reserved by the built-in placeholder. */
204
247
  function parseArgumentNames(raw: unknown): string[] | undefined {
205
248
  let items: string[]
206
- if (Array.isArray(raw)) items = raw.map((entry) => String(entry))
249
+ if (Array.isArray(raw)) items = raw.map(String)
207
250
  else if (typeof raw === 'string') items = raw.split(/[\s,]+/)
208
251
  else return undefined
209
252
  const names = items.map((name) => name.trim()).filter((name) => ARGUMENT_NAME.test(name) && name !== 'ARGUMENTS')
@@ -233,7 +276,7 @@ export function parseCommandFile(content: string): ParsedCommand {
233
276
  disallowedTools: parseToolGrants(frontmatter['disallowed-tools'])?.tools,
234
277
  shell: SHELLS.has(shell) ? shell : undefined,
235
278
  model: text(frontmatter.model) || undefined,
236
- disableModelInvocation: disable === true || text(disable) === 'true',
279
+ disableModelInvocation: isFlagEnabled(disable),
237
280
  body,
238
281
  }
239
282
  }
@@ -300,8 +343,8 @@ export function substituteArgsDetailed(body: string, args: string, names: string
300
343
  return value
301
344
  }
302
345
  const text = body.replaceAll(argPattern(names), (token, bracketIdx?: string, defIdx?: string, defVal?: string, argsDefault?: string, shorthandIdx?: string, name?: string) => {
303
- if (token === '\\\\') return token
304
- if (token === '\\$') return '$'
346
+ if (token === String.raw`\\`) return token
347
+ if (token === String.raw`\$`) return '$'
305
348
  if (bracketIdx !== undefined) return fill(parts[Number(bracketIdx)], token)
306
349
  if (defIdx !== undefined) return fill(parts[Number(defIdx)], defVal ?? '')
307
350
  if (argsDefault !== undefined) {
@@ -20,6 +20,16 @@ function decodeAllEntities(text: string): string {
20
20
 
21
21
  const stripInnerTags = (html: string): string => html.replace(/<[^<>]*>/g, '')
22
22
 
23
+ // Strip leading and trailing newline runs in linear time. The equivalent
24
+ // /^\n+|\n+$/g backtracks super-linearly on a long run of newlines (S8786).
25
+ const trimNewlines = (value: string): string => {
26
+ let start = 0
27
+ let end = value.length
28
+ while (start < end && value[start] === '\n') start++
29
+ while (end > start && value[end - 1] === '\n') end--
30
+ return value.slice(start, end)
31
+ }
32
+
23
33
  export function htmlToMarkdown(html: string): string {
24
34
  // Pre blocks are lifted out first so no later transform touches their content.
25
35
  const preBodies: string[] = []
@@ -27,7 +37,7 @@ export function htmlToMarkdown(html: string): string {
27
37
  .replace(/<!--[\s\S]*?-->/g, ' ')
28
38
  .replace(/<(script|style|noscript|head|svg)\b[^<>]*>[\s\S]*?<\/\1[^<>]*>/gi, ' ')
29
39
  .replace(/<pre\b[^<>]*>([\s\S]*?)<\/pre>/gi, (_whole, inner: string) => {
30
- preBodies.push(decodeAllEntities(stripInnerTags(inner)).replace(/^\n+|\n+$/g, ''))
40
+ preBodies.push(trimNewlines(decodeAllEntities(stripInnerTags(inner))))
31
41
  return `\n\n\uE000PRE${preBodies.length - 1}\uE000\n\n`
32
42
  })
33
43
 
@@ -15,7 +15,7 @@ export const INSTRUCTIONS_CHANNEL = 'pi-code:instructions'
15
15
  /** Claude's memory_type vocabulary for InstructionsLoaded payloads. */
16
16
  export type InstructionMemoryType = 'User' | 'Project' | 'Local' | 'Managed'
17
17
 
18
- const MEMORY_TYPES: readonly string[] = ['User', 'Project', 'Local', 'Managed']
18
+ const MEMORY_TYPES: ReadonlySet<string> = new Set(['User', 'Project', 'Local', 'Managed'])
19
19
 
20
20
  export interface InstructionLoadEvent {
21
21
  /** Absolute path of the instruction file that entered context. */
@@ -35,7 +35,7 @@ export function isInstructionLoadEvent(data: unknown): data is InstructionLoadEv
35
35
  const event = data as InstructionLoadEvent | null
36
36
  if (event === null || typeof event !== 'object') return false
37
37
  if (typeof event.file_path !== 'string' || typeof event.load_reason !== 'string') return false
38
- if (!MEMORY_TYPES.includes(event.memory_type)) return false
38
+ if (!MEMORY_TYPES.has(event.memory_type)) return false
39
39
  if (event.globs !== undefined && !(Array.isArray(event.globs) && event.globs.every((glob) => typeof glob === 'string'))) return false
40
40
  if (event.trigger_file_path !== undefined && typeof event.trigger_file_path !== 'string') return false
41
41
  if (event.parent_file_path !== undefined && typeof event.parent_file_path !== 'string') return false
@@ -15,7 +15,7 @@ import * as fs from 'node:fs'
15
15
  export function managedSettingsPath(platform: NodeJS.Platform = process.platform): string {
16
16
  if (platform === 'darwin') return '/Library/Application Support/ClaudeCode/managed-settings.json'
17
17
  // The legacy C:\ProgramData\ClaudeCode path was dropped in Claude Code v2.1.75.
18
- if (platform === 'win32') return 'C:\\Program Files\\ClaudeCode\\managed-settings.json'
18
+ if (platform === 'win32') return String.raw`C:\Program Files\ClaudeCode\managed-settings.json`
19
19
  return '/etc/claude-code/managed-settings.json'
20
20
  }
21
21
 
@@ -32,17 +32,21 @@ interface StoredAuth {
32
32
  * the store directory and distinct names from colliding after sanitization. */
33
33
  function storeFileFor(serverName: string): string {
34
34
  const digest = crypto.createHash('sha256').update(serverName).digest('hex').slice(0, 8)
35
- const safe =
36
- serverName
37
- .replace(/[^A-Za-z0-9_-]+/g, '-')
38
- .replace(/^-+|-+$/g, '')
39
- .slice(0, 40) || 'server'
35
+ // Collapse disallowed runs to a single hyphen, then strip leading and trailing
36
+ // hyphens by index. The old /^-+|-+$/g trim rescanned on every hyphen of a long run
37
+ // (its trailing-anchored branch backtracks per start position), which is quadratic.
38
+ const collapsed = serverName.replace(/[^A-Za-z0-9_-]+/g, '-')
39
+ let start = 0
40
+ let end = collapsed.length
41
+ while (start < end && collapsed[start] === '-') start++
42
+ while (end > start && collapsed[end - 1] === '-') end--
43
+ const safe = collapsed.slice(start, end).slice(0, 40) || 'server'
40
44
  return path.join(getAgentDir(), 'mcp-oauth', `${safe}-${digest}.json`)
41
45
  }
42
46
 
43
47
  export class FileOAuthProvider implements OAuthClientProvider {
44
48
  private readonly storePath: string
45
- private data: StoredAuth
49
+ private readonly data: StoredAuth
46
50
  private port = 0
47
51
  private readonly onRedirect: (authorizationUrl: URL) => void
48
52
 
@@ -161,7 +165,9 @@ export function waitForAuthCode(server: http.Server, timeoutMs: number): Promise
161
165
 
162
166
  /** Best-effort browser launch; the caller also surfaces the URL as text. */
163
167
  export function openBrowser(url: string): void {
164
- const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
168
+ let command = 'xdg-open'
169
+ if (process.platform === 'darwin') command = 'open'
170
+ else if (process.platform === 'win32') command = 'cmd'
165
171
  const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
166
172
  try {
167
173
  spawn(command, args, { stdio: 'ignore', detached: true }).unref()
@@ -64,6 +64,15 @@ function enabledMap(settingsFiles: string[]): Record<string, boolean> {
64
64
  return merged
65
65
  }
66
66
 
67
+ /** Copy one plugin config's `options` into `target`, coercing scalars to strings and
68
+ * ignoring the rest, later keys winning. */
69
+ function mergeOptionValues(target: Record<string, string>, options: object): void {
70
+ for (const [key, value] of Object.entries(options)) {
71
+ if (typeof value === 'string') target[key] = value
72
+ else if (typeof value === 'number' || typeof value === 'boolean') target[key] = String(value)
73
+ }
74
+ }
75
+
67
76
  /** `pluginConfigs[id].options` per plugin id, later files winning per key. */
68
77
  function pluginConfigsMap(settingsFiles: string[]): Record<string, Record<string, string>> {
69
78
  const merged: Record<string, Record<string, string>> = {}
@@ -74,10 +83,7 @@ function pluginConfigsMap(settingsFiles: string[]): Record<string, Record<string
74
83
  const options = (config as Record<string, unknown>)?.options
75
84
  if (options === null || typeof options !== 'object') continue
76
85
  const values = merged[id] ?? {}
77
- for (const [key, value] of Object.entries(options)) {
78
- if (typeof value === 'string') values[key] = value
79
- else if (typeof value === 'number' || typeof value === 'boolean') values[key] = String(value)
80
- }
86
+ mergeOptionValues(values, options)
81
87
  merged[id] = values
82
88
  }
83
89
  }
@@ -99,27 +105,34 @@ export function installedPlugins(home: string, extraSettingsFiles: string[] = []
99
105
  const plugins: InstalledPlugin[] = []
100
106
  for (const marketplace of listDirs(cacheDir)) {
101
107
  for (const pluginDir of listDirs(path.join(cacheDir, marketplace))) {
102
- const qualified = `${pluginDir}@${marketplace}`
103
- const state = enabled[qualified] ?? enabled[pluginDir]
104
- if (state !== true) continue
105
- const version = newestVersion(listDirs(path.join(cacheDir, marketplace, pluginDir)))
106
- if (!version) continue
107
- const root = path.join(cacheDir, marketplace, pluginDir, version)
108
- const manifest = readJson(path.join(root, '.claude-plugin', 'plugin.json'))
109
- const name = typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : pluginDir
110
- const id = qualified.replace(/[^A-Za-z0-9]+/g, '-')
111
- const userConfig = configs[qualified] ?? configs[pluginDir] ?? configs[name]
112
- plugins.push({ name, root, dataDir: path.join(home, '.claude', 'plugins', 'data', id), manifest, ...(userConfig ? { userConfig } : {}) })
108
+ const plugin = resolvePlugin(home, cacheDir, marketplace, pluginDir, enabled, configs)
109
+ if (plugin) plugins.push(plugin)
113
110
  }
114
111
  }
115
112
  return plugins
116
113
  }
117
114
 
115
+ /** Resolve one cached plugin directory into an enabled InstalledPlugin, or null to skip
116
+ * it: not turned on in settings, or no version directory on disk yet. */
117
+ function resolvePlugin(home: string, cacheDir: string, marketplace: string, pluginDir: string, enabled: Record<string, boolean>, configs: Record<string, Record<string, string>>): InstalledPlugin | null {
118
+ const qualified = `${pluginDir}@${marketplace}`
119
+ const state = enabled[qualified] ?? enabled[pluginDir]
120
+ if (state !== true) return null
121
+ const version = newestVersion(listDirs(path.join(cacheDir, marketplace, pluginDir)))
122
+ if (!version) return null
123
+ const root = path.join(cacheDir, marketplace, pluginDir, version)
124
+ const manifest = readJson(path.join(root, '.claude-plugin', 'plugin.json'))
125
+ const name = typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : pluginDir
126
+ const id = qualified.replace(/[^A-Za-z0-9]+/g, '-')
127
+ const userConfig = configs[qualified] ?? configs[pluginDir] ?? configs[name]
128
+ return { name, root, dataDir: path.join(home, '.claude', 'plugins', 'data', id), manifest, ...(userConfig ? { userConfig } : {}) }
129
+ }
130
+
118
131
  /** The two plugin path variables, textually substituted into plugin-shipped
119
132
  * config (hook commands, MCP server definitions, command bodies). */
120
133
  export function substitutePluginVars(value: string, plugin: InstalledPlugin): string {
121
134
  return value
122
135
  .replaceAll('${CLAUDE_PLUGIN_ROOT}', plugin.root)
123
136
  .replaceAll('${CLAUDE_PLUGIN_DATA}', plugin.dataDir)
124
- .replace(/\$\{user_config\.([A-Za-z0-9_]+)\}/g, (_, key: string) => plugin.userConfig?.[key] ?? '')
137
+ .replace(/\$\{user_config\.(\w+)\}/g, (_, key: string) => plugin.userConfig?.[key] ?? '')
125
138
  }
@@ -25,51 +25,74 @@ function fenceLength(lineStart: string, marker: string): number {
25
25
  return length
26
26
  }
27
27
 
28
+ // CommonMark closes a fenced block only with a fence of the same character that
29
+ // is at least as long as the opener, so both are tracked: a shorter same-char
30
+ // fence line (the classic 3-backtick block quoted inside a 4-backtick one) is
31
+ // content, not a closer.
32
+ interface Fence {
33
+ marker: string
34
+ length: number
35
+ }
36
+
37
+ /** The fence state after a line, plus whether the line is fenced code (opener,
38
+ * body, or closer) and so emitted verbatim rather than scanned for comments. */
39
+ function stepFence(fence: Fence | null, trimmed: string, marker: string | null): { fence: Fence | null; fenced: boolean } {
40
+ if (marker !== null && fence === null) {
41
+ return { fence: { marker, length: fenceLength(trimmed, marker) }, fenced: true }
42
+ }
43
+ if (fence !== null) {
44
+ const closes = marker === fence.marker && fenceLength(trimmed, marker) >= fence.length
45
+ return { fence: closes ? null : fence, fenced: true } // closer included: comments are content, not maintainer notes
46
+ }
47
+ return { fence, fenced: false }
48
+ }
49
+
50
+ /** Advance a line while inside an open multi-line comment. Emits any content that
51
+ * trails the closer onto its own line; returns whether the comment stays open. */
52
+ function continueOpenComment(out: string[], line: string): boolean {
53
+ const close = line.indexOf('-->')
54
+ if (close === -1) return true // still inside the comment: the line goes with it
55
+ const rest = line.slice(close + 3)
56
+ // Content trailing the closer keeps its line; a bare closer line is dropped.
57
+ if (rest.trim().length > 0) out.push(rest.trimStart())
58
+ return false
59
+ }
60
+
61
+ /** Fold line-starting HTML comments off `trimmed`; several may share one line.
62
+ * `allComment` means the line held nothing but comments and whitespace, so it is
63
+ * dropped; `opensBlock` means the last comment opened a multi-line block. */
64
+ function consumeLineComments(trimmed: string): { allComment: boolean; opensBlock: boolean } {
65
+ let rest = trimmed
66
+ let sawComment = false
67
+ while (rest.startsWith('<!--')) {
68
+ sawComment = true
69
+ const close = rest.indexOf('-->')
70
+ if (close === -1) return { allComment: true, opensBlock: true } // opens a multi-line comment
71
+ rest = rest.slice(close + 3).trimStart()
72
+ }
73
+ return { allComment: sawComment && rest.length === 0, opensBlock: false }
74
+ }
75
+
28
76
  /** Remove whole-line HTML comments, keeping fenced code and inline comments. */
29
77
  export function stripBlockComments(text: string): string {
30
78
  const out: string[] = []
31
- // CommonMark closes a fenced block only with a fence of the same character
32
- // that is at least as long as the opener, so both are tracked: a shorter
33
- // same-char fence line (the classic 3-backtick block quoted inside a
34
- // 4-backtick one) is content, not a closer.
35
- let fence: { marker: string; length: number } | null = null
79
+ let fence: Fence | null = null
36
80
  let inComment = false
37
81
  for (const line of text.split('\n')) {
38
82
  if (inComment) {
39
- const close = line.indexOf('-->')
40
- if (close === -1) continue // still inside the comment: the line goes with it
41
- inComment = false
42
- const rest = line.slice(close + 3)
43
- // Content trailing the closer keeps its line; a bare closer line is dropped.
44
- if (rest.trim().length > 0) out.push(rest.trimStart())
83
+ inComment = continueOpenComment(out, line)
45
84
  continue
46
85
  }
47
86
  const trimmed = line.trimStart()
48
- const marker = fenceMarker(trimmed)
49
- if (marker !== null && fence === null) {
50
- fence = { marker, length: fenceLength(trimmed, marker) }
87
+ const step = stepFence(fence, trimmed, fenceMarker(trimmed))
88
+ fence = step.fence
89
+ if (step.fenced) {
51
90
  out.push(line)
52
91
  continue
53
92
  }
54
- if (fence !== null) {
55
- if (marker === fence.marker && fenceLength(trimmed, marker) >= fence.length) fence = null
56
- out.push(line) // fenced code, closer included: comments are content, not maintainer notes
57
- continue
58
- }
59
- // Consume comments anchored at the line start; several may share one line.
60
- let rest = trimmed
61
- let sawComment = false
62
- while (rest.startsWith('<!--')) {
63
- sawComment = true
64
- const close = rest.indexOf('-->')
65
- if (close === -1) {
66
- inComment = true // opens a multi-line comment
67
- rest = ''
68
- break
69
- }
70
- rest = rest.slice(close + 3).trimStart()
71
- }
72
- if (sawComment && rest.length === 0) continue // the whole line was comment
93
+ const { allComment, opensBlock } = consumeLineComments(trimmed)
94
+ if (opensBlock) inComment = true
95
+ if (allComment) continue // the whole line was comment
73
96
  // No comment, or a line-starting comment followed by content: inline, verbatim.
74
97
  out.push(line)
75
98
  }