@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.
- 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 +168 -0
- package/dist/_chunks-cjs/index.cjs.map +1 -0
- package/dist/_chunks-cjs/index2.cjs +311 -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 +169 -0
- package/dist/_chunks-es/index.js.map +1 -0
- package/dist/_chunks-es/index2.js +313 -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 +2103 -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 +2110 -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 +287 -0
- package/dist/locjson.d.ts +287 -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 +262 -0
- package/dist/progress.d.ts +262 -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 +258 -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 +27 -0
- package/src/lib/locjson/index.ts +10 -0
- package/src/lib/locjson/locjson.test.ts +545 -0
- package/src/lib/locjson/paths.test.ts +41 -0
- package/src/lib/locjson/paths.ts +73 -0
- package/src/lib/locjson/portableText.ts +98 -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 +17 -0
- package/src/lib/resolveConfig.test.ts +62 -0
- package/src/lib/resolveConfig.ts +47 -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 +387 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/progress.test.ts +268 -0
- package/src/schema/settings.ts +55 -0
- package/src/schema/translatableOptions.ts +25 -0
- package/src/schema/translationProject.ts +264 -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 +44 -0
- package/src/tool/ItemProgress.tsx +176 -0
- package/src/tool/LogPanel.tsx +207 -0
- package/src/tool/ProjectView.tsx +975 -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 +79 -0
- package/src/tool/index.ts +19 -0
- package/src/tool/processing.test.ts +623 -0
- package/src/tool/processing.ts +766 -0
- package/src/tool/useTemplates.ts +84 -0
- package/src/tool/useWorkflowSelection.ts +65 -0
- 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 ` ` 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
|
+
}
|