@smartcat/sanity-plugin 1.0.0 → 1.0.1

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 +168 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +311 -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 +169 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +313 -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 +2103 -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 +2110 -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 +287 -0
  39. package/dist/locjson.d.ts +287 -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 +262 -0
  45. package/dist/progress.d.ts +262 -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 +258 -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 +27 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +545 -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 +98 -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 +17 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +47 -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 +387 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +268 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +264 -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 +44 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +975 -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 +79 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +623 -0
  118. package/src/tool/processing.ts +766 -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,98 @@
1
+ import {toHTML} from '@portabletext/to-html'
2
+ import {htmlToBlocks} from '@sanity/block-tools'
3
+
4
+ /** A Portable Text block (loosely typed — shape varies by schema). */
5
+ export type PortableTextBlock = Record<string, unknown>
6
+
7
+ /** Parses an HTML string into a DOM Document. Provided by the environment. */
8
+ export type ParseHtml = (html: string) => Document
9
+
10
+ /**
11
+ * Renders Portable Text to an HTML string for translation.
12
+ * Whole-field: the entire block array becomes one HTML document fragment.
13
+ */
14
+ export function portableTextToHtml(blocks: PortableTextBlock[] | undefined | null): string {
15
+ if (!blocks || blocks.length === 0) return ''
16
+ return toHTML(blocks as never, {
17
+ // Fallback only. Serialization skips any field containing an unserializable
18
+ // block up front (see findUnserializableBlockTypes), so to-html should never
19
+ // meet an unknown type here. If one slips through, render it as empty rather
20
+ // than let @portabletext/to-html emit its default placeholder — a hidden
21
+ // `<div style="display:none">Unknown block type "…"</div>` whose English text
22
+ // would otherwise be sent to Smartcat and translated into every locale.
23
+ // (`onMissingComponent: false` only silences the warning; it does NOT stop the
24
+ // placeholder — the `unknownType` override is what suppresses it.)
25
+ onMissingComponent: false,
26
+ components: {unknownType: () => ''},
27
+ })
28
+ }
29
+
30
+ /**
31
+ * Distinct `_type`s in a Portable Text value that `@portabletext/to-html` cannot
32
+ * serialize: any top-level member that isn't a standard `block`, or any inline
33
+ * child that isn't a `span` (e.g. a custom `table` block or an inline object).
34
+ * Such nodes render to nothing, so a field containing them can't survive the
35
+ * HTML round-trip — the caller skips the whole field and warns rather than
36
+ * silently drop or corrupt it. Marks/annotations are intentionally not included:
37
+ * their text survives (only the annotation would be lost).
38
+ */
39
+ export function findUnserializableBlockTypes(
40
+ blocks: PortableTextBlock[] | undefined | null,
41
+ ): string[] {
42
+ if (!Array.isArray(blocks)) return []
43
+ const types = new Set<string>()
44
+ for (const block of blocks) {
45
+ if (!block || typeof block !== 'object') continue
46
+ const type = (block as {_type?: unknown})._type
47
+ if (type !== 'block') {
48
+ if (typeof type === 'string') types.add(type)
49
+ continue
50
+ }
51
+ const children = (block as {children?: unknown}).children
52
+ if (!Array.isArray(children)) continue
53
+ for (const child of children) {
54
+ const childType = (child as {_type?: unknown} | null)?._type
55
+ if (typeof childType === 'string' && childType !== 'span') types.add(childType)
56
+ }
57
+ }
58
+ return [...types].sort()
59
+ }
60
+
61
+ /**
62
+ * Extracts the visible text of Portable Text — the concatenation of every span's
63
+ * `text` across all blocks (recursing into `children`). Used to decide whether a
64
+ * field carries anything translatable: structural-but-textless blocks render to
65
+ * non-empty HTML (`<p></p>`, `<br/>`, `<ul><li></li></ul>`), so emptiness must be
66
+ * judged from the source blocks, not from stripping tags/entities out of the HTML
67
+ * (which would mishandle `&nbsp;` and void elements).
68
+ */
69
+ export function portableTextToPlainText(blocks: PortableTextBlock[] | undefined | null): string {
70
+ if (!Array.isArray(blocks)) return ''
71
+ let text = ''
72
+ const visit = (node: unknown): void => {
73
+ if (Array.isArray(node)) {
74
+ node.forEach(visit)
75
+ } else if (node && typeof node === 'object') {
76
+ const value = node as {text?: unknown; children?: unknown}
77
+ if (typeof value.text === 'string') text += value.text
78
+ if (Array.isArray(value.children)) visit(value.children)
79
+ }
80
+ }
81
+ visit(blocks)
82
+ return text
83
+ }
84
+
85
+ /**
86
+ * Parses translated HTML back into Portable Text using the field's compiled
87
+ * block content type. `parseHtml` supplies a DOM parser:
88
+ * - Studio: (html) => new DOMParser().parseFromString(html, 'text/html')
89
+ * - Functions: (html) => new JSDOM(html).window.document
90
+ */
91
+ export function htmlToPortableText(
92
+ html: string,
93
+ blockContentType: unknown,
94
+ parseHtml: ParseHtml,
95
+ ): PortableTextBlock[] {
96
+ if (!html.trim()) return []
97
+ return htmlToBlocks(html, blockContentType as never, {parseHtml}) as unknown as PortableTextBlock[]
98
+ }
@@ -0,0 +1,124 @@
1
+ import {
2
+ findUnserializableBlockTypes,
3
+ portableTextToHtml,
4
+ portableTextToPlainText,
5
+ type PortableTextBlock,
6
+ } from './portableText'
7
+ import {enumerateLeaves} from './fields'
8
+ import {getAtPath} from './paths'
9
+ import type {LocJSON, LocUnit, TranslatableField} from './types'
10
+
11
+ export interface SerializeOptions {
12
+ sourceLanguage: string
13
+ /**
14
+ * How internationalized-array members identify their locale: a field name
15
+ * (e.g. `'language'`) or `'_key'` (v4.x and lower). Resolved by the caller (see
16
+ * `resolveConfig`); no default is assumed here.
17
+ */
18
+ fieldLanguageKey: string
19
+ /**
20
+ * Called when a Portable Text field is skipped because it contains block types
21
+ * that can't be serialized (see {@link findUnserializableBlockTypes}). The
22
+ * whole field is left out so its content isn't dropped or corrupted on the
23
+ * round-trip; the caller surfaces this to the user's log.
24
+ */
25
+ onSkippedField?: (info: {fieldPath: string; title?: string; types: string[]}) => void
26
+ }
27
+
28
+ /** A minimal view of a Sanity document needed for serialization. */
29
+ export interface SerializableDocument {
30
+ _id: string
31
+ _type: string
32
+ _rev?: string
33
+ [key: string]: unknown
34
+ }
35
+
36
+ /**
37
+ * Serializes a Sanity document into a LocJSON file: one unit per translatable
38
+ * field. Plain fields become plain units (segments split on newlines); Portable
39
+ * Text fields become whole-field HTML units (Smartcat segments them internally).
40
+ *
41
+ * Fields with no value are skipped, so the file only contains real source text.
42
+ */
43
+ export function serializeToLocjson(
44
+ doc: SerializableDocument,
45
+ fields: TranslatableField[],
46
+ options: SerializeOptions,
47
+ ): LocJSON {
48
+ const units: LocUnit[] = []
49
+
50
+ for (const field of fields) {
51
+ // For field-level (internationalized-array) fields the source content is the
52
+ // source-language member's `value` (the field may be nested through objects).
53
+ const source =
54
+ field.localization === 'field'
55
+ ? memberValue(getAtPath(doc, field.path), options.sourceLanguage, options.fieldLanguageKey)
56
+ : getAtPath(doc, field.path)
57
+
58
+ // A scalar field yields one leaf; array/object member values yield one leaf
59
+ // per translatable string inside (addressed by valuePath).
60
+ for (const leaf of enumerateLeaves(field, source)) {
61
+ const value = leaf.valuePath ? getAtPath(source, leaf.valuePath) : source
62
+ const comments = leaf.title ? [leaf.title] : undefined
63
+
64
+ if (leaf.kind === 'plain') {
65
+ // Skip when there's no translatable text. Trim only for the check — the
66
+ // stored source keeps its original whitespace so the round-trip is exact.
67
+ if (typeof value !== 'string' || value.trim().length === 0) continue
68
+ units.push({
69
+ key: leaf.fullKey,
70
+ properties: {comments, 'x-smartcat-split': 'full', 'x-sanity': {fieldPath: leaf.fullKey}},
71
+ // Split into line segments but keep the trailing newline on each, so
72
+ // concatenating the segments (LocJSON's '' join) reconstructs the original.
73
+ source: value.split(/(?<=\n)/),
74
+ target: [],
75
+ })
76
+ } else {
77
+ const blocks = value as PortableTextBlock[] | undefined
78
+ // A field with a block to-html can't serialize (e.g. a custom `table`)
79
+ // is skipped whole: partial HTML would drop that block on the round-trip.
80
+ const unserializable = findUnserializableBlockTypes(blocks)
81
+ if (unserializable.length > 0) {
82
+ options.onSkippedField?.({fieldPath: field.path, title: field.title, types: unserializable})
83
+ continue
84
+ }
85
+ // `html` is non-empty for structural-but-textless blocks (e.g. `<p></p>`,
86
+ // `<br/>`), so also require actual visible text before emitting a unit.
87
+ const html = portableTextToHtml(blocks)
88
+ if (!html || portableTextToPlainText(blocks).trim().length === 0) continue
89
+ units.push({
90
+ key: leaf.fullKey,
91
+ properties: {comments, 'x-smartcat-format': 'html', 'x-smartcat-split': 'full', 'x-sanity': {fieldPath: leaf.fullKey}},
92
+ source: [html],
93
+ target: [],
94
+ })
95
+ }
96
+ }
97
+ }
98
+
99
+ return {
100
+ properties: {
101
+ version: 1,
102
+ 'x-sanity': {
103
+ documentId: doc._id,
104
+ documentType: doc._type,
105
+ rev: doc._rev,
106
+ sourceLanguage: options.sourceLanguage,
107
+ },
108
+ },
109
+ units,
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Reads the `value` of an internationalized-array field's member for a given
115
+ * language. Members are identified strictly by `languageKey` — a field name
116
+ * (`'language'`, v5+) or `'_key'` (v4.x and lower).
117
+ */
118
+ function memberValue(field: unknown, language: string, languageKey: string): unknown {
119
+ if (!Array.isArray(field)) return undefined
120
+ const member = field.find(
121
+ (m) => m && typeof m === 'object' && (m as Record<string, unknown>)[languageKey] === language,
122
+ )
123
+ return (member as {value?: unknown} | undefined)?.value
124
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Minimal LocJSON type definitions.
3
+ *
4
+ * LocJSON is Smartcat's JSON localization format: a set of `units`, each with a
5
+ * stable `key`, a `source` (array of string segments) and a `target` that
6
+ * Smartcat fills in. File- and unit-level `properties` may carry arbitrary
7
+ * `x-*` metadata that round-trips through translation.
8
+ */
9
+
10
+ /** How a unit's content should be treated by Smartcat. "plain" is the default and is omitted. */
11
+ export type SmartcatFormat = 'markdown' | 'html'
12
+
13
+ /** Our per-document metadata, stored at file level. */
14
+ export interface SanityFileMeta {
15
+ documentId: string
16
+ documentType: string
17
+ /** `_rev` at export time, to detect source drift when translations return. */
18
+ rev?: string
19
+ sourceLanguage: string
20
+ }
21
+
22
+ /** Our per-unit metadata, stored at unit level. */
23
+ export interface SanityUnitMeta {
24
+ /** Path of the Sanity field this unit represents (also used as the unit key). */
25
+ fieldPath: string
26
+ }
27
+
28
+ export interface LocUnitProperties {
29
+ comments?: string[]
30
+ 'x-smartcat-format'?: SmartcatFormat
31
+ 'x-smartcat-split'?: 'full'
32
+ 'x-sanity'?: SanityUnitMeta
33
+ }
34
+
35
+ export interface LocUnit {
36
+ /** Unique, stable id within the file. We use the Sanity field path. */
37
+ key: string
38
+ properties?: LocUnitProperties
39
+ source: string[]
40
+ target: string[]
41
+ }
42
+
43
+ export interface LocJSONProperties {
44
+ version: number
45
+ 'x-sanity'?: SanityFileMeta
46
+ }
47
+
48
+ export interface LocJSON {
49
+ properties: LocJSONProperties
50
+ units: LocUnit[]
51
+ }
52
+
53
+ /** Kind of translatable field, deciding how it is serialized. */
54
+ export type TranslatableFieldKind = 'plain' | 'portableText'
55
+
56
+ /**
57
+ * Where a document's translations live:
58
+ * - `document`: separate locale documents linked via translation.metadata
59
+ * (@sanity/document-internationalization).
60
+ * - `field`: inline per-language array members on the same document
61
+ * (sanity-plugin-internationalized-array).
62
+ *
63
+ * This is a per-type property derived from the schema; it never travels in
64
+ * LocJSON. On a `field` field the source value is the source-language array
65
+ * member's `value` rather than the field itself.
66
+ */
67
+ export type LocalizationMode = 'document' | 'field'
68
+
69
+ /**
70
+ * A translatable leaf inside an array/object member value, addressed relative to
71
+ * that value (see `paths.ts`). `subPath` is empty for a scalar member value.
72
+ */
73
+ export interface TranslatableLeaf {
74
+ subPath: string
75
+ kind: TranslatableFieldKind
76
+ title?: string
77
+ }
78
+
79
+ /**
80
+ * How a field-mode member value is shaped:
81
+ * - `scalar` — the value is itself the translatable leaf (string/text/Portable Text).
82
+ * - `arrayOfScalar` — an array of strings; one leaf per index.
83
+ * - `arrayOfObject` — an array of objects; the listed `leaves` per item (by `_key`).
84
+ * - `object` — a single object; the listed `leaves`.
85
+ */
86
+ export type ValueShape =
87
+ | {container: 'scalar'; kind: TranslatableFieldKind}
88
+ | {container: 'arrayOfScalar'; kind: TranslatableFieldKind}
89
+ | {container: 'arrayOfObject'; leaves: TranslatableLeaf[]}
90
+ | {container: 'object'; leaves: TranslatableLeaf[]}
91
+
92
+ /** A translatable field selected from a document/schema. */
93
+ export interface TranslatableField {
94
+ /** Path to the field (dotted; nested through plain objects). Also the base unit key. */
95
+ path: string
96
+ kind: TranslatableFieldKind
97
+ /** How serialize extracts the source value (direct field vs. source-language array member). */
98
+ localization: LocalizationMode
99
+ /** Optional human title, surfaced to translators as a unit comment. */
100
+ title?: string
101
+ /**
102
+ * Shape of the (source-language member's) value. Absent means `scalar` — the
103
+ * historical behavior. Present when the value nests translatable leaves.
104
+ */
105
+ value?: ValueShape
106
+ }
package/src/lib/log.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Live, in-memory log lines for the export/import flows, streamed to the
3
+ * ProjectView Log panel as each document and field is processed. Never persisted
4
+ * — purely a session-scoped trace for troubleshooting (e.g. on client calls).
5
+ */
6
+ export type LogLevel = 'info' | 'success' | 'skip' | 'error' | 'update'
7
+
8
+ export interface LogLine {
9
+ level: LogLevel
10
+ message: string
11
+ /** Field-level lines are indented under their document header. */
12
+ indent?: boolean
13
+ /** Optional value (source or translated text); revealed on click in the panel. */
14
+ value?: string
15
+ /** Prior value; when set, the panel shows `before` and `value` side by side. */
16
+ before?: string
17
+ /** Extra detail (e.g. a JSON parse error); shown in its own box on expand. */
18
+ error?: string
19
+ }
20
+
21
+ /** Sink for log lines; processing accepts one to stream progress to the UI. */
22
+ export type LogFn = (line: LogLine) => void
23
+
24
+ /** Parse a Function's JSON-encoded `functionLog` field into log lines (lenient). */
25
+ export function parseFunctionLog(raw: string | null | undefined): LogLine[] {
26
+ if (!raw) return []
27
+ try {
28
+ const parsed = JSON.parse(raw)
29
+ return Array.isArray(parsed) ? (parsed as LogLine[]) : []
30
+ } catch {
31
+ return []
32
+ }
33
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * GROQ fragments for reading a translation project's `items`.
3
+ *
4
+ * An item is either the current `smartcat.projectItem` shape (`{docId, docType}`)
5
+ * or a legacy weak reference (`{_ref}`) from before the migration. These
6
+ * fragments normalize across both so the old/new tolerance lives in one place —
7
+ * interpolate them into item queries rather than re-writing `coalesce(...)`.
8
+ */
9
+
10
+ /** Normalized item id, evaluated in the item's own scope (inside `items[...]`). */
11
+ export const ITEM_ID = 'coalesce(docId, _ref)'
12
+
13
+ /** Normalized item id, evaluated from a subquery nested under an item (via `^`). */
14
+ export const ITEM_ID_FROM_SUBQUERY = 'coalesce(^.docId, ^._ref)'
15
+
16
+ /** Dereference an item's document, with an optional projection e.g. `{_id, _type}`. */
17
+ export const itemDocDeref = (projection = ''): string => `*[_id == ${ITEM_ID_FROM_SUBQUERY}][0]${projection}`
@@ -0,0 +1,62 @@
1
+ import {describe, expect, it} from 'vitest'
2
+ import {resolveConfig} from './resolveConfig'
3
+ import {SETTINGS_TYPE, TRANSLATION_PROJECT_TYPE} from './constants'
4
+
5
+ describe('resolveConfig', () => {
6
+ it('applies defaults for omitted optional fields', () => {
7
+ const resolved = resolveConfig({sourceLanguage: 'en'})
8
+ expect(resolved).toMatchObject({
9
+ rootTypes: [],
10
+ sourceLanguage: 'en',
11
+ languages: [],
12
+ documentI18nLanguageField: 'language',
13
+ fieldI18nLanguageField: 'language',
14
+ })
15
+ })
16
+
17
+ it('treats every non-plugin type as translatable when translatableTypes is omitted', () => {
18
+ const {isTranslatableType} = resolveConfig({sourceLanguage: 'en'})
19
+ expect(isTranslatableType('page')).toBe(true)
20
+ expect(isTranslatableType('anythingElse')).toBe(true)
21
+ expect(isTranslatableType(TRANSLATION_PROJECT_TYPE)).toBe(false)
22
+ expect(isTranslatableType(SETTINGS_TYPE)).toBe(false)
23
+ })
24
+
25
+ it('restricts translatable types to the configured list', () => {
26
+ const {isTranslatableType} = resolveConfig({sourceLanguage: 'en', translatableTypes: ['page']})
27
+ expect(isTranslatableType('page')).toBe(true)
28
+ expect(isTranslatableType('article')).toBe(false)
29
+ })
30
+
31
+ it('inherits explicitly-configured translatableTypes as rootTypes', () => {
32
+ const resolved = resolveConfig({sourceLanguage: 'en', translatableTypes: ['page', 'article']})
33
+ expect(resolved.rootTypes).toEqual(['page', 'article'])
34
+ })
35
+
36
+ it('leaves rootTypes empty when translatableTypes is also omitted (no crawl boundary)', () => {
37
+ expect(resolveConfig({sourceLanguage: 'en'}).rootTypes).toEqual([])
38
+ })
39
+
40
+ it('honors an explicit rootTypes override independent of translatableTypes', () => {
41
+ const resolved = resolveConfig({
42
+ sourceLanguage: 'en',
43
+ translatableTypes: ['page', 'article'],
44
+ rootTypes: ['page'],
45
+ })
46
+ expect(resolved.rootTypes).toEqual(['page'])
47
+ })
48
+
49
+ it('honors explicit overrides (e.g. internationalized-array v4 `_key`)', () => {
50
+ const resolved = resolveConfig({
51
+ sourceLanguage: 'en',
52
+ documentI18nPluginLanguageField: 'lang',
53
+ fieldI18nPluginLanguageField: '_key',
54
+ })
55
+ expect(resolved.documentI18nLanguageField).toBe('lang')
56
+ expect(resolved.fieldI18nLanguageField).toBe('_key')
57
+ })
58
+
59
+ it('throws when sourceLanguage is missing', () => {
60
+ expect(() => resolveConfig({sourceLanguage: ''})).toThrow(/sourceLanguage/)
61
+ })
62
+ })
@@ -0,0 +1,47 @@
1
+ import {requireSourceLanguage, SETTINGS_TYPE, TRANSLATION_PROJECT_TYPE} from './constants'
2
+ import type {SmartcatLanguage, SmartcatPluginConfig} from '../types'
3
+
4
+ /** The plugin's own document types, never translatable regardless of config. */
5
+ const PLUGIN_TYPES = new Set([TRANSLATION_PROJECT_TYPE, SETTINGS_TYPE])
6
+
7
+ /**
8
+ * Plugin config with every optional field resolved to its default. This is the
9
+ * single place plugin defaults are applied — notably the internationalized-array
10
+ * locale key, whose default (`'language'`, plugin v5+) is the one version
11
+ * assumption; downstream code receives a concrete value and never re-guesses.
12
+ */
13
+ export interface ResolvedSmartcatConfig {
14
+ /**
15
+ * Whether a document type shows the "Add to translation project" UI. When
16
+ * `translatableTypes` is omitted this is true for every type except the
17
+ * plugin's own `smartcat.*` types; otherwise it's list membership.
18
+ */
19
+ isTranslatableType: (type: string) => boolean
20
+ /** Root/standalone types where the linked-documents crawl stops. */
21
+ rootTypes: string[]
22
+ sourceLanguage: string
23
+ languages: SmartcatLanguage[]
24
+ /** document-internationalization locale field (its `languageField`). */
25
+ documentI18nLanguageField: string
26
+ /** internationalized-array member locale key (`'language'` v5+, `'_key'` v4.x). */
27
+ fieldI18nLanguageField: string
28
+ }
29
+
30
+ export function resolveConfig(config: SmartcatPluginConfig): ResolvedSmartcatConfig {
31
+ const translatableTypes = config.translatableTypes
32
+ const isTranslatableType = translatableTypes
33
+ ? (type: string) => translatableTypes.includes(type)
34
+ : (type: string) => !PLUGIN_TYPES.has(type)
35
+
36
+ return {
37
+ isTranslatableType,
38
+ // `rootTypes` inherits the *explicitly-configured* translatableTypes (not the
39
+ // resolved "everything") so an unconfigured Studio gets no crawl boundary
40
+ // rather than every type being a root (which would stop the crawl at once).
41
+ rootTypes: config.rootTypes ?? translatableTypes ?? [],
42
+ sourceLanguage: requireSourceLanguage(config.sourceLanguage),
43
+ languages: config.languages ?? [],
44
+ documentI18nLanguageField: config.documentI18nPluginLanguageField ?? 'language',
45
+ fieldI18nLanguageField: config.fieldI18nPluginLanguageField ?? 'language',
46
+ }
47
+ }
@@ -0,0 +1,37 @@
1
+ import {describe, it, expect} from 'vitest'
2
+ import {
3
+ isWorkflowValid,
4
+ resolveWorkflowCreateParams,
5
+ STANDARD_AI,
6
+ STANDARD_AI_HUMAN,
7
+ } from './workflow'
8
+
9
+ describe('isWorkflowValid', () => {
10
+ it('accepts the standard presets regardless of templates', () => {
11
+ expect(isWorkflowValid(STANDARD_AI_HUMAN, [])).toBe(true)
12
+ expect(isWorkflowValid(STANDARD_AI, [])).toBe(true)
13
+ })
14
+
15
+ it('accepts a template only while its id is still cached', () => {
16
+ expect(isWorkflowValid('template:abc', ['abc', 'def'])).toBe(true)
17
+ expect(isWorkflowValid('template:gone', ['abc', 'def'])).toBe(false)
18
+ })
19
+
20
+ it('rejects empty / unknown values (no selection)', () => {
21
+ expect(isWorkflowValid('', ['abc'])).toBe(false)
22
+ expect(isWorkflowValid('whatever', ['abc'])).toBe(false)
23
+ })
24
+ })
25
+
26
+ describe('resolveWorkflowCreateParams', () => {
27
+ it('maps the standard presets to workflow stages', () => {
28
+ expect(resolveWorkflowCreateParams(STANDARD_AI_HUMAN)).toEqual({
29
+ workflowStages: ['translation', 'postediting'],
30
+ })
31
+ expect(resolveWorkflowCreateParams(STANDARD_AI)).toEqual({workflowStages: ['translation']})
32
+ })
33
+
34
+ it('maps a template selection to a template id', () => {
35
+ expect(resolveWorkflowCreateParams('template:abc')).toEqual({templateId: 'abc'})
36
+ })
37
+ })
@@ -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
+ }