veodl 1.10.0 → 1.11.0

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.
@@ -1,4 +1,4 @@
1
- import { CONFIG_KEYS, RESERVED_PROFILE_NAMES, parseConfigText, effectiveConfig, applyProfile } from './config.js';
1
+ import { CONFIG_KEYS, RESERVED_PROFILE_NAMES, parseConfigText, effectiveConfig, applyProfile } from './config.js';
2
2
  import { stripConfigComments } from './config-template.js';
3
3
  import { QUALITIES, AUDIO_FORMATS, VIDEO_FORMATS } from './utils.js';
4
4
 
@@ -64,7 +64,7 @@ function valueChoices(key, config = {}) {
64
64
  if (key === 'format') return config.audio ? AUDIO_FORMATS : VIDEO_FORMATS;
65
65
  if (key === 'timeout') return ['30s', '45s', '1m', '2m', '5m'];
66
66
  if (['concurrentDownloads', 'playlistConcurrency'].includes(key)) return [1, 2, 3, 4];
67
- if (key === 'concurrentFragments') return Array.from({ length: 16 }, (_, i) => i + 1);
67
+ if (key === 'concurrentFragments') return Array.from({ length: 64 }, (_, i) => i + 1);
68
68
  return [];
69
69
  }
70
70
 
@@ -75,7 +75,7 @@ export async function semanticIssue(text) {
75
75
  if (!object(data)) return located(text, node([]), 'The config must contain a JSON object.');
76
76
  const check = (settings, path) => {
77
77
  for (const [key, value] of Object.entries(settings)) {
78
- if (!path.length && ['$schema', 'profiles', 'activeProfile'].includes(key)) continue;
78
+ if (!path.length && ['$schema', 'profiles', 'activeProfile'].includes(key)) continue;
79
79
  const target = node([...path, key]);
80
80
  if (!Object.hasOwn(CONFIG_KEYS, key)) {
81
81
  const proposed = suggestion(key, path.length ? keys : [...keys, 'profiles', '$schema']);
@@ -90,22 +90,22 @@ export async function semanticIssue(text) {
90
90
  };
91
91
  let issue = check(data, []);
92
92
  if (issue) return issue;
93
- if (Object.hasOwn(data, 'profiles')) {
94
- if (!object(data.profiles)) return located(text, node(['profiles']), 'Profiles must be an object.');
95
- for (const [name, profile] of Object.entries(data.profiles)) {
96
- if (RESERVED_PROFILE_NAMES.includes(name)) {
97
- return located(text, node(['profiles', name]), `Profile name "${name}" is reserved for veo profile ${name}. Rename this profile.`, true);
98
- }
99
- if (!object(profile)) return located(text, node(['profiles', name]), `Profile "${name}" must be an object.`);
93
+ if (Object.hasOwn(data, 'profiles')) {
94
+ if (!object(data.profiles)) return located(text, node(['profiles']), 'Profiles must be an object.');
95
+ for (const [name, profile] of Object.entries(data.profiles)) {
96
+ if (RESERVED_PROFILE_NAMES.includes(name)) {
97
+ return located(text, node(['profiles', name]), `Profile name "${name}" is reserved for veo profile ${name}. Rename this profile.`, true);
98
+ }
99
+ if (!object(profile)) return located(text, node(['profiles', name]), `Profile "${name}" must be an object.`);
100
100
  issue = check(profile, ['profiles', name]); if (issue) return issue;
101
101
  }
102
- }
103
- if (Object.hasOwn(data, 'activeProfile')) {
104
- if (typeof data.activeProfile !== 'string' || !data.activeProfile) return located(text, node(['activeProfile']), '"activeProfile" must be a non-empty profile name.');
105
- if (!object(data.profiles) || !Object.hasOwn(data.profiles, data.activeProfile)) {
106
- return located(text, node(['activeProfile']), `Unknown active profile "${data.activeProfile}". Choose: ${Object.keys(data.profiles || {}).join(', ') || 'none'}.`, false, Object.keys(data.profiles || {}));
107
- }
108
- }
102
+ }
103
+ if (Object.hasOwn(data, 'activeProfile')) {
104
+ if (typeof data.activeProfile !== 'string' || !data.activeProfile) return located(text, node(['activeProfile']), '"activeProfile" must be a non-empty profile name.');
105
+ if (!object(data.profiles) || !Object.hasOwn(data.profiles, data.activeProfile)) {
106
+ return located(text, node(['activeProfile']), `Unknown active profile "${data.activeProfile}". Choose: ${Object.keys(data.profiles || {}).join(', ') || 'none'}.`, false, Object.keys(data.profiles || {}));
107
+ }
108
+ }
109
109
  const { config } = parseConfigText(text);
110
110
  for (const profile of [undefined, ...Object.keys(config.profiles || {})]) {
111
111
  try { await effectiveConfig(config, profile); }
@@ -114,7 +114,7 @@ export async function semanticIssue(text) {
114
114
  let key = /Invalid quality/.test(message) ? 'quality' : /Invalid (?:audio|video) format/.test(message) ? 'format' :
115
115
  /output directory/.test(message) ? 'output' : /custom filename/.test(message) ? 'rename' :
116
116
  /--([a-z-]+)/.exec(message)?.[1]?.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
117
- const name = profile ?? config.activeProfile ?? (config.profiles?.default ? 'default' : undefined);
117
+ const name = profile ?? config.activeProfile ?? (config.profiles?.default ? 'default' : undefined);
118
118
  const selected = applyProfile(config, profile);
119
119
  // Template errors do not name the CLI option. Locate the failing template.
120
120
  if (!key) {
@@ -143,8 +143,8 @@ export function editorCompletions(text, cursor) {
143
143
  const key = path.at(-1), property = cursor >= item.keyStart && cursor <= item.keyEnd;
144
144
  if (!property && !(cursor >= item.start && cursor <= item.end)) continue;
145
145
  const profile = path.length === 3 ? path[1] : undefined;
146
- const choices = property ? (path.length === 1 ? [...keys, 'profiles', 'activeProfile', '$schema'] : keys) :
147
- key === 'activeProfile' ? Object.keys(data.profiles || {}) : valueChoices(key, applyProfile(data, profile));
146
+ const choices = property ? (path.length === 1 ? [...keys, 'profiles', 'activeProfile', '$schema'] : keys) :
147
+ key === 'activeProfile' ? Object.keys(data.profiles || {}) : valueChoices(key, applyProfile(data, profile));
148
148
  if (!choices.length) return null;
149
149
  const proposed = property ? suggestion(key, choices) : null;
150
150
  return { start: property ? item.keyStart : item.start, end: property ? item.keyEnd : item.end,
@@ -6,6 +6,7 @@ import { parseConfigText } from './config.js';
6
6
  import { semanticIssue, editorCompletions } from './config-diagnostics.js';
7
7
  import { configClipboard } from './config-clipboard.js';
8
8
  import { addConfigGuide, configGuideState, removeConfigGuide } from './config-template.js';
9
+ import { folderLink } from './path-links.js';
9
10
 
10
11
  const safe = text => text.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
11
12
 
@@ -118,19 +119,18 @@ export async function editConfig(file, { input = process.stdin, output = process
118
119
  let status = guideState === 'outdated' ? 'Updated config guide available. Press F3 to choose.' :
119
120
  guideState === 'current' ? 'Generated guide in file. Press F3 to remove it if duplicated.' : '', revision = 0;
120
121
  let viewport, dragging = false, completion = null;
121
- const wasRaw = input.isRaw, wasPaused = input.isPaused();
122
+ const wasRaw = input.isRaw, wasPaused = input.isPaused(), wasFlowing = input.readableFlowing;
122
123
  const render = () => {
123
124
  if (closed) return;
124
125
  const width = Math.max(1, (output.columns || 80) - 1);
125
126
  const diagnosis = issue ? `${issue.line ? `Line ${issue.line}, column ${issue.column}: ` : ''}${issue.message}` : '';
126
127
  const detail = safe(completion ? `Options (${completion.index + 1}/${completion.choices.length}): ${JSON.stringify(completion.choices[completion.index])} | Up/Down choose, Enter apply, Esc cancel` :
127
128
  status ? `${status}${diagnosis ? ` | ${diagnosis}` : ''}` : diagnosis || 'Config OK');
128
- const detailRows = Math.min(3, Math.max(1, Math.ceil(detail.length / width)));
129
129
  const helpLines = question ? ['Discard unsaved changes? Y = discard, N / Esc = keep editing'] :
130
130
  guidePrompt ? ['Guide: A add/update | R remove | Esc cancel'] :
131
131
  ['F3 Guide | F2 Options | Ctrl+S Save | Ctrl+Z Undo | Ctrl+Y Redo',
132
132
  'Ctrl+A All | Ctrl+C Copy | Ctrl+V Paste | Esc / Ctrl+Q Exit'];
133
- const height = Math.max(1, (output.rows || 24) - 1 - helpLines.length - detailRows);
133
+ const height = Math.max(1, (output.rows || 24) - 3);
134
134
  const lines = buffer.text.split('\n'), { row, col } = buffer.position;
135
135
  const gutter = Math.min(width - 1, String(lines.length).length + 2), available = Math.max(1, width - gutter);
136
136
  if (followCursor) {
@@ -142,7 +142,9 @@ export async function editConfig(file, { input = process.stdin, output = process
142
142
  viewport = { height, gutter, width };
143
143
  const [selectionStart, selectionEnd] = buffer.selection;
144
144
  let offset = lines.slice(0, top).reduce((sum, line) => sum + line.length + 1, 0);
145
- const screen = [safe(`veo config | ${file}${buffer.text !== saved ? ' *' : ''}`).slice(0, width)];
145
+ const heading = safe(`veo config | ${file}${buffer.text !== saved ? ' *' : ''}`).slice(0, width);
146
+ const pathLabel = heading.slice(13, Math.min(heading.length, 13 + safe(file).length));
147
+ const screen = [heading.slice(0, 13) + folderLink(output, file, pathLabel) + heading.slice(13 + pathLabel.length)];
146
148
  for (let index = top; index < top + height; index++) {
147
149
  const line = lines[index];
148
150
  const prefix = line === undefined ? '' : `${index + 1 === issue?.line ? '!' : ' '}${String(index + 1).padStart(Math.max(0, gutter - 2))} `;
@@ -157,18 +159,18 @@ export async function editConfig(file, { input = process.stdin, output = process
157
159
  offset += (line || '').length + 1;
158
160
  }
159
161
  const position = `Ln ${row + 1}, Col ${col + 1}`;
160
- for (let index = 0; index < helpLines.length; index++) {
161
- const last = index === helpLines.length - 1;
162
- const helpWidth = Math.max(0, width - (last ? position.length + 1 : 0));
163
- const visibleHelp = safe(helpLines[index]).slice(0, helpWidth);
164
- const footer = last ? visibleHelp + ' '.repeat(Math.max(1, width - position.length - visibleHelp.length)) + position : visibleHelp;
165
- screen.push(color ? footer.replace(/Ctrl\+[A-Z]|F[23]|Esc|\b[A,R]\b/g, match => `\x1b[1;36m${match}\x1b[0m`) : footer);
166
- }
167
- for (let index = 0; index < detailRows; index++) {
168
- const line = detail.slice(index * width, (index + 1) * width);
169
- const tone = completion ? 33 : issue ? 31 : status ? 32 : 90;
170
- screen.push(color && line ? `\x1b[${tone}m${line}\x1b[0m` : line);
171
- }
162
+ const tone = completion ? 33 : issue ? 31 : status ? 32 : 90;
163
+ const styledDetail = value => color ? `\x1b[${tone}m${value}\x1b[0m` : value;
164
+ const styledHelp = value => color ? value.replace(/Ctrl\+[A-Z]|F[23]|Esc|\b[A,R]\b/g, match => `\x1b[1;36m${match}\x1b[0m`) : value;
165
+ const prompt = question || guidePrompt;
166
+ const longDetail = prompt || detail.length + position.length + 3 > Math.max(30, Math.floor(width / 2));
167
+ screen.push(prompt ? styledHelp(safe(helpLines[0]).slice(0, width)) :
168
+ longDetail ? styledDetail(detail.slice(0, width)) : styledHelp(safe(helpLines[0]).slice(0, width)));
169
+ const right = (longDetail ? '' : `${detail} | `) + position;
170
+ const helpWidth = Math.max(0, width - right.length - 1);
171
+ const help = safe(helpLines[1] || '').slice(0, helpWidth);
172
+ const padding = ' '.repeat(Math.max(0, width - help.length - right.length));
173
+ screen.push(styledHelp(help) + padding + (longDetail ? position : styledDetail(detail) + ' | ' + position));
172
174
  const cursorVisible = row >= top && row < top + height;
173
175
  output.write('\x1b[?25l\x1b[H' + screen.map(line => line + '\x1b[K').join('\r\n') +
174
176
  (cursorVisible ? `\x1b[${row - top + 2};${gutter + col - left + 1}H\x1b[?25h` : ''));
@@ -253,7 +255,9 @@ export async function editConfig(file, { input = process.stdin, output = process
253
255
  closed = true; clearTimeout(timer); clearTimeout(mouseTimer); stopDrag();
254
256
  input.off('data', onData); keyboard.off('keypress', onKey); keyboard.destroy();
255
257
  input.off('keypress', onKey); input.off('end', onEnd); input.off('error', onError); output.off('resize', render);
256
- input.setRawMode(Boolean(wasRaw)); if (wasPaused) input.pause();
258
+ input.setRawMode(Boolean(wasRaw));
259
+ // A fresh stdin has no explicit pause, but resume() still keeps its handle alive.
260
+ if (wasPaused || wasFlowing !== true) input.pause();
257
261
  output.write('\x1b[?1002l\x1b[?1006l\x1b[0m\x1b[?25h\x1b[?1049l');
258
262
  error ? reject(error) : resolve();
259
263
  };
@@ -7,11 +7,11 @@ export const SOURCE_OPTIONS_GUIDE = String.raw`// veo: source discovery options
7
7
  // "deepScan": true, // Verify every observed media candidate instead of the first 30
8
8
  // "timeout": "2m", // Discovery deadline: 5s to 10m; plain numbers mean seconds
9
9
  // "listSources": false, // true = list sources and exit for single-URL calls
10
- // "autoListSources": true, // Search after extraction fails; false disables fallback
10
+ // "autoListSources": true, // Search after extraction fails; false disables fallback
11
11
  // Command-line flags override these values, including --no-deep-scan and --no-list-sources.
12
12
  `;
13
13
 
14
- const PROFILE_OPTIONS_MARKER = '// veo: profile download options v4';
14
+ const PROFILE_OPTIONS_MARKER = '// veo: profile download options v5';
15
15
  export const PROFILE_OPTIONS_GUIDE = String.raw`${PROFILE_OPTIONS_MARKER}
16
16
  // Any global option can also be placed inside a profile.
17
17
  // Copy the settings you want into "profiles" > "default" or a named profile.
@@ -20,11 +20,14 @@ export const PROFILE_OPTIONS_GUIDE = String.raw`${PROFILE_OPTIONS_MARKER}
20
20
  // "quality": "best",
21
21
  // "concurrentDownloads": 2, // Parallel URLs/batch items: 1-4; default 2
22
22
  // "adaptiveConcurrency": true, // Reduce parallelism and retry transient errors
23
- // "concurrentFragments": 8, // DASH/HLS fragments: 1-16; default 8
23
+ // "concurrentFragments": 8, // DASH/HLS fragments: 1-64; default 8
24
+ // Values 17-64 are experimental and require terminal confirmation before download.
25
+ // "experimentalFragments": false, // true explicitly accepts 17-64, including in scripts
26
+ // More connections may trigger server limits and do not guarantee better speed.
24
27
  // "deepScan": true, // Check every observed source candidate
25
28
  // "timeout": "2m", // Source-search deadline, 5s to 10m
26
29
  // "listSources": false, // List sources and exit for one URL
27
- // "autoListSources": true, // Search after extraction fails (default)
30
+ // "autoListSources": true, // Search after extraction fails (default)
28
31
  // "playlist": true, // Enable only for playlist downloads
29
32
  // "playlistConcurrency": 2, // Simultaneous entries: 1-4; default 2
30
33
  // "checkSpace": true, // Estimate free cache/output space first
@@ -37,7 +40,10 @@ export const PROFILE_OPTIONS_GUIDE = String.raw`${PROFILE_OPTIONS_MARKER}
37
40
  // "folderTemplate": "{channel}/{year}",
38
41
  // "filenameTemplate": "{index} - {title}", // Without extension; do not combine with rename
39
42
  // "resume": true,
43
+ // "incognito": false, // Save only media; no run ID, history, stats or retry data
44
+ // "neutralFilename": false, // Optional random filenames; ignores rename and naming templates
40
45
  // "skipExisting": true
46
+ // "verify": false, // Probe saved media with FFprobe; no full decode
41
47
  // Lossless container change (the selected codecs must fit the container):
42
48
  // "format": "mkv",
43
49
  // "compatible": false, // Opt-in H.264/AAC MP4; converts only when needed
@@ -79,8 +85,13 @@ const TEMPLATE_SOURCE = String.raw`${TEMPLATE_MARKER}
79
85
  // "section": "*10:00-12:00", // Download only a time range
80
86
  // "open": false, // Open the completed file automatically
81
87
  // "resume": true, // Resume interrupted downloads
88
+ // "incognito": true, // Save only media; no run ID, history, stats or retry data
89
+ // "neutralFilename": false, // Set true for random filenames without title or template folders
82
90
  // "skipExisting": true, // Skip previously saved downloads
83
- // "concurrentFragments": 8, // Concurrent fragments: 1 to 16; default 8
91
+ // "verify": false, // Check saved media container and tracks with FFprobe
92
+ // "concurrentFragments": 8, // Concurrent fragments: 1 to 64; default 8
93
+ // Values above 16 require confirmation or the explicit opt-in below.
94
+ // "experimentalFragments": false, // true accepts experimental values 17-64
84
95
  // "cookies": "D:/cookies.txt", // Netscape-format cookies file
85
96
  // "cookiesFromBrowser": "firefox", // Use your browser's cookies
86
97
  // "sponsorblockRemove": "sponsor", // Remove matching SponsorBlock segments
@@ -90,15 +101,17 @@ ${SOURCE_OPTIONS_GUIDE}
90
101
 
91
102
  // Subtitles and additional information:
92
103
  // "subs": true, // Download subtitles; defaults to English
104
+ // "autoSubs": false, // Include automatically generated subtitles
93
105
  // "subLangs": "de,en", // Enable subtitles for these languages
106
+ // "subFormat": "srt/vtt/best", // Prefer these available subtitle formats
94
107
  // "embedSubs": true, // Embed subtitles in the video
95
108
  // "embedMetadata": true, // Embed the title, date and other metadata
96
109
  // "embedThumbnail": true, // Embed the thumbnail
97
110
 
98
- // The default profile is used automatically unless you select another profile.
99
- // Use veo profile NAME to make any named profile the default for future commands.
100
- // The active choice is stored at the top level: "activeProfile": "music",
101
- ${PROFILE_OPTIONS_GUIDE}
111
+ // The default profile is used automatically unless you select another profile.
112
+ // Use veo profile NAME to make any named profile the default for future commands.
113
+ // The active choice is stored at the top level: "activeProfile": "music",
114
+ ${PROFILE_OPTIONS_GUIDE}
102
115
  // Select other profiles with --profile NAME or in the interactive wizard.
103
116
  // Example: veo "https://example.com/video.mp4" --profile music
104
117
  "profiles": {
@@ -127,62 +140,62 @@ ${PROFILE_OPTIONS_GUIDE}
127
140
  }
128
141
  `;
129
142
 
130
- // The guide revision follows its content so the editor can offer an update.
143
+ // The guide revision follows its content so the editor can offer an update.
131
144
  const TEMPLATE_REVISION = createHash('sha256').update(TEMPLATE_SOURCE).digest('hex').slice(0, 12);
132
- const REFERENCE_BEGIN = '// veo: template reference begin';
133
- const REFERENCE_END = '// veo: template reference end';
134
- export const CONFIG_TEMPLATE = `{
135
- "profiles": {
136
- "default": {},
137
- "kompatibel": { "audio": false, "format": "mp4", "compatible": true, "recode": false },
138
- "music": { "audio": true, "format": "mp3" },
139
- "archive": { "quality": "1080p", "embedMetadata": true, "subLangs": "de,en" }
140
- }
141
- }
142
- `;
143
-
144
- // Kept in the program. It is inserted into a config only when requested.
145
- export const CONFIG_GUIDE = `${REFERENCE_BEGIN} ${TEMPLATE_REVISION}\n// Current veo options. Copy only the settings you want into the JSON below.\n${TEMPLATE_SOURCE.split('\n').slice(1).map(line => line.startsWith('//') ? line : `// ${line}`).join('\n').trimEnd()}\n${REFERENCE_END}\n`;
146
-
147
- function generatedRange(text) {
148
- const begin = text.indexOf(REFERENCE_BEGIN);
149
- const end = begin < 0 ? -1 : text.indexOf(REFERENCE_END, begin);
150
- if (begin < 0) {
151
- const legacyHeader = /^\uFEFF?\/\/ veo: commented configuration template\r?\n\/\/ Reference: copy any examples/;
152
- const settingsMarker = '// Your existing settings:';
153
- const markerAt = legacyHeader.test(text) ? text.indexOf(settingsMarker) : -1;
154
- if (markerAt < 0) return null;
155
- const lineEnd = text.indexOf('\n', markerAt);
156
- return { start: 0, stop: lineEnd < 0 ? text.length : lineEnd + 1, revision: 'legacy' };
157
- }
158
- if (end < 0) return null;
159
- let start = begin;
160
- const before = text.slice(0, begin);
161
- if (/^\uFEFF?\/\/ veo: commented configuration template\r?\n$/.test(before)) start = 0;
162
- const lineEnd = text.indexOf('\n', end);
163
- let stop = lineEnd < 0 ? text.length : lineEnd + 1;
164
- if (text.slice(stop).startsWith('// Your existing settings:\n')) stop += '// Your existing settings:\n'.length;
165
- return { start, stop, revision: text.slice(begin, text.indexOf('\n', begin) < 0 ? text.length : text.indexOf('\n', begin)) };
166
- }
167
-
168
- export function configGuideState(text) {
169
- const range = generatedRange(text);
170
- if (!range) return text.includes(REFERENCE_BEGIN) || text.includes('// Reference: copy any examples') ? 'incomplete' : 'absent';
171
- return range.revision === `${REFERENCE_BEGIN} ${TEMPLATE_REVISION}` ? 'current' : 'outdated';
172
- }
173
-
174
- export function addConfigGuide(text) {
175
- const range = generatedRange(text);
176
- if (!range && configGuideState(text) === 'incomplete') return text;
177
- const bom = text.startsWith('\uFEFF') ? '\uFEFF' : '';
178
- if (range) return (range.start === 0 ? bom : text.slice(0, range.start)) + CONFIG_GUIDE + text.slice(range.stop);
179
- return bom + CONFIG_GUIDE + text.slice(bom.length);
180
- }
181
-
182
- export function removeConfigGuide(text) {
183
- const range = generatedRange(text);
184
- return range ? (range.start === 0 && text.startsWith('\uFEFF') ? '\uFEFF' : text.slice(0, range.start)) + text.slice(range.stop) : text;
185
- }
145
+ const REFERENCE_BEGIN = '// veo: template reference begin';
146
+ const REFERENCE_END = '// veo: template reference end';
147
+ export const CONFIG_TEMPLATE = `{
148
+ "profiles": {
149
+ "default": {},
150
+ "kompatibel": { "audio": false, "format": "mp4", "compatible": true, "recode": false },
151
+ "music": { "audio": true, "format": "mp3" },
152
+ "archive": { "quality": "1080p", "embedMetadata": true, "subLangs": "de,en" }
153
+ }
154
+ }
155
+ `;
156
+
157
+ // Kept in the program. It is inserted into a config only when requested.
158
+ export const CONFIG_GUIDE = `${REFERENCE_BEGIN} ${TEMPLATE_REVISION}\n// Current veo options. Copy only the settings you want into the JSON below.\n${TEMPLATE_SOURCE.split('\n').slice(1).map(line => line.startsWith('//') ? line : `// ${line}`).join('\n').trimEnd()}\n${REFERENCE_END}\n`;
159
+
160
+ function generatedRange(text) {
161
+ const begin = text.indexOf(REFERENCE_BEGIN);
162
+ const end = begin < 0 ? -1 : text.indexOf(REFERENCE_END, begin);
163
+ if (begin < 0) {
164
+ const legacyHeader = /^\uFEFF?\/\/ veo: commented configuration template\r?\n\/\/ Reference: copy any examples/;
165
+ const settingsMarker = '// Your existing settings:';
166
+ const markerAt = legacyHeader.test(text) ? text.indexOf(settingsMarker) : -1;
167
+ if (markerAt < 0) return null;
168
+ const lineEnd = text.indexOf('\n', markerAt);
169
+ return { start: 0, stop: lineEnd < 0 ? text.length : lineEnd + 1, revision: 'legacy' };
170
+ }
171
+ if (end < 0) return null;
172
+ let start = begin;
173
+ const before = text.slice(0, begin);
174
+ if (/^\uFEFF?\/\/ veo: commented configuration template\r?\n$/.test(before)) start = 0;
175
+ const lineEnd = text.indexOf('\n', end);
176
+ let stop = lineEnd < 0 ? text.length : lineEnd + 1;
177
+ if (text.slice(stop).startsWith('// Your existing settings:\n')) stop += '// Your existing settings:\n'.length;
178
+ return { start, stop, revision: text.slice(begin, text.indexOf('\n', begin) < 0 ? text.length : text.indexOf('\n', begin)) };
179
+ }
180
+
181
+ export function configGuideState(text) {
182
+ const range = generatedRange(text);
183
+ if (!range) return text.includes(REFERENCE_BEGIN) || text.includes('// Reference: copy any examples') ? 'incomplete' : 'absent';
184
+ return range.revision === `${REFERENCE_BEGIN} ${TEMPLATE_REVISION}` ? 'current' : 'outdated';
185
+ }
186
+
187
+ export function addConfigGuide(text) {
188
+ const range = generatedRange(text);
189
+ if (!range && configGuideState(text) === 'incomplete') return text;
190
+ const bom = text.startsWith('\uFEFF') ? '\uFEFF' : '';
191
+ if (range) return (range.start === 0 ? bom : text.slice(0, range.start)) + CONFIG_GUIDE + text.slice(range.stop);
192
+ return bom + CONFIG_GUIDE + text.slice(bom.length);
193
+ }
194
+
195
+ export function removeConfigGuide(text) {
196
+ const range = generatedRange(text);
197
+ return range ? (range.start === 0 && text.startsWith('\uFEFF') ? '\uFEFF' : text.slice(0, range.start)) + text.slice(range.stop) : text;
198
+ }
186
199
 
187
200
  // Remove comments only outside JSON strings. Whitespace replacement preserves
188
201
  // token boundaries, so malformed input cannot become valid by joining tokens.
@@ -210,5 +223,5 @@ export function stripConfigComments(text) {
210
223
  if (!closed) throw Object.assign(new Error('Unterminated block comment; add */ to close it.'), { position: commentStart });
211
224
  } else result += char;
212
225
  }
213
- return result;
214
- }
226
+ return result;
227
+ }