@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,73 @@
1
+ /**
2
+ * Tiny path grammar for addressing translatable leaves nested inside a field's
3
+ * value: `.field` for object properties, `[key]` for array members by `_key`,
4
+ * `[n]` for array members by index. Used to expand array/object member values
5
+ * into per-leaf LocJSON units and to overlay translations back onto a cloned
6
+ * source value.
7
+ *
8
+ * e.g. `items[b1].value`, `tabs[2]`, `` (empty = the value itself).
9
+ */
10
+ export type PathSeg = {field: string} | {key: string} | {index: number}
11
+
12
+ export function parsePath(path: string): PathSeg[] {
13
+ const segs: PathSeg[] = []
14
+ const re = /\.?([^.[\]]+)|\[([^\]]+)\]/g
15
+ let m: RegExpExecArray | null
16
+ while ((m = re.exec(path)) !== null) {
17
+ if (m[1] !== undefined) segs.push({field: m[1]})
18
+ else if (/^\d+$/.test(m[2])) segs.push({index: Number(m[2])})
19
+ else segs.push({key: m[2]})
20
+ }
21
+ return segs
22
+ }
23
+
24
+ /** Append a segment to a base path string, producing a valid path. */
25
+ export function joinPath(base: string, seg: PathSeg): string {
26
+ if ('field' in seg) return base ? `${base}.${seg.field}` : seg.field
27
+ if ('index' in seg) return `${base}[${seg.index}]`
28
+ return `${base}[${seg.key}]`
29
+ }
30
+
31
+ function child(container: unknown, seg: PathSeg): unknown {
32
+ if (container == null || typeof container !== 'object') return undefined
33
+ if ('field' in seg) return (container as Record<string, unknown>)[seg.field]
34
+ if ('index' in seg) return Array.isArray(container) ? container[seg.index] : undefined
35
+ return Array.isArray(container)
36
+ ? container.find((m) => m && typeof m === 'object' && (m as Record<string, unknown>)._key === seg.key)
37
+ : undefined
38
+ }
39
+
40
+ /** Read the value at a path from a root value (undefined if any segment misses). */
41
+ export function getAtPath(root: unknown, path: string): unknown {
42
+ let cur = root
43
+ for (const seg of parsePath(path)) {
44
+ cur = child(cur, seg)
45
+ if (cur === undefined) return undefined
46
+ }
47
+ return cur
48
+ }
49
+
50
+ /**
51
+ * Return a deep clone of `root` with the leaf at `path` replaced by `value`.
52
+ * An empty path replaces the whole value. Missing intermediate segments are a
53
+ * no-op (the clone is returned unchanged), so a template that lacks a leaf can't
54
+ * be corrupted.
55
+ */
56
+ export function setAtPath<T>(root: T, path: string, value: unknown): T {
57
+ const segs = parsePath(path)
58
+ if (segs.length === 0) return value as T
59
+ const clone = structuredClone(root)
60
+ let cur: unknown = clone
61
+ for (let i = 0; i < segs.length - 1; i++) {
62
+ cur = child(cur, segs[i])
63
+ if (cur == null || typeof cur !== 'object') return clone
64
+ }
65
+ const last = segs[segs.length - 1]
66
+ if ('field' in last) (cur as Record<string, unknown>)[last.field] = value
67
+ else if ('index' in last && Array.isArray(cur)) cur[last.index] = value
68
+ else if ('key' in last && Array.isArray(cur)) {
69
+ const idx = cur.findIndex((m) => m && typeof m === 'object' && (m as Record<string, unknown>)._key === last.key)
70
+ if (idx >= 0) cur[idx] = value
71
+ }
72
+ return clone
73
+ }
@@ -0,0 +1,157 @@
1
+ import {escapeHTML, toHTML} from '@portabletext/to-html'
2
+ import {htmlToBlocks, type DeserializerRule} 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
+ /**
8
+ * Custom (non-`block`) top-level Portable Text types that we *can* round-trip
9
+ * through HTML, and so must NOT treat as unserializable. Each needs both a
10
+ * `toHTML` component (below) and an `htmlToBlocks` rule (see {@link tableRule})
11
+ * so the block survives export → translation → import intact.
12
+ *
13
+ * Currently just `@sanity/table`: `{_type:'table', rows:[{_type:'tableRow',
14
+ * cells: string[]}]}` — plain-string cells, no header row, no col/row spans.
15
+ */
16
+ const SERIALIZABLE_CUSTOM_TYPES = new Set(['table'])
17
+
18
+ /** Renders a `@sanity/table` block to an HTML `<table>` for translation. */
19
+ function tableToHtml(value: unknown): string {
20
+ const rows = Array.isArray((value as {rows?: unknown})?.rows) ? (value as {rows: unknown[]}).rows : []
21
+ const trs = rows
22
+ .map((row) => {
23
+ const cells = Array.isArray((row as {cells?: unknown})?.cells) ? (row as {cells: unknown[]}).cells : []
24
+ const tds = cells.map((cell) => `<td>${escapeHTML(typeof cell === 'string' ? cell : '')}</td>`).join('')
25
+ return `<tr>${tds}</tr>`
26
+ })
27
+ .join('')
28
+ return `<table><tbody>${trs}</tbody></table>`
29
+ }
30
+
31
+ /**
32
+ * An `htmlToBlocks` rule that turns a translated `<table>` back into a
33
+ * `@sanity/table` block. Rows get deterministic index-based `_key`s (the whole
34
+ * field value is replaced on import, so keys need only be unique within the
35
+ * table); cells are plain strings, matching the schema.
36
+ */
37
+ const tableRule: DeserializerRule = {
38
+ deserialize(el, _next, createBlock) {
39
+ if ((el as Element).tagName?.toLowerCase() !== 'table') return undefined
40
+ const rows = Array.from((el as Element).querySelectorAll('tr')).map((tr, i) => ({
41
+ _type: 'tableRow',
42
+ _key: `row${i}`,
43
+ cells: Array.from(tr.querySelectorAll('td, th')).map((cell) => cell.textContent ?? ''),
44
+ }))
45
+ return createBlock({_type: 'table', rows}) as never
46
+ },
47
+ }
48
+
49
+ /** Parses an HTML string into a DOM Document. Provided by the environment. */
50
+ export type ParseHtml = (html: string) => Document
51
+
52
+ /**
53
+ * Renders Portable Text to an HTML string for translation.
54
+ * Whole-field: the entire block array becomes one HTML document fragment.
55
+ */
56
+ export function portableTextToHtml(blocks: PortableTextBlock[] | undefined | null): string {
57
+ if (!blocks || blocks.length === 0) return ''
58
+ return toHTML(blocks as never, {
59
+ // Fallback only. Serialization skips any field containing an unserializable
60
+ // block up front (see findUnserializableBlockTypes), so to-html should never
61
+ // meet an unknown type here. If one slips through, render it as empty rather
62
+ // than let @portabletext/to-html emit its default placeholder — a hidden
63
+ // `<div style="display:none">Unknown block type "…"</div>` whose English text
64
+ // would otherwise be sent to Smartcat and translated into every locale.
65
+ // (`onMissingComponent: false` only silences the warning; it does NOT stop the
66
+ // placeholder — the `unknownType` override is what suppresses it.)
67
+ onMissingComponent: false,
68
+ components: {
69
+ unknownType: () => '',
70
+ // Serializable custom block types get real markup so their text is
71
+ // translatable and can be parsed back (see SERIALIZABLE_CUSTOM_TYPES).
72
+ types: {table: ({value}) => tableToHtml(value)},
73
+ },
74
+ })
75
+ }
76
+
77
+ /**
78
+ * Distinct `_type`s in a Portable Text value that `@portabletext/to-html` cannot
79
+ * serialize: any top-level member that isn't a standard `block` (and isn't in
80
+ * {@link SERIALIZABLE_CUSTOM_TYPES}, e.g. `table`, which we render explicitly),
81
+ * or any inline child that isn't a `span` (e.g. an inline object). Such nodes
82
+ * render to nothing, so a field containing them can't survive the HTML
83
+ * round-trip — the caller skips the whole field and warns rather than silently
84
+ * drop or corrupt it. Marks/annotations are intentionally not included: their
85
+ * text survives (only the annotation would be lost).
86
+ */
87
+ export function findUnserializableBlockTypes(
88
+ blocks: PortableTextBlock[] | undefined | null,
89
+ ): string[] {
90
+ if (!Array.isArray(blocks)) return []
91
+ const types = new Set<string>()
92
+ for (const block of blocks) {
93
+ if (!block || typeof block !== 'object') continue
94
+ const type = (block as {_type?: unknown})._type
95
+ if (type !== 'block') {
96
+ if (typeof type === 'string' && !SERIALIZABLE_CUSTOM_TYPES.has(type)) types.add(type)
97
+ continue
98
+ }
99
+ const children = (block as {children?: unknown}).children
100
+ if (!Array.isArray(children)) continue
101
+ for (const child of children) {
102
+ const childType = (child as {_type?: unknown} | null)?._type
103
+ if (typeof childType === 'string' && childType !== 'span') types.add(childType)
104
+ }
105
+ }
106
+ return [...types].sort()
107
+ }
108
+
109
+ /**
110
+ * Extracts the visible text of Portable Text — the concatenation of every span's
111
+ * `text` across all blocks (recursing into `children`). Used to decide whether a
112
+ * field carries anything translatable: structural-but-textless blocks render to
113
+ * non-empty HTML (`<p></p>`, `<br/>`, `<ul><li></li></ul>`), so emptiness must be
114
+ * judged from the source blocks, not from stripping tags/entities out of the HTML
115
+ * (which would mishandle `&nbsp;` and void elements).
116
+ */
117
+ export function portableTextToPlainText(blocks: PortableTextBlock[] | undefined | null): string {
118
+ if (!Array.isArray(blocks)) return ''
119
+ let text = ''
120
+ const visit = (node: unknown): void => {
121
+ if (Array.isArray(node)) {
122
+ node.forEach(visit)
123
+ } else if (node && typeof node === 'object') {
124
+ const value = node as {text?: unknown; children?: unknown; rows?: unknown}
125
+ if (typeof value.text === 'string') text += value.text
126
+ if (Array.isArray(value.children)) visit(value.children)
127
+ // `table` blocks carry text in `rows[].cells[]` (plain strings), not spans;
128
+ // count it so a table-only field isn't judged empty and skipped on export.
129
+ if (Array.isArray(value.rows)) {
130
+ for (const row of value.rows) {
131
+ const cells = (row as {cells?: unknown})?.cells
132
+ if (Array.isArray(cells)) for (const cell of cells) if (typeof cell === 'string') text += cell
133
+ }
134
+ }
135
+ }
136
+ }
137
+ visit(blocks)
138
+ return text
139
+ }
140
+
141
+ /**
142
+ * Parses translated HTML back into Portable Text using the field's compiled
143
+ * block content type. `parseHtml` supplies a DOM parser:
144
+ * - Studio: (html) => new DOMParser().parseFromString(html, 'text/html')
145
+ * - Functions: (html) => new JSDOM(html).window.document
146
+ */
147
+ export function htmlToPortableText(
148
+ html: string,
149
+ blockContentType: unknown,
150
+ parseHtml: ParseHtml,
151
+ ): PortableTextBlock[] {
152
+ if (!html.trim()) return []
153
+ return htmlToBlocks(html, blockContentType as never, {
154
+ parseHtml,
155
+ rules: [tableRule],
156
+ }) as unknown as PortableTextBlock[]
157
+ }
@@ -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,31 @@
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}`
18
+
19
+ /**
20
+ * Dereference an item's document, honoring where it was added from: items added
21
+ * in the **published** perspective (`sourceIsPublished == true`) resolve the
22
+ * published document only; every other item (including legacy items with no
23
+ * flag) prefers the **draft**, falling back to published. This makes the export
24
+ * translate the content the editor was actually viewing, and gives pre-existing
25
+ * projects the draft-preferring default for free.
26
+ */
27
+ export const itemDocDerefRespectingDraft = (projection = ''): string =>
28
+ `select(` +
29
+ `sourceIsPublished == true => *[_id == ${ITEM_ID_FROM_SUBQUERY}][0]${projection}, ` +
30
+ `coalesce(*[_id == "drafts." + ${ITEM_ID_FROM_SUBQUERY}][0]${projection}, *[_id == ${ITEM_ID_FROM_SUBQUERY}][0]${projection})` +
31
+ `)`
@@ -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,54 @@
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
+ /**
21
+ * The explicitly-configured translatable types, verbatim, for display/logging.
22
+ * `undefined` means none was configured — every non-plugin type is translatable
23
+ * (see {@link isTranslatableType}). Logic should use the predicate, not this.
24
+ */
25
+ translatableTypes?: string[]
26
+ /** Root/standalone types where the linked-documents crawl stops. */
27
+ rootTypes: string[]
28
+ sourceLanguage: string
29
+ languages: SmartcatLanguage[]
30
+ /** document-internationalization locale field (its `languageField`). */
31
+ documentI18nLanguageField: string
32
+ /** internationalized-array member locale key (`'language'` v5+, `'_key'` v4.x). */
33
+ fieldI18nLanguageField: string
34
+ }
35
+
36
+ export function resolveConfig(config: SmartcatPluginConfig): ResolvedSmartcatConfig {
37
+ const translatableTypes = config.translatableTypes
38
+ const isTranslatableType = translatableTypes
39
+ ? (type: string) => translatableTypes.includes(type)
40
+ : (type: string) => !PLUGIN_TYPES.has(type)
41
+
42
+ return {
43
+ isTranslatableType,
44
+ translatableTypes,
45
+ // `rootTypes` inherits the *explicitly-configured* translatableTypes (not the
46
+ // resolved "everything") so an unconfigured Studio gets no crawl boundary
47
+ // rather than every type being a root (which would stop the crawl at once).
48
+ rootTypes: config.rootTypes ?? translatableTypes ?? [],
49
+ sourceLanguage: requireSourceLanguage(config.sourceLanguage),
50
+ languages: config.languages ?? [],
51
+ documentI18nLanguageField: config.documentI18nPluginLanguageField ?? 'language',
52
+ fieldI18nLanguageField: config.fieldI18nPluginLanguageField ?? 'language',
53
+ }
54
+ }
@@ -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
+ })