@lievclouds/dsh-cortex 0.5.4 → 1.0.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.

Potentially problematic release.


This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.

Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/client.js +434 -412
  3. package/index.js +7 -0
  4. package/locale/en.json +6 -6
  5. package/locale/zh.json +6 -6
  6. package/package.json +115 -110
  7. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  8. package/src/cortex-ui/renderer/App.tsx +39 -0
  9. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  10. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  11. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  12. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  13. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  14. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +291 -137
  16. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  17. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  18. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  19. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  20. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  21. package/src/cortex-ui/renderer/i18n/messages/retest.ts +144 -2
  22. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  23. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  24. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  25. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  26. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  27. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -17
  28. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  29. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  30. package/src/host/cortex/index.js +42 -1
  31. package/src/host/cortex/repeater-store.js +411 -0
  32. package/src/host/cortex/repeater.js +15 -2
  33. package/src/host/cortex/tools.js +2 -0
  34. package/src/host/miniapp/analyze.js +1078 -0
  35. package/src/host/miniapp/assets-import.js +294 -0
  36. package/src/host/miniapp/detect.js +426 -0
  37. package/src/host/miniapp/format.js +263 -0
  38. package/src/host/miniapp/roots.js +360 -0
  39. package/src/host/miniapp/routes.js +304 -0
  40. package/src/host/miniapp/service.js +559 -0
  41. package/src/host/miniapp/store.js +756 -0
  42. package/src/host/miniapp/tools.js +335 -0
  43. package/src/host/miniapp/unpack.js +1010 -0
  44. package/src/host/miniapp/wxapkg.js +416 -0
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Read-only mini-program (wxapkg) discovery.
3
+ *
4
+ * `scanMiniapps` walks each configured root, groups `.wxapkg` files by package
5
+ * directory, infers appid/version, detects the container format from the first
6
+ * bytes and upserts one source row per package directory.
7
+ *
8
+ * Hard rules honoured here:
9
+ * - never write/move/delete under a scanned root (read-only);
10
+ * - never follow symlinks or descend into `node_modules`;
11
+ * - permission errors are counted per root and never abort the scan;
12
+ * - the event loop is yielded to roughly every 2000 directory entries.
13
+ */
14
+
15
+ import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs'
16
+ import { basename, dirname, resolve } from 'node:path'
17
+ import { expandRoots, inferIdentity } from './roots.js'
18
+ import { sourceIdFor } from './store.js'
19
+
20
+ /** Default scan limits (contract §3.3). */
21
+ export const SCAN_LIMITS = { maxDepth: 10, maxFiles: 30000, maxPackageFiles: 200 }
22
+
23
+ /** Package files above this size are reported and skipped. */
24
+ export const MAX_PACKAGE_FILE_BYTES = 300 * 1024 * 1024
25
+
26
+ /** Directory names that are never descended into. */
27
+ const SKIP_DIRS = new Set(['node_modules'])
28
+
29
+ /** Yield to the host event loop after roughly this many directory entries. */
30
+ const YIELD_EVERY = 2000
31
+
32
+ const DEFAULT_IO = { existsSync, statSync, readdirSync }
33
+
34
+ /** @param {string} filePath */
35
+ function isWxapkgName(filePath) {
36
+ return basename(filePath).toLowerCase().endsWith('.wxapkg')
37
+ }
38
+
39
+ /** @param {string} value */
40
+ function normalizePathKey(value) {
41
+ const absolute = resolve(value)
42
+ return process.platform === 'win32' ? absolute.toLowerCase() : absolute
43
+ }
44
+
45
+ /**
46
+ * Sniff a package container from its first bytes:
47
+ * `V1MMWX` -> `v1mmwx`, byte0 `0xBE` -> `plain`, anything else -> `unknown`.
48
+ * Missing/unreadable files are `unknown` (never throws).
49
+ * @param {string} filePath
50
+ * @returns {'plain'|'v1mmwx'|'unknown'}
51
+ */
52
+ export function detectFormat(filePath) {
53
+ let fd = null
54
+ try {
55
+ fd = openSync(filePath, 'r')
56
+ const head = Buffer.alloc(6)
57
+ const read = readSync(fd, head, 0, head.length, 0)
58
+ if (read >= 6 && head.toString('latin1', 0, 6) === 'V1MMWX') return 'v1mmwx'
59
+ if (read >= 1 && head[0] === 0xbe) return 'plain'
60
+ return 'unknown'
61
+ } catch {
62
+ return 'unknown'
63
+ } finally {
64
+ if (fd !== null) {
65
+ try {
66
+ closeSync(fd)
67
+ } catch {
68
+ /* ignore */
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ /** `__APP__.wxapkg` first, then by file name. */
75
+ function comparePackageFiles(a, b) {
76
+ const left = basename(a.path).toLowerCase()
77
+ const right = basename(b.path).toLowerCase()
78
+ if (left === '__app__.wxapkg') return -1
79
+ if (right === '__app__.wxapkg') return 1
80
+ return left.localeCompare(right)
81
+ }
82
+
83
+ /** @param {string|{path:string,size?:number}} raw */
84
+ function normalizeFileEntry(raw) {
85
+ if (typeof raw === 'string') {
86
+ const path = resolve(raw)
87
+ let size = 0
88
+ try {
89
+ size = statSync(path).size
90
+ } catch {
91
+ /* size stays 0; grouping does not need it */
92
+ }
93
+ return { path, size }
94
+ }
95
+ if (!raw || typeof raw !== 'object' || typeof raw.path !== 'string' || !raw.path.trim()) return null
96
+ const path = resolve(raw.path)
97
+ const size = Number(raw.size)
98
+ return { path, size: Number.isFinite(size) && size >= 0 ? Math.floor(size) : 0 }
99
+ }
100
+
101
+ /**
102
+ * Group package files by containing directory and produce `upsertSource`
103
+ * inputs (one per package directory). Format comes from the largest file.
104
+ *
105
+ * @param {Array<string|{path:string,size?:number}>} files
106
+ * @param {import('./roots.js').Root|string} root
107
+ * @returns {Array<Record<string, any>>}
108
+ */
109
+ export function groupPackages(files, root) {
110
+ /** @type {Map<string, {dir:string, files:{path:string,size:number}[]}>} */
111
+ const groups = new Map()
112
+ for (const raw of Array.isArray(files) ? files : []) {
113
+ const file = normalizeFileEntry(raw)
114
+ if (!file || !isWxapkgName(file.path)) continue
115
+ const dir = dirname(file.path)
116
+ const key = normalizePathKey(dir)
117
+ let group = groups.get(key)
118
+ if (!group) {
119
+ group = { dir, files: [] }
120
+ groups.set(key, group)
121
+ }
122
+ if (!group.files.some((entry) => normalizePathKey(entry.path) === normalizePathKey(file.path))) {
123
+ group.files.push(file)
124
+ }
125
+ }
126
+
127
+ const out = []
128
+ for (const group of groups.values()) {
129
+ group.files.sort(comparePackageFiles)
130
+ const largest = group.files.reduce((best, file) => (file.size > best.size ? file : best), group.files[0])
131
+ const identity = inferIdentity(largest.path, root)
132
+ out.push({
133
+ id: sourceIdFor(identity.rootPath, identity.packageDir),
134
+ root_path: identity.rootPath,
135
+ root_kind: identity.rootKind,
136
+ appid: identity.appid,
137
+ app_name: '',
138
+ version: identity.version,
139
+ package_dir: identity.packageDir,
140
+ files: group.files.map((file) => ({
141
+ name: basename(file.path),
142
+ path: file.path,
143
+ size: file.size,
144
+ })),
145
+ size_bytes: group.files.reduce((sum, file) => sum + file.size, 0),
146
+ file_count: group.files.length,
147
+ package_hash: '',
148
+ format: detectFormat(largest.path),
149
+ status: 'detected',
150
+ unpacked_path: '',
151
+ error: '',
152
+ })
153
+ }
154
+ out.sort((a, b) => a.package_dir.localeCompare(b.package_dir))
155
+ return out
156
+ }
157
+
158
+ /** @param {any} root @param {{maxDepth:number}} limits */
159
+ function normalizeRoot(root, limits) {
160
+ if (typeof root === 'string') {
161
+ const absolute = resolve(root)
162
+ return {
163
+ kind: 'custom',
164
+ path: absolute,
165
+ label: basename(absolute) || absolute,
166
+ mode: 'recursive',
167
+ maxDepth: limits.maxDepth,
168
+ }
169
+ }
170
+ if (!root || typeof root.path !== 'string' || !root.path.trim()) return null
171
+ const maxDepth = Number(root.maxDepth)
172
+ const declaredDepth =
173
+ Number.isFinite(maxDepth) && maxDepth >= 0 ? Math.floor(maxDepth) : limits.maxDepth
174
+ return {
175
+ kind: typeof root.kind === 'string' && root.kind ? root.kind : 'custom',
176
+ path: resolve(root.path),
177
+ label: typeof root.label === 'string' ? root.label : basename(resolve(root.path)),
178
+ mode: root.mode === 'packages' ? 'packages' : 'recursive',
179
+ maxDepth: Math.min(declaredDepth, limits.maxDepth),
180
+ }
181
+ }
182
+
183
+ /** Newest-first source ordering used for the scan result. */
184
+ function compareSources(a, b) {
185
+ const byAppid = String(a.appid ?? '').localeCompare(String(b.appid ?? ''))
186
+ if (byAppid !== 0) return byAppid
187
+ const left = String(a.version ?? '')
188
+ const right = String(b.version ?? '')
189
+ const leftNumeric = /^\d+$/.test(left)
190
+ const rightNumeric = /^\d+$/.test(right)
191
+ if (leftNumeric && rightNumeric && Number(left) !== Number(right)) return Number(right) - Number(left)
192
+ const byVersion = right.localeCompare(left, undefined, { numeric: true })
193
+ if (byVersion !== 0) return byVersion
194
+ return String(a.package_dir ?? '').localeCompare(String(b.package_dir ?? ''))
195
+ }
196
+
197
+ /** @param {string} message @param {string} root @param {string} path */
198
+ function scanError(message, root, path) {
199
+ return { root, path, message }
200
+ }
201
+
202
+ /**
203
+ * Scan every root and persist the detected packages.
204
+ *
205
+ * @param {{
206
+ * store: import('./store.js').MiniappStore,
207
+ * roots?: Array<any>,
208
+ * customRoots?: string[],
209
+ * env?: NodeJS.ProcessEnv,
210
+ * limits?: {maxDepth?:number, maxFiles?:number, maxPackageFiles?:number},
211
+ * now?: () => string,
212
+ * io?: {existsSync:Function, statSync:Function, readdirSync:Function}
213
+ * }} [options]
214
+ */
215
+ export async function scanMiniapps({
216
+ store,
217
+ roots = [],
218
+ customRoots = [],
219
+ env = process.env,
220
+ limits = {},
221
+ now = () => new Date().toISOString(),
222
+ io = DEFAULT_IO,
223
+ } = {}) {
224
+ if (!store || typeof store.upsertSource !== 'function') {
225
+ throw new TypeError('scanMiniapps: a MiniappStore instance is required')
226
+ }
227
+ store.ensure?.()
228
+
229
+ const effectiveLimits = {
230
+ maxDepth: Number.isFinite(Number(limits.maxDepth)) ? Number(limits.maxDepth) : SCAN_LIMITS.maxDepth,
231
+ maxFiles: Number.isFinite(Number(limits.maxFiles)) ? Number(limits.maxFiles) : SCAN_LIMITS.maxFiles,
232
+ maxPackageFiles: Number.isFinite(Number(limits.maxPackageFiles))
233
+ ? Number(limits.maxPackageFiles)
234
+ : SCAN_LIMITS.maxPackageFiles,
235
+ }
236
+
237
+ const explicitRoots = Array.isArray(roots) && roots.length > 0 ? roots : null
238
+ const rootInputs = explicitRoots ?? expandRoots({ customRoots, env })
239
+ const rootList = []
240
+ const seenRoots = new Set()
241
+ for (const rawRoot of rootInputs) {
242
+ const root = normalizeRoot(rawRoot, effectiveLimits)
243
+ if (!root) continue
244
+ const key = normalizePathKey(root.path)
245
+ if (seenRoots.has(key)) continue
246
+ seenRoots.add(key)
247
+ rootList.push(root)
248
+ }
249
+
250
+ const scannedAt = now()
251
+ /** @type {Array<{root:string,path:string,message:string}>} */
252
+ const errors = []
253
+ /** @type {Array<{kind:string,path:string,exists:boolean,filesFound:number,errors:number}>} */
254
+ const rootStats = []
255
+ /** @type {Map<string, {root:any, files:{path:string,size:number}[]}>} */
256
+ const byPackageDir = new Map()
257
+
258
+ let totalFiles = 0
259
+ let maxFilesReached = false
260
+ let entriesSinceYield = 0
261
+
262
+ const tick = async () => {
263
+ entriesSinceYield += 1
264
+ if (entriesSinceYield >= YIELD_EVERY) {
265
+ entriesSinceYield = 0
266
+ await new Promise((resolveTick) => setImmediate(resolveTick))
267
+ }
268
+ }
269
+
270
+ for (const root of rootList) {
271
+ const stat = { kind: root.kind, path: root.path, exists: false, filesFound: 0, errors: 0 }
272
+ rootStats.push(stat)
273
+
274
+ let rootExists = false
275
+ try {
276
+ rootExists = Boolean(io.existsSync(root.path))
277
+ } catch {
278
+ rootExists = false
279
+ }
280
+ if (!rootExists) continue
281
+ stat.exists = true
282
+
283
+ let directory = false
284
+ try {
285
+ directory = Boolean(io.statSync(root.path).isDirectory())
286
+ } catch (err) {
287
+ stat.errors += 1
288
+ errors.push(scanError(String(err && err.code ? err.code : err), root.path, root.path))
289
+ continue
290
+ }
291
+ if (!directory) {
292
+ stat.errors += 1
293
+ errors.push(scanError('not a directory', root.path, root.path))
294
+ continue
295
+ }
296
+
297
+ const rootFiles = []
298
+ const stack = [{ dir: root.path, depth: 0 }]
299
+ while (stack.length > 0 && !maxFilesReached) {
300
+ const current = stack.pop()
301
+ let entries
302
+ try {
303
+ entries = io.readdirSync(current.dir, { withFileTypes: true })
304
+ } catch (err) {
305
+ stat.errors += 1
306
+ errors.push(
307
+ scanError(
308
+ String(err && err.code ? err.code : (err && err.message) || 'readdir failed'),
309
+ root.path,
310
+ current.dir,
311
+ ),
312
+ )
313
+ await tick()
314
+ continue
315
+ }
316
+
317
+ for (const entry of entries) {
318
+ await tick()
319
+ const entryName = String(entry && entry.name ? entry.name : '')
320
+ if (!entryName) continue
321
+ const entryPath = resolve(current.dir, entryName)
322
+
323
+ // Never follow symlinks (files or directories), never walk node_modules.
324
+ if (typeof entry.isSymbolicLink === 'function' && entry.isSymbolicLink()) continue
325
+
326
+ if (typeof entry.isDirectory === 'function' && entry.isDirectory()) {
327
+ if (SKIP_DIRS.has(entryName.toLowerCase())) continue
328
+ if (current.depth + 1 > effectiveLimits.maxDepth) continue
329
+ stack.push({ dir: entryPath, depth: current.depth + 1 })
330
+ continue
331
+ }
332
+
333
+ if (typeof entry.isFile !== 'function' || !entry.isFile()) continue
334
+ if (!entryName.toLowerCase().endsWith('.wxapkg')) continue
335
+
336
+ if (totalFiles >= effectiveLimits.maxFiles) {
337
+ maxFilesReached = true
338
+ errors.push(
339
+ scanError(`maxFiles limit (${effectiveLimits.maxFiles}) reached`, root.path, entryPath),
340
+ )
341
+ break
342
+ }
343
+
344
+ let size = 0
345
+ try {
346
+ size = io.statSync(entryPath).size
347
+ } catch (err) {
348
+ stat.errors += 1
349
+ errors.push(
350
+ scanError(String(err && err.code ? err.code : 'stat failed'), root.path, entryPath),
351
+ )
352
+ continue
353
+ }
354
+
355
+ totalFiles += 1
356
+ stat.filesFound += 1
357
+ if (size > MAX_PACKAGE_FILE_BYTES) {
358
+ stat.errors += 1
359
+ errors.push(scanError('package file exceeds 300 MB limit', root.path, entryPath))
360
+ continue
361
+ }
362
+ rootFiles.push({ path: entryPath, size })
363
+ }
364
+ }
365
+
366
+ // One package directory can be reachable from overlapping roots; keep the
367
+ // first root that found it so ids stay stable.
368
+ for (const file of rootFiles) {
369
+ const key = normalizePathKey(dirname(file.path))
370
+ const existing = byPackageDir.get(key)
371
+ if (!existing) {
372
+ byPackageDir.set(key, { root, files: [file] })
373
+ continue
374
+ }
375
+ const fileKey = normalizePathKey(file.path)
376
+ if (!existing.files.some((entry) => normalizePathKey(entry.path) === fileKey)) {
377
+ existing.files.push(file)
378
+ }
379
+ }
380
+ }
381
+
382
+ let totalPackages = 0
383
+ const grouped = []
384
+ for (const { root, files } of byPackageDir.values()) {
385
+ const sorted = files.slice().sort(comparePackageFiles)
386
+ let kept = sorted
387
+ if (sorted.length > effectiveLimits.maxPackageFiles) {
388
+ kept = sorted.slice(0, Math.max(0, effectiveLimits.maxPackageFiles))
389
+ errors.push(
390
+ scanError(
391
+ `maxPackageFiles limit (${effectiveLimits.maxPackageFiles}) reached`,
392
+ root.path,
393
+ dirname(sorted[0].path),
394
+ ),
395
+ )
396
+ }
397
+ totalPackages += kept.length
398
+ if (kept.length > 0) grouped.push(...groupPackages(kept, root))
399
+ }
400
+
401
+ let added = 0
402
+ let updated = 0
403
+ let unchanged = 0
404
+ const sources = []
405
+ for (const input of grouped) {
406
+ const result = store.upsertSource({ ...input, detected_at: scannedAt, updated_at: scannedAt })
407
+ if (result.created) added += 1
408
+ else if (result.changed) updated += 1
409
+ else unchanged += 1
410
+ sources.push(result.row)
411
+ }
412
+ sources.sort(compareSources)
413
+
414
+ return {
415
+ ok: true,
416
+ scannedAt,
417
+ totalSources: sources.length,
418
+ totalPackages,
419
+ added,
420
+ updated,
421
+ unchanged,
422
+ roots: rootStats,
423
+ errors,
424
+ sources,
425
+ }
426
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Read-only text formatting for the mini-program file viewer.
3
+ *
4
+ * Minified `app-config.json` is a single 70 KB line and `app-service.js` is a
5
+ * multi-megabyte one-liner, so raw previews are unusable. This module adds:
6
+ * - JSON: parse + `JSON.stringify(_, null, 2)` (falls back to raw text),
7
+ * - JS/TS/WXS: a conservative, dependency-free tokenizer/beautifier that
8
+ * inserts line breaks after `{`, `}`, `;` and keeps string / template /
9
+ * comment / simple-regex contents untouched,
10
+ * - language detection by extension.
11
+ *
12
+ * Formatting is display-only: the original bytes on disk are never changed and
13
+ * the formatter never executes the code.
14
+ */
15
+
16
+ /** Input cap for formatting (larger files are previewed raw). */
17
+ export const MAX_FORMAT_INPUT_BYTES = 5 * 1024 * 1024
18
+ /** Output cap so a pathological one-liner cannot blow up the response. */
19
+ export const MAX_FORMAT_OUTPUT_BYTES = 4 * 1024 * 1024
20
+
21
+ const JS_EXTENSIONS = new Set(['.js', '.cjs', '.mjs', '.jsx', '.ts', '.tsx', '.wxs', '.wxml'])
22
+ const JSON_EXTENSIONS = new Set(['.json'])
23
+
24
+ /** @param {string} path */
25
+ export function languageOf(path) {
26
+ const lowered = String(path || '').toLowerCase()
27
+ for (const extension of JS_EXTENSIONS) {
28
+ if (lowered.endsWith(extension)) return extension === '.wxml' ? 'xml' : 'js'
29
+ }
30
+ for (const extension of JSON_EXTENSIONS) {
31
+ if (lowered.endsWith(extension)) return 'json'
32
+ }
33
+ return 'text'
34
+ }
35
+
36
+ /** Pretty-print JSON when it parses; returns null otherwise. */
37
+ export function formatJsonText(text) {
38
+ const source = String(text || '')
39
+ if (source.trim().length === 0) return null
40
+ try {
41
+ const parsed = JSON.parse(source)
42
+ return `${JSON.stringify(parsed, null, 2)}\n`
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ function isRegexStart(previousSignificant) {
49
+ if (previousSignificant === '') return true
50
+ return '(,=:[!&|?{};+-*%<>~^'.includes(previousSignificant)
51
+ }
52
+
53
+ /**
54
+ * Conservative JS beautifier. It only moves whitespace; every non-whitespace
55
+ * character is preserved in order. States tracked: single/double/template
56
+ * strings, line/block comments and simple regex literals.
57
+ * @param {string} text
58
+ * @returns {string}
59
+ */
60
+ export function formatJsText(text) {
61
+ const source = String(text || '')
62
+ if (source.length === 0) return ''
63
+ /** @type {string[]} */
64
+ const chunks = []
65
+ let bytes = 0
66
+ let lastChar = ''
67
+ let indent = 0
68
+ let lineStart = true
69
+ let pendingSpace = false
70
+ let previousSignificant = ''
71
+ let index = 0
72
+ const limit = Math.min(source.length, MAX_FORMAT_INPUT_BYTES)
73
+
74
+ const indentText = () => ' '.repeat(Math.min(indent, 40))
75
+
76
+ const push = (value) => {
77
+ if (bytes + value.length > MAX_FORMAT_OUTPUT_BYTES) return false
78
+ chunks.push(value)
79
+ bytes += value.length
80
+ if (value.length > 0) {
81
+ lastChar = value[value.length - 1]
82
+ lineStart = lastChar === '\n'
83
+ }
84
+ return true
85
+ }
86
+ const newline = () => {
87
+ if (bytes === 0 || lastChar === '\n') return true
88
+ chunks.push('\n')
89
+ bytes += 1
90
+ lastChar = '\n'
91
+ lineStart = true
92
+ return true
93
+ }
94
+
95
+ while (index < limit) {
96
+ const char = source[index]
97
+
98
+ if (char === '\n' || char === '\r') {
99
+ index += 1
100
+ pendingSpace = false
101
+ // Preserve the author's line structure; minified files simply have none.
102
+ if (!lineStart) newline()
103
+ continue
104
+ }
105
+ if (char === ' ' || char === '\t') {
106
+ pendingSpace = true
107
+ index += 1
108
+ continue
109
+ }
110
+
111
+ if (lineStart) {
112
+ if (!push(indentText())) break
113
+ lineStart = false
114
+ pendingSpace = false
115
+ }
116
+
117
+ if (char === '/' && source[index + 1] === '/') {
118
+ const end = source.indexOf('\n', index)
119
+ const stop = end < 0 ? limit : end
120
+ if (!push(source.slice(index, stop))) break
121
+ index = stop
122
+ continue
123
+ }
124
+ if (char === '/' && source[index + 1] === '*') {
125
+ const end = source.indexOf('*/', index + 2)
126
+ const stop = end < 0 ? limit : end + 2
127
+ if (!push(source.slice(index, stop))) break
128
+ index = stop
129
+ continue
130
+ }
131
+ if (char === "'" || char === '"' || char === '`') {
132
+ const quote = char
133
+ let stop = index + 1
134
+ while (stop < limit) {
135
+ if (source[stop] === '\\') {
136
+ stop += 2
137
+ continue
138
+ }
139
+ if (source[stop] === quote) {
140
+ stop += 1
141
+ break
142
+ }
143
+ stop += 1
144
+ }
145
+ if (!push(source.slice(index, stop))) break
146
+ previousSignificant = quote
147
+ index = stop
148
+ continue
149
+ }
150
+ if (char === '/' && isRegexStart(previousSignificant)) {
151
+ let stop = index + 1
152
+ let inClass = false
153
+ let closed = false
154
+ while (stop < limit) {
155
+ const current = source[stop]
156
+ if (current === '\\') {
157
+ stop += 2
158
+ continue
159
+ }
160
+ if (current === '[') inClass = true
161
+ else if (current === ']') inClass = false
162
+ else if (current === '/' && !inClass) {
163
+ stop += 1
164
+ closed = true
165
+ break
166
+ } else if (current === '\n') {
167
+ break
168
+ }
169
+ stop += 1
170
+ }
171
+ if (closed) {
172
+ if (!push(source.slice(index, stop))) break
173
+ previousSignificant = '/'
174
+ index = stop
175
+ continue
176
+ }
177
+ // Not a regex after all; fall through to normal handling.
178
+ }
179
+
180
+ if (char === '{') {
181
+ let lookahead = index + 1
182
+ while (lookahead < limit && /\s/.test(source[lookahead])) lookahead += 1
183
+ if (source[lookahead] === '}') {
184
+ if (!push('{}')) break
185
+ previousSignificant = '}'
186
+ index = lookahead + 1
187
+ continue
188
+ }
189
+ if (!push('{')) break
190
+ indent += 1
191
+ newline()
192
+ previousSignificant = '{'
193
+ index += 1
194
+ continue
195
+ }
196
+ if (char === '}') {
197
+ indent = Math.max(0, indent - 1)
198
+ if (!lineStart) newline()
199
+ if (!push(`${indentText()}}`)) break
200
+ previousSignificant = '}'
201
+ index += 1
202
+ // `} else`, `},`, `};` should stay on the same line.
203
+ if (source[index] !== ';' && source[index] !== ',' && source[index] !== ')') newline()
204
+ continue
205
+ }
206
+ if (char === ';') {
207
+ if (!push(';')) break
208
+ newline()
209
+ previousSignificant = ';'
210
+ index += 1
211
+ continue
212
+ }
213
+ if (char === ',') {
214
+ if (!push(', ')) break
215
+ previousSignificant = ','
216
+ index += 1
217
+ continue
218
+ }
219
+ if (char === ')') {
220
+ if (!push(')')) break
221
+ previousSignificant = ')'
222
+ index += 1
223
+ continue
224
+ }
225
+
226
+ if (pendingSpace && bytes > 0) {
227
+ const last = lastChar
228
+ if (!/[\s(\[{]/.test(last)) {
229
+ if (!push(' ')) break
230
+ }
231
+ }
232
+ pendingSpace = false
233
+ if (!push(char)) break
234
+ if (!/\s/.test(char)) previousSignificant = char
235
+ index += 1
236
+ }
237
+ if (bytes > 0 && lastChar !== '\n') {
238
+ chunks.push('\n')
239
+ bytes += 1
240
+ }
241
+ return chunks.join('')
242
+ }
243
+
244
+ /**
245
+ * Format one preview chunk according to its file type.
246
+ * @param {{path:string, content:string}} input
247
+ * @returns {{text:string, formatted:boolean, language:'json'|'js'|'xml'|'text'}}
248
+ */
249
+ export function formatContent(input) {
250
+ const path = String((input && input.path) || '')
251
+ const content = String((input && input.content) || '')
252
+ const language = languageOf(path)
253
+ if (language === 'json') {
254
+ const formatted = formatJsonText(content)
255
+ if (formatted !== null) return { text: formatted, formatted: true, language }
256
+ return { text: content, formatted: false, language }
257
+ }
258
+ if (language === 'js') {
259
+ const formatted = formatJsText(content)
260
+ return { text: formatted, formatted: true, language }
261
+ }
262
+ return { text: content, formatted: false, language }
263
+ }