@smartcat/sanity-plugin 1.0.0 → 1.1.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.
Files changed (121) hide show
  1. package/README.md +294 -0
  2. package/dist/_chunks-cjs/constants.cjs +13 -0
  3. package/dist/_chunks-cjs/constants.cjs.map +1 -0
  4. package/dist/_chunks-cjs/index.cjs +174 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +338 -0
  7. package/dist/_chunks-cjs/index2.cjs.map +1 -0
  8. package/dist/_chunks-cjs/workflow.cjs +16 -0
  9. package/dist/_chunks-cjs/workflow.cjs.map +1 -0
  10. package/dist/_chunks-es/constants.js +14 -0
  11. package/dist/_chunks-es/constants.js.map +1 -0
  12. package/dist/_chunks-es/index.js +175 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +340 -0
  15. package/dist/_chunks-es/index2.js.map +1 -0
  16. package/dist/_chunks-es/workflow.js +17 -0
  17. package/dist/_chunks-es/workflow.js.map +1 -0
  18. package/dist/export.cjs +160 -0
  19. package/dist/export.cjs.map +1 -0
  20. package/dist/export.d.cts +162 -0
  21. package/dist/export.d.ts +162 -0
  22. package/dist/export.js +162 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/import.cjs +169 -0
  25. package/dist/import.cjs.map +1 -0
  26. package/dist/import.d.cts +155 -0
  27. package/dist/import.d.ts +155 -0
  28. package/dist/import.js +169 -0
  29. package/dist/import.js.map +1 -0
  30. package/dist/index.cjs +2172 -0
  31. package/dist/index.cjs.map +1 -0
  32. package/dist/index.d.cts +122 -0
  33. package/dist/index.d.ts +122 -0
  34. package/dist/index.js +2179 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/locjson.cjs +12 -0
  37. package/dist/locjson.cjs.map +1 -0
  38. package/dist/locjson.d.cts +291 -0
  39. package/dist/locjson.d.ts +291 -0
  40. package/dist/locjson.js +12 -0
  41. package/dist/locjson.js.map +1 -0
  42. package/dist/progress.cjs +16 -0
  43. package/dist/progress.cjs.map +1 -0
  44. package/dist/progress.d.cts +277 -0
  45. package/dist/progress.d.ts +277 -0
  46. package/dist/progress.js +16 -0
  47. package/dist/progress.js.map +1 -0
  48. package/dist/smartcat.cjs +287 -0
  49. package/dist/smartcat.cjs.map +1 -0
  50. package/dist/smartcat.d.cts +234 -0
  51. package/dist/smartcat.d.ts +234 -0
  52. package/dist/smartcat.js +287 -0
  53. package/dist/smartcat.js.map +1 -0
  54. package/dist/templates.cjs +12 -0
  55. package/dist/templates.cjs.map +1 -0
  56. package/dist/templates.d.cts +52 -0
  57. package/dist/templates.d.ts +52 -0
  58. package/dist/templates.js +12 -0
  59. package/dist/templates.js.map +1 -0
  60. package/package.json +101 -15
  61. package/src/actions/AddToProjectAction.tsx +274 -0
  62. package/src/export/export.test.ts +537 -0
  63. package/src/export/index.ts +393 -0
  64. package/src/form/TranslationStatusInput.tsx +212 -0
  65. package/src/import/import.test.ts +346 -0
  66. package/src/import/index.ts +418 -0
  67. package/src/index.ts +63 -0
  68. package/src/lib/constants.ts +23 -0
  69. package/src/lib/documentTitle.test.ts +43 -0
  70. package/src/lib/documentTitle.ts +30 -0
  71. package/src/lib/languageMap.test.ts +56 -0
  72. package/src/lib/languageMap.ts +71 -0
  73. package/src/lib/linkedDocuments.test.ts +56 -0
  74. package/src/lib/linkedDocuments.ts +100 -0
  75. package/src/lib/locjson/deserialize.ts +60 -0
  76. package/src/lib/locjson/fields.ts +355 -0
  77. package/src/lib/locjson/filename.ts +34 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +615 -0
  80. package/src/lib/locjson/paths.test.ts +41 -0
  81. package/src/lib/locjson/paths.ts +73 -0
  82. package/src/lib/locjson/portableText.ts +157 -0
  83. package/src/lib/locjson/serialize.ts +124 -0
  84. package/src/lib/locjson/types.ts +106 -0
  85. package/src/lib/log.ts +33 -0
  86. package/src/lib/projectItems.ts +31 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +54 -0
  89. package/src/lib/workflow.test.ts +37 -0
  90. package/src/lib/workflow.ts +48 -0
  91. package/src/lib/zip.fixtures.ts +75 -0
  92. package/src/lib/zip.test.ts +110 -0
  93. package/src/lib/zip.ts +164 -0
  94. package/src/progress/core.ts +409 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +290 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +268 -0
  100. package/src/smartcat/client.test.ts +222 -0
  101. package/src/smartcat/client.ts +351 -0
  102. package/src/smartcat/index.ts +10 -0
  103. package/src/smartcat/types.ts +99 -0
  104. package/src/templates/core.ts +55 -0
  105. package/src/templates/index.ts +5 -0
  106. package/src/templates/templates.test.ts +50 -0
  107. package/src/tool/Dashboard.tsx +46 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +1004 -0
  111. package/src/tool/ProjectsView.tsx +208 -0
  112. package/src/tool/StatusBadge.tsx +33 -0
  113. package/src/tool/StudioInit.tsx +42 -0
  114. package/src/tool/WorkflowSelect.tsx +41 -0
  115. package/src/tool/data.ts +86 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +698 -0
  118. package/src/tool/processing.ts +839 -0
  119. package/src/tool/useTemplates.ts +84 -0
  120. package/src/tool/useWorkflowSelection.ts +65 -0
  121. package/src/types.ts +77 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Workflow selection for new projects. A project stores a single `workflow`
3
+ * string: either `template:<smartcatTemplateId>` or one of the standard presets.
4
+ * The browser sets it at creation; the export Function decodes it into the
5
+ * Smartcat `createProject` params (a template id, or explicit workflow stages).
6
+ *
7
+ * Dependency-free so it bundles into the thin export Function.
8
+ */
9
+
10
+ export const TEMPLATE_PREFIX = 'template:'
11
+ export const STANDARD_AI_HUMAN = 'standard:ai-human'
12
+ export const STANDARD_AI = 'standard:ai'
13
+
14
+ /** Backwards-compatible default: matches the previous hard-coded behavior. */
15
+ export const DEFAULT_WORKFLOW = STANDARD_AI_HUMAN
16
+
17
+ /** Labels for the two standard presets (rendered under the "Standard workflows" group). */
18
+ export const STANDARD_WORKFLOW_OPTIONS: {value: string; label: string}[] = [
19
+ {value: STANDARD_AI_HUMAN, label: 'AI Translation + Human Review'},
20
+ {value: STANDARD_AI, label: 'AI Translation'},
21
+ ]
22
+
23
+ export interface WorkflowCreateParams {
24
+ templateId?: string
25
+ workflowStages?: string[]
26
+ }
27
+
28
+ /**
29
+ * Whether a `workflow` value is currently selectable: the standard presets always
30
+ * are; a `template:<id>` is valid only while that id still exists in the cached
31
+ * template list. Empty / unknown values are invalid (no selection).
32
+ */
33
+ export function isWorkflowValid(workflow: string, templateIds: string[]): boolean {
34
+ if (workflow === STANDARD_AI_HUMAN || workflow === STANDARD_AI) return true
35
+ if (workflow.startsWith(TEMPLATE_PREFIX)) {
36
+ return templateIds.includes(workflow.slice(TEMPLATE_PREFIX.length))
37
+ }
38
+ return false
39
+ }
40
+
41
+ /** Decodes a stored `workflow` value into Smartcat create-project params. */
42
+ export function resolveWorkflowCreateParams(workflow?: string): WorkflowCreateParams {
43
+ if (workflow && workflow.startsWith(TEMPLATE_PREFIX)) {
44
+ return {templateId: workflow.slice(TEMPLATE_PREFIX.length)}
45
+ }
46
+ if (workflow === STANDARD_AI) return {workflowStages: ['translation']}
47
+ return {workflowStages: ['translation', 'postediting']}
48
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Test-only ZIP builder: assembles a minimal, structurally valid archive
3
+ * (local headers + central directory + EOCD) for exercising the reader.
4
+ * CRCs are zeroed — the reader doesn't verify them, and neither do we.
5
+ */
6
+
7
+ export interface ZipFixtureEntry {
8
+ name: string
9
+ content: string
10
+ /** 0 = stored (default), 8 = deflate. */
11
+ method?: 0 | 8
12
+ }
13
+
14
+ export async function buildZip(entries: ZipFixtureEntry[]): Promise<Uint8Array> {
15
+ const encoder = new TextEncoder()
16
+ const chunks: Uint8Array[] = []
17
+ const central: Uint8Array[] = []
18
+ let offset = 0
19
+
20
+ for (const entry of entries) {
21
+ const method = entry.method ?? 0
22
+ const nameBytes = encoder.encode(entry.name)
23
+ const raw = encoder.encode(entry.content)
24
+ const data = method === 8 ? await deflateRaw(raw) : raw
25
+
26
+ const local = new Uint8Array(30 + nameBytes.length)
27
+ const lv = new DataView(local.buffer)
28
+ lv.setUint32(0, 0x04034b50, true)
29
+ lv.setUint16(4, 20, true) // version needed
30
+ lv.setUint16(8, method, true)
31
+ lv.setUint32(18, data.length, true) // compressed size
32
+ lv.setUint32(22, raw.length, true) // uncompressed size
33
+ lv.setUint16(26, nameBytes.length, true)
34
+ local.set(nameBytes, 30)
35
+ chunks.push(local, data)
36
+
37
+ const dir = new Uint8Array(46 + nameBytes.length)
38
+ const dv = new DataView(dir.buffer)
39
+ dv.setUint32(0, 0x02014b50, true)
40
+ dv.setUint16(4, 20, true) // version made by
41
+ dv.setUint16(6, 20, true) // version needed
42
+ dv.setUint16(10, method, true)
43
+ dv.setUint32(20, data.length, true)
44
+ dv.setUint32(24, raw.length, true)
45
+ dv.setUint16(28, nameBytes.length, true)
46
+ dv.setUint32(42, offset, true) // local header offset
47
+ dir.set(nameBytes, 46)
48
+ central.push(dir)
49
+
50
+ offset += local.length + data.length
51
+ }
52
+
53
+ const centralSize = central.reduce((sum, c) => sum + c.length, 0)
54
+ const eocd = new Uint8Array(22)
55
+ const ev = new DataView(eocd.buffer)
56
+ ev.setUint32(0, 0x06054b50, true)
57
+ ev.setUint16(8, entries.length, true) // entries on this disk
58
+ ev.setUint16(10, entries.length, true) // entries total
59
+ ev.setUint32(12, centralSize, true)
60
+ ev.setUint32(16, offset, true) // central directory offset
61
+
62
+ const total = offset + centralSize + eocd.length
63
+ const out = new Uint8Array(total)
64
+ let pos = 0
65
+ for (const chunk of [...chunks, ...central, eocd]) {
66
+ out.set(chunk, pos)
67
+ pos += chunk.length
68
+ }
69
+ return out
70
+ }
71
+
72
+ async function deflateRaw(data: Uint8Array): Promise<Uint8Array> {
73
+ const stream = new Blob([data.slice()]).stream().pipeThrough(new CompressionStream('deflate-raw'))
74
+ return new Uint8Array(await new Response(stream).arrayBuffer())
75
+ }
@@ -0,0 +1,110 @@
1
+ import {describe, expect, it} from 'vitest'
2
+ import {isZip, unzip, unzipText} from './zip'
3
+ import {buildZip} from './zip.fixtures'
4
+
5
+ /**
6
+ * A REAL Smartcat-format export archive: generated with the exact library and
7
+ * settings production uses — Smartcat.Fork.SharpZipLib 1.5.1, `ZipOutputStream
8
+ * { UseZip64 = On }`, streamed entries with `IsUnicodeText = true` (see
9
+ * ExportDocumentsProcessor.cs:1525 in the smartcat repo). Forced ZIP64 means
10
+ * every central-directory entry carries 0xFFFFFFFF size placeholders with the
11
+ * real values in ZIP64 extra fields — the format that a non-ZIP64 reader fails
12
+ * on (which silently degraded every batch to per-document fallback in v1).
13
+ */
14
+ const SMARTCAT_ZIP64_ARCHIVE_B64 =
15
+ 'UEsDBC0AAAgIAAeK6FzGlJGm//////////8iABQAcGFnZS9BYm91dC1hYmMxMjM0NShmci1GUikubG9janNvbgEAEAB8AAAAAAAA' +
16
+ 'AGwAAAAAAAAAFYsxDsMgEAT/sjUukjiNS1fJGyIKgk8RNuEsOKREiL/7vNVqZ7Zhz7xTlkAFU8NvKC4F+Z99YV+/lOS5YIJ7+8v1' +
17
+ 'Nt4H0qB3g6qefl4NG6kPCRIJBoVr9qQAD4qRYQ3E5Q/JOc2cVuWw3fYDUEsDBC0AAAgIAAeK6Fxyl2Ox//////////8iABQAcGFn' +
18
+ 'ZS9Iw7ZtZS1kZWY2Nzg5MChmci1GUikubG9janNvbgEAEAB7AAAAAAAAAHEAAAAAAAAAFYsxDsIwEAT/srUjUZGQH1BQp0AuotwF' +
19
+ 'WQQ7Op8lkOVv8QE+xmWr1exsxS5pZ9HAGWPFu8tzDPo5OqWlvDjqlTCCeD33w+XUrRa05lDMs8+94snmQ4NuDIeciixsA6YkG8E7' +
20
+ '6CwP1gPdft9IDN98+wNQSwMELQAACAgAB4roXGMJtzf//////////ysAFAByZXNwb25zaXZlSW1hZ2UvQWx0LTk5ODg3NzY2KGZy' +
21
+ 'LUZSKS5sb2Nqc29uAQAQAEUAAAAAAAAAQQAAAAAAAACrViooyi9ILSrJTC1WsqpWqtAtTszLLKkEsVPyk0tzU/NKPFOUrJQsLS0s' +
22
+ 'zM3NzHQTgUCptlZHqRSoDqgnOrYWAFBLAQIzAC0AAAgIAAeK6FzGlJGm//////////8iABQAAAAAAAAAAAAAAAAAAABwYWdlL0Fi' +
23
+ 'b3V0LWFiYzEyMzQ1KGZyLUZSKS5sb2Nqc29uAQAQAHwAAAAAAAAAbAAAAAAAAABQSwECMwAtAAAICAAHiuhccpdjsf//////////' +
24
+ 'IgAUAAAAAAAAAAAAAADAAAAAcGFnZS9Iw7ZtZS1kZWY2Nzg5MChmci1GUikubG9janNvbgEAEAB7AAAAAAAAAHEAAAAAAAAAUEsB' +
25
+ 'AjMALQAACAgAB4roXGMJtzf//////////ysAFAAAAAAAAAAAAAAAhQEAAHJlc3BvbnNpdmVJbWFnZS9BbHQtOTk4ODc3NjYoZnIt' +
26
+ 'RlIpLmxvY2pzb24BABAARQAAAAAAAABBAAAAAAAAAFBLBQYAAAAAAwADADUBAAAjAgAAAAA='
27
+
28
+ function fromBase64(b64: string): Uint8Array {
29
+ const bin = atob(b64)
30
+ const out = new Uint8Array(bin.length)
31
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
32
+ return out
33
+ }
34
+
35
+ describe('isZip', () => {
36
+ it('recognizes the PK magic', async () => {
37
+ const zip = await buildZip([{name: 'a.txt', content: 'hello'}])
38
+ expect(isZip(zip)).toBe(true)
39
+ })
40
+
41
+ it('rejects plain text and short payloads', () => {
42
+ expect(isZip(new TextEncoder().encode('{"units":[]}'))).toBe(false)
43
+ expect(isZip(new Uint8Array([0x50, 0x4b]))).toBe(false)
44
+ })
45
+ })
46
+
47
+ describe('unzip', () => {
48
+ it('parses a real Smartcat production-format archive (SharpZipLib, forced ZIP64)', async () => {
49
+ const bytes = fromBase64(SMARTCAT_ZIP64_ARCHIVE_B64)
50
+ expect(isZip(bytes)).toBe(true)
51
+
52
+ const entries = await unzipText(bytes)
53
+ expect(entries.map((e) => e.name)).toEqual([
54
+ 'page/About-abc12345(fr-FR).locjson',
55
+ 'page/Höme-def67890(fr-FR).locjson',
56
+ 'responsiveImage/Alt-99887766(fr-FR).locjson',
57
+ ])
58
+ // Contents parse as LocJSON and carry the x-sanity correlation metadata.
59
+ const first = JSON.parse(entries[0].content)
60
+ expect(first.properties['x-sanity'].documentId).toBe('abc12345-eeee')
61
+ expect(first.units[0].target).toEqual(['Bonjour'])
62
+ const second = JSON.parse(entries[1].content)
63
+ expect(second.properties['x-sanity'].documentId).toBe('def67890-ffff')
64
+ expect(second.units[0].target).toEqual(['Mönde'])
65
+ const third = JSON.parse(entries[2].content)
66
+ expect(third.properties['x-sanity'].documentId).toBe('99887766-aaaa')
67
+ })
68
+
69
+ it('extracts stored entries', async () => {
70
+ const zip = await buildZip([
71
+ {name: 'page/About-abc12345(fr).locjson', content: '{"a":1}'},
72
+ {name: 'page/Home-def67890(fr).locjson', content: '{"b":2}'},
73
+ ])
74
+ const entries = await unzipText(zip)
75
+ expect(entries).toEqual([
76
+ {name: 'page/About-abc12345(fr).locjson', content: '{"a":1}'},
77
+ {name: 'page/Home-def67890(fr).locjson', content: '{"b":2}'},
78
+ ])
79
+ })
80
+
81
+ it('inflates deflate-compressed entries', async () => {
82
+ const long = 'translated content '.repeat(200)
83
+ const zip = await buildZip([{name: 'doc.locjson', content: long, method: 8}])
84
+ const entries = await unzipText(zip)
85
+ expect(entries).toHaveLength(1)
86
+ expect(entries[0].content).toBe(long)
87
+ })
88
+
89
+ it('skips directory entries', async () => {
90
+ const zip = await buildZip([
91
+ {name: 'page/', content: ''},
92
+ {name: 'page/About.locjson', content: '{}'},
93
+ ])
94
+ const entries = await unzip(zip)
95
+ expect(entries.map((e) => e.name)).toEqual(['page/About.locjson'])
96
+ })
97
+
98
+ it('preserves non-ASCII entry names and contents', async () => {
99
+ const zip = await buildZip([{name: 'sida/Åh nej-abc12345(sv).locjson', content: '{"t":"översättning"}', method: 8}])
100
+ const entries = await unzipText(zip)
101
+ expect(entries[0].name).toBe('sida/Åh nej-abc12345(sv).locjson')
102
+ expect(entries[0].content).toBe('{"t":"översättning"}')
103
+ })
104
+
105
+ it('throws on payloads without an end-of-central-directory record', async () => {
106
+ await expect(unzip(new TextEncoder().encode('PK\x03\x04 not really a zip'))).rejects.toThrow(
107
+ /end-of-central-directory/,
108
+ )
109
+ })
110
+ })
package/src/lib/zip.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Minimal ZIP reader for Smartcat batch-export archives.
3
+ *
4
+ * Dependency-free by design (like the rest of the Function-side code): built on
5
+ * `DataView` + the global `DecompressionStream` (Node 18+ / browsers). Supports
6
+ * the two compression methods Smartcat archives use — stored (0) and deflate (8).
7
+ *
8
+ * ZIP64 is REQUIRED, not an edge case: Smartcat writes export archives with
9
+ * SharpZipLib's `UseZip64 = On` (forced), so central-directory size/offset
10
+ * fields hold `0xFFFFFFFF` placeholders with the real 64-bit values in each
11
+ * entry's ZIP64 extra field — even for tiny archives. Large archives
12
+ * additionally use the ZIP64 end-of-central-directory record.
13
+ */
14
+
15
+ export interface ZipEntry {
16
+ /** Entry path inside the archive (folders separated by `/`). */
17
+ name: string
18
+ data: Uint8Array
19
+ }
20
+
21
+ const LOCAL_HEADER_SIG = 0x04034b50
22
+ const CENTRAL_HEADER_SIG = 0x02014b50
23
+ const EOCD_SIG = 0x06054b50
24
+ const ZIP64_EOCD_SIG = 0x06064b50
25
+ const ZIP64_EOCD_LOCATOR_SIG = 0x07064b50
26
+ /** EOCD record is 22 bytes + an optional comment of up to 65535 bytes. */
27
+ const EOCD_MIN_SIZE = 22
28
+ /** ZIP64 EOCD locator is a fixed 20 bytes, sitting right before the classic EOCD. */
29
+ const ZIP64_LOCATOR_SIZE = 20
30
+ /** Placeholder meaning "the real value is in the ZIP64 extra field / record". */
31
+ const U32_MAX = 0xffffffff
32
+ const U16_MAX = 0xffff
33
+ /** Extra-field header id of the ZIP64 extended information block. */
34
+ const ZIP64_EXTRA_ID = 0x0001
35
+
36
+ /** Whether the payload looks like a ZIP archive (`PK\x03\x04` magic). */
37
+ export function isZip(bytes: Uint8Array): boolean {
38
+ return (
39
+ bytes.length >= 4 &&
40
+ bytes[0] === 0x50 &&
41
+ bytes[1] === 0x4b &&
42
+ bytes[2] === 0x03 &&
43
+ bytes[3] === 0x04
44
+ )
45
+ }
46
+
47
+ /** Extracts all file entries (directories are skipped). */
48
+ export async function unzip(bytes: Uint8Array): Promise<ZipEntry[]> {
49
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
50
+ const eocd = findEndOfCentralDirectory(view)
51
+ const entries: ZipEntry[] = []
52
+
53
+ let offset = eocd.centralDirectoryOffset
54
+ for (let i = 0; i < eocd.entryCount; i++) {
55
+ if (view.getUint32(offset, true) !== CENTRAL_HEADER_SIG) {
56
+ throw new Error(`Invalid ZIP: bad central directory entry at ${offset}`)
57
+ }
58
+ const method = view.getUint16(offset + 10, true)
59
+ let compressedSize: number = view.getUint32(offset + 20, true)
60
+ let uncompressedSize: number = view.getUint32(offset + 24, true)
61
+ const nameLength = view.getUint16(offset + 28, true)
62
+ const extraLength = view.getUint16(offset + 30, true)
63
+ const commentLength = view.getUint16(offset + 32, true)
64
+ let localHeaderOffset: number = view.getUint32(offset + 42, true)
65
+ const name = new TextDecoder().decode(bytes.subarray(offset + 46, offset + 46 + nameLength))
66
+
67
+ // ZIP64: any field at its 32-bit max is a placeholder; the real value lives
68
+ // in the entry's ZIP64 extra block, in a fixed order, holding ONLY the
69
+ // fields that overflowed (Smartcat's forced-ZIP64 writer always defers the
70
+ // sizes this way, even for small files).
71
+ if (compressedSize === U32_MAX || uncompressedSize === U32_MAX || localHeaderOffset === U32_MAX) {
72
+ const extraStart = offset + 46 + nameLength
73
+ const extraEnd = extraStart + extraLength
74
+ let p = extraStart
75
+ while (p + 4 <= extraEnd) {
76
+ const id = view.getUint16(p, true)
77
+ const size = view.getUint16(p + 2, true)
78
+ if (id === ZIP64_EXTRA_ID) {
79
+ let q = p + 4
80
+ if (uncompressedSize === U32_MAX) {
81
+ uncompressedSize = Number(view.getBigUint64(q, true))
82
+ q += 8
83
+ }
84
+ if (compressedSize === U32_MAX) {
85
+ compressedSize = Number(view.getBigUint64(q, true))
86
+ q += 8
87
+ }
88
+ if (localHeaderOffset === U32_MAX) {
89
+ localHeaderOffset = Number(view.getBigUint64(q, true))
90
+ }
91
+ break
92
+ }
93
+ p += 4 + size
94
+ }
95
+ if (compressedSize === U32_MAX || localHeaderOffset === U32_MAX) {
96
+ throw new Error(`Invalid ZIP: "${name}" needs ZIP64 sizes but has no ZIP64 extra field`)
97
+ }
98
+ }
99
+ offset += 46 + nameLength + extraLength + commentLength
100
+
101
+ if (name.endsWith('/')) continue // directory entry
102
+
103
+ // The local header repeats name/extra with its own lengths; the data
104
+ // follows it. Sizes come from the central directory (authoritative even
105
+ // when the local header deferred them to a data descriptor).
106
+ if (view.getUint32(localHeaderOffset, true) !== LOCAL_HEADER_SIG) {
107
+ throw new Error(`Invalid ZIP: bad local header for "${name}"`)
108
+ }
109
+ const localNameLength = view.getUint16(localHeaderOffset + 26, true)
110
+ const localExtraLength = view.getUint16(localHeaderOffset + 28, true)
111
+ const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength
112
+ const compressed = bytes.subarray(dataStart, dataStart + compressedSize)
113
+
114
+ entries.push({name, data: await decompress(method, compressed, name)})
115
+ }
116
+ return entries
117
+ }
118
+
119
+ /** Convenience: unzip and decode every file entry as UTF-8 text. */
120
+ export async function unzipText(bytes: Uint8Array): Promise<{name: string; content: string}[]> {
121
+ const entries = await unzip(bytes)
122
+ const decoder = new TextDecoder()
123
+ return entries.map((e) => ({name: e.name, content: decoder.decode(e.data)}))
124
+ }
125
+
126
+ function findEndOfCentralDirectory(view: DataView): {
127
+ entryCount: number
128
+ centralDirectoryOffset: number
129
+ } {
130
+ const lowest = Math.max(0, view.byteLength - EOCD_MIN_SIZE - 0xffff)
131
+ for (let i = view.byteLength - EOCD_MIN_SIZE; i >= lowest; i--) {
132
+ if (view.getUint32(i, true) !== EOCD_SIG) continue
133
+
134
+ let entryCount: number = view.getUint16(i + 10, true)
135
+ let centralDirectoryOffset: number = view.getUint32(i + 16, true)
136
+
137
+ // ZIP64: placeholder values defer to the ZIP64 EOCD record, found via the
138
+ // fixed-size locator that sits immediately before the classic EOCD.
139
+ if (entryCount === U16_MAX || centralDirectoryOffset === U32_MAX) {
140
+ const locator = i - ZIP64_LOCATOR_SIZE
141
+ if (locator < 0 || view.getUint32(locator, true) !== ZIP64_EOCD_LOCATOR_SIG) {
142
+ throw new Error('Invalid ZIP: ZIP64 values without a ZIP64 end-of-central-directory locator')
143
+ }
144
+ const zip64Eocd = Number(view.getBigUint64(locator + 8, true))
145
+ if (view.getUint32(zip64Eocd, true) !== ZIP64_EOCD_SIG) {
146
+ throw new Error('Invalid ZIP: bad ZIP64 end-of-central-directory record')
147
+ }
148
+ entryCount = Number(view.getBigUint64(zip64Eocd + 32, true))
149
+ centralDirectoryOffset = Number(view.getBigUint64(zip64Eocd + 48, true))
150
+ }
151
+ return {entryCount, centralDirectoryOffset}
152
+ }
153
+ throw new Error('Invalid ZIP: end-of-central-directory record not found')
154
+ }
155
+
156
+ async function decompress(method: number, data: Uint8Array, name: string): Promise<Uint8Array> {
157
+ if (method === 0) return data // stored
158
+ if (method === 8) {
159
+ // Copy into a fresh ArrayBuffer so the Blob sees exactly this entry's bytes.
160
+ const stream = new Blob([data.slice()]).stream().pipeThrough(new DecompressionStream('deflate-raw'))
161
+ return new Uint8Array(await new Response(stream).arrayBuffer())
162
+ }
163
+ throw new Error(`Unsupported ZIP compression method ${method} for "${name}"`)
164
+ }