pi-code 1.0.5 → 1.0.7
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/extensions/commands.ts +303 -85
- package/extensions/context-imports.ts +162 -91
- package/extensions/git-checkpoint.ts +40 -0
- package/extensions/hooks.ts +203 -36
- package/extensions/internal/command-file.ts +91 -48
- package/extensions/internal/html-markdown.ts +11 -1
- package/extensions/internal/instruction-events.ts +2 -2
- package/extensions/internal/managed-settings.ts +1 -1
- package/extensions/internal/mcp-oauth.ts +44 -8
- package/extensions/internal/path-rules.ts +69 -2
- package/extensions/internal/plugins.ts +29 -16
- package/extensions/internal/strip-comments.ts +56 -33
- package/extensions/mcp.ts +462 -84
- package/extensions/memory.ts +29 -19
- package/extensions/notify.ts +1 -2
- package/extensions/status-line.ts +8 -3
- package/extensions/subagent/background.ts +11 -0
- package/extensions/subagent/index.ts +119 -5
- package/extensions/web.ts +39 -26
- package/package.json +1 -1
|
@@ -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:
|
|
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.
|
|
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
|
|
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,19 +32,28 @@ 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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
52
|
+
// A fresh random CSRF token per login attempt. The SDK puts it in the authorization
|
|
53
|
+
// URL's `state` param, the server echoes it back on the redirect, and waitForAuthCode
|
|
54
|
+
// rejects any callback that does not carry it, so another local process or an open web
|
|
55
|
+
// page cannot inject an authorization code into this login (RFC 8252 8.9).
|
|
56
|
+
private readonly loginState = crypto.randomBytes(16).toString('hex')
|
|
48
57
|
|
|
49
58
|
constructor(serverName: string, onRedirect: (authorizationUrl: URL) => void) {
|
|
50
59
|
this.storePath = storeFileFor(serverName)
|
|
@@ -113,6 +122,12 @@ export class FileOAuthProvider implements OAuthClientProvider {
|
|
|
113
122
|
return this.data.tokens !== undefined
|
|
114
123
|
}
|
|
115
124
|
|
|
125
|
+
/** The CSRF token the SDK adds to the authorization URL as `state`; waitForAuthCode
|
|
126
|
+
* verifies the redirect echoes exactly this value. */
|
|
127
|
+
state(): string {
|
|
128
|
+
return this.loginState
|
|
129
|
+
}
|
|
130
|
+
|
|
116
131
|
redirectToAuthorization(authorizationUrl: URL): void {
|
|
117
132
|
this.onRedirect(authorizationUrl)
|
|
118
133
|
}
|
|
@@ -143,11 +158,30 @@ export async function startCallbackServer(preferredPort?: number): Promise<{ ser
|
|
|
143
158
|
return { server, port: (server.address() as { port: number }).port }
|
|
144
159
|
}
|
|
145
160
|
|
|
146
|
-
export function waitForAuthCode(server: http.Server, timeoutMs: number): Promise<string> {
|
|
161
|
+
export function waitForAuthCode(server: http.Server, timeoutMs: number, expectedState?: string): Promise<string> {
|
|
147
162
|
return new Promise((resolve, reject) => {
|
|
148
163
|
const timer = setTimeout(() => reject(new Error(`authorization timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
164
|
+
// Do not let the pending timer keep the process alive on its own: if the login is
|
|
165
|
+
// abandoned or resolved out of band, the event loop can still drain.
|
|
166
|
+
timer.unref?.()
|
|
149
167
|
server.on('request', (request, response) => {
|
|
150
168
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1')
|
|
169
|
+
// Only the redirect path settles the login. A stray request (a favicon fetch, a
|
|
170
|
+
// local port scan, or a forged redirect from another process or an open web page)
|
|
171
|
+
// is answered but ignored, so it can neither inject a code nor abort the login by
|
|
172
|
+
// rejecting the promise (a repeatable DoS on a stable, guessable loopback port).
|
|
173
|
+
if (url.pathname !== '/callback') {
|
|
174
|
+
response.writeHead(404, { 'content-type': 'text/plain' })
|
|
175
|
+
response.end('not found')
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
// The CSRF check: a callback that does not echo this login's state is rejected
|
|
179
|
+
// without settling, so an attacker who cannot read the state cannot complete it.
|
|
180
|
+
if (expectedState !== undefined && url.searchParams.get('state') !== expectedState) {
|
|
181
|
+
response.writeHead(400, { 'content-type': 'text/plain' })
|
|
182
|
+
response.end('state mismatch')
|
|
183
|
+
return
|
|
184
|
+
}
|
|
151
185
|
const code = url.searchParams.get('code')
|
|
152
186
|
const error = url.searchParams.get('error')
|
|
153
187
|
response.writeHead(200, { 'content-type': 'text/html' })
|
|
@@ -161,7 +195,9 @@ export function waitForAuthCode(server: http.Server, timeoutMs: number): Promise
|
|
|
161
195
|
|
|
162
196
|
/** Best-effort browser launch; the caller also surfaces the URL as text. */
|
|
163
197
|
export function openBrowser(url: string): void {
|
|
164
|
-
|
|
198
|
+
let command = 'xdg-open'
|
|
199
|
+
if (process.platform === 'darwin') command = 'open'
|
|
200
|
+
else if (process.platform === 'win32') command = 'cmd'
|
|
165
201
|
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
|
|
166
202
|
try {
|
|
167
203
|
spawn(command, args, { stdio: 'ignore', detached: true }).unref()
|
|
@@ -21,8 +21,66 @@ export interface PathAnchors {
|
|
|
21
21
|
|
|
22
22
|
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/** Cap on brace-expanded alternatives per pattern, mirroring Claude's ~1000
|
|
25
|
+
* budget; an over-budget pattern is used unexpanded. */
|
|
26
|
+
const BRACE_EXPANSION_LIMIT = 1000
|
|
27
|
+
|
|
28
|
+
interface BraceGroup {
|
|
29
|
+
start: number
|
|
30
|
+
end: number
|
|
31
|
+
options: string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The `{...}` group opening at `open`, or null when it is unmatched or carries no
|
|
35
|
+
* top-level comma (literal braces). Options are split on commas at the group's own
|
|
36
|
+
* depth so a nested group stays inside one option. */
|
|
37
|
+
function parseBraceGroup(pattern: string, open: number): BraceGroup | null {
|
|
38
|
+
let depth = 1
|
|
39
|
+
let optionStart = open + 1
|
|
40
|
+
const options: string[] = []
|
|
41
|
+
for (let i = open + 1; i < pattern.length; i += 1) {
|
|
42
|
+
const ch = pattern[i]
|
|
43
|
+
if (ch === '{') depth += 1
|
|
44
|
+
else if (ch === ',' && depth === 1) {
|
|
45
|
+
options.push(pattern.slice(optionStart, i))
|
|
46
|
+
optionStart = i + 1
|
|
47
|
+
} else if (ch === '}' && --depth === 0) {
|
|
48
|
+
if (options.length === 0) return null // no top-level comma: literal braces
|
|
49
|
+
options.push(pattern.slice(optionStart, i))
|
|
50
|
+
return { start: open, end: i, options }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The first expandable `{...}` group. A comma-less or unmatched `{` is skipped as
|
|
57
|
+
* literal, so the scan can still find an expandable group nested inside it. */
|
|
58
|
+
function findBraceGroup(pattern: string): BraceGroup | null {
|
|
59
|
+
for (let open = pattern.indexOf('{'); open !== -1; open = pattern.indexOf('{', open + 1)) {
|
|
60
|
+
const group = parseBraceGroup(pattern, open)
|
|
61
|
+
if (group) return group
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Bash-style brace expansion of one pattern into its alternatives: each group
|
|
67
|
+
* multiplies out (Cartesian across groups, nested groups recurse). Returns null
|
|
68
|
+
* when the expansion would exceed the budget. */
|
|
69
|
+
function expandBraces(pattern: string): string[] | null {
|
|
70
|
+
const group = findBraceGroup(pattern)
|
|
71
|
+
if (group === null) return [pattern]
|
|
72
|
+
const expanded: string[] = []
|
|
73
|
+
for (const option of group.options) {
|
|
74
|
+
const branch = expandBraces(pattern.slice(0, group.start) + option + pattern.slice(group.end + 1))
|
|
75
|
+
if (branch === null) return null
|
|
76
|
+
expanded.push(...branch)
|
|
77
|
+
if (expanded.length > BRACE_EXPANSION_LIMIT) return null
|
|
78
|
+
}
|
|
79
|
+
return expanded
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One glob pattern, braces already expanded, as a regular expression source. */
|
|
83
|
+
function translateGlob(pattern: string): string {
|
|
26
84
|
let out = ''
|
|
27
85
|
let i = 0
|
|
28
86
|
while (i < pattern.length) {
|
|
@@ -54,6 +112,15 @@ export function globToRegExpSource(pattern: string): string {
|
|
|
54
112
|
return out
|
|
55
113
|
}
|
|
56
114
|
|
|
115
|
+
/** One gitignore-style pattern as an anchored regular expression source. Brace
|
|
116
|
+
* groups (`{ts,tsx}`, nested, Cartesian across groups) expand into ORed
|
|
117
|
+
* alternatives; an over-budget expansion falls back to the literal pattern. */
|
|
118
|
+
export function globToRegExpSource(pattern: string): string {
|
|
119
|
+
const alternatives = expandBraces(pattern) ?? [pattern]
|
|
120
|
+
if (alternatives.length === 1) return translateGlob(alternatives[0])
|
|
121
|
+
return `(?:${alternatives.map(translateGlob).join('|')})`
|
|
122
|
+
}
|
|
123
|
+
|
|
57
124
|
/** A rule resolved to an absolute glob per its anchor form. */
|
|
58
125
|
function resolveRule(rule: string, anchors: PathAnchors): string {
|
|
59
126
|
if (rule.startsWith('//')) return rule.slice(1)
|
|
@@ -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
|
-
|
|
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
|
|
103
|
-
|
|
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\.(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
}
|