@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.
- package/README.md +294 -0
- package/dist/_chunks-cjs/constants.cjs +13 -0
- package/dist/_chunks-cjs/constants.cjs.map +1 -0
- package/dist/_chunks-cjs/index.cjs +174 -0
- package/dist/_chunks-cjs/index.cjs.map +1 -0
- package/dist/_chunks-cjs/index2.cjs +338 -0
- package/dist/_chunks-cjs/index2.cjs.map +1 -0
- package/dist/_chunks-cjs/workflow.cjs +16 -0
- package/dist/_chunks-cjs/workflow.cjs.map +1 -0
- package/dist/_chunks-es/constants.js +14 -0
- package/dist/_chunks-es/constants.js.map +1 -0
- package/dist/_chunks-es/index.js +175 -0
- package/dist/_chunks-es/index.js.map +1 -0
- package/dist/_chunks-es/index2.js +340 -0
- package/dist/_chunks-es/index2.js.map +1 -0
- package/dist/_chunks-es/workflow.js +17 -0
- package/dist/_chunks-es/workflow.js.map +1 -0
- package/dist/export.cjs +160 -0
- package/dist/export.cjs.map +1 -0
- package/dist/export.d.cts +162 -0
- package/dist/export.d.ts +162 -0
- package/dist/export.js +162 -0
- package/dist/export.js.map +1 -0
- package/dist/import.cjs +169 -0
- package/dist/import.cjs.map +1 -0
- package/dist/import.d.cts +155 -0
- package/dist/import.d.ts +155 -0
- package/dist/import.js +169 -0
- package/dist/import.js.map +1 -0
- package/dist/index.cjs +2172 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +122 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2179 -0
- package/dist/index.js.map +1 -0
- package/dist/locjson.cjs +12 -0
- package/dist/locjson.cjs.map +1 -0
- package/dist/locjson.d.cts +291 -0
- package/dist/locjson.d.ts +291 -0
- package/dist/locjson.js +12 -0
- package/dist/locjson.js.map +1 -0
- package/dist/progress.cjs +16 -0
- package/dist/progress.cjs.map +1 -0
- package/dist/progress.d.cts +277 -0
- package/dist/progress.d.ts +277 -0
- package/dist/progress.js +16 -0
- package/dist/progress.js.map +1 -0
- package/dist/smartcat.cjs +287 -0
- package/dist/smartcat.cjs.map +1 -0
- package/dist/smartcat.d.cts +234 -0
- package/dist/smartcat.d.ts +234 -0
- package/dist/smartcat.js +287 -0
- package/dist/smartcat.js.map +1 -0
- package/dist/templates.cjs +12 -0
- package/dist/templates.cjs.map +1 -0
- package/dist/templates.d.cts +52 -0
- package/dist/templates.d.ts +52 -0
- package/dist/templates.js +12 -0
- package/dist/templates.js.map +1 -0
- package/package.json +101 -15
- package/src/actions/AddToProjectAction.tsx +274 -0
- package/src/export/export.test.ts +537 -0
- package/src/export/index.ts +393 -0
- package/src/form/TranslationStatusInput.tsx +212 -0
- package/src/import/import.test.ts +346 -0
- package/src/import/index.ts +418 -0
- package/src/index.ts +63 -0
- package/src/lib/constants.ts +23 -0
- package/src/lib/documentTitle.test.ts +43 -0
- package/src/lib/documentTitle.ts +30 -0
- package/src/lib/languageMap.test.ts +56 -0
- package/src/lib/languageMap.ts +71 -0
- package/src/lib/linkedDocuments.test.ts +56 -0
- package/src/lib/linkedDocuments.ts +100 -0
- package/src/lib/locjson/deserialize.ts +60 -0
- package/src/lib/locjson/fields.ts +355 -0
- package/src/lib/locjson/filename.ts +34 -0
- package/src/lib/locjson/index.ts +10 -0
- package/src/lib/locjson/locjson.test.ts +615 -0
- package/src/lib/locjson/paths.test.ts +41 -0
- package/src/lib/locjson/paths.ts +73 -0
- package/src/lib/locjson/portableText.ts +157 -0
- package/src/lib/locjson/serialize.ts +124 -0
- package/src/lib/locjson/types.ts +106 -0
- package/src/lib/log.ts +33 -0
- package/src/lib/projectItems.ts +31 -0
- package/src/lib/resolveConfig.test.ts +62 -0
- package/src/lib/resolveConfig.ts +54 -0
- package/src/lib/workflow.test.ts +37 -0
- package/src/lib/workflow.ts +48 -0
- package/src/lib/zip.fixtures.ts +75 -0
- package/src/lib/zip.test.ts +110 -0
- package/src/lib/zip.ts +164 -0
- package/src/progress/core.ts +409 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/progress.test.ts +290 -0
- package/src/schema/settings.ts +55 -0
- package/src/schema/translatableOptions.ts +25 -0
- package/src/schema/translationProject.ts +268 -0
- package/src/smartcat/client.test.ts +222 -0
- package/src/smartcat/client.ts +351 -0
- package/src/smartcat/index.ts +10 -0
- package/src/smartcat/types.ts +99 -0
- package/src/templates/core.ts +55 -0
- package/src/templates/index.ts +5 -0
- package/src/templates/templates.test.ts +50 -0
- package/src/tool/Dashboard.tsx +46 -0
- package/src/tool/ItemProgress.tsx +176 -0
- package/src/tool/LogPanel.tsx +207 -0
- package/src/tool/ProjectView.tsx +1004 -0
- package/src/tool/ProjectsView.tsx +208 -0
- package/src/tool/StatusBadge.tsx +33 -0
- package/src/tool/StudioInit.tsx +42 -0
- package/src/tool/WorkflowSelect.tsx +41 -0
- package/src/tool/data.ts +86 -0
- package/src/tool/index.ts +19 -0
- package/src/tool/processing.test.ts +698 -0
- package/src/tool/processing.ts +839 -0
- package/src/tool/useTemplates.ts +84 -0
- package/src/tool/useWorkflowSelection.ts +65 -0
- 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 ` ` 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
|
+
})
|