@smartcat/sanity-plugin 1.0.1 → 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/dist/_chunks-cjs/index.cjs +10 -4
- package/dist/_chunks-cjs/index.cjs.map +1 -1
- package/dist/_chunks-cjs/index2.cjs +34 -7
- package/dist/_chunks-cjs/index2.cjs.map +1 -1
- package/dist/_chunks-es/index.js +10 -4
- package/dist/_chunks-es/index.js.map +1 -1
- package/dist/_chunks-es/index2.js +35 -8
- package/dist/_chunks-es/index2.js.map +1 -1
- package/dist/import.cjs +1 -1
- package/dist/import.cjs.map +1 -1
- package/dist/import.js +2 -2
- package/dist/import.js.map +1 -1
- package/dist/index.cjs +96 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +100 -31
- package/dist/index.js.map +1 -1
- package/dist/locjson.d.cts +5 -1
- package/dist/locjson.d.ts +5 -1
- package/dist/progress.d.cts +24 -9
- package/dist/progress.d.ts +24 -9
- package/package.json +1 -1
- package/src/actions/AddToProjectAction.tsx +21 -5
- package/src/import/index.ts +3 -3
- package/src/lib/locjson/filename.ts +10 -3
- package/src/lib/locjson/locjson.test.ts +78 -8
- package/src/lib/locjson/portableText.ts +71 -12
- package/src/lib/projectItems.ts +14 -0
- package/src/lib/resolveConfig.ts +7 -0
- package/src/progress/core.ts +37 -15
- package/src/progress/progress.test.ts +22 -0
- package/src/schema/translationProject.ts +4 -0
- package/src/tool/Dashboard.tsx +3 -1
- package/src/tool/ProjectView.tsx +35 -6
- package/src/tool/data.ts +7 -0
- package/src/tool/processing.test.ts +79 -4
- package/src/tool/processing.ts +84 -11
package/dist/locjson.d.cts
CHANGED
|
@@ -5,12 +5,16 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The type acts as a folder; the id prefix guarantees uniqueness. Identity on
|
|
7
7
|
* import comes from the file's `x-sanity` metadata, not this name, so renames
|
|
8
|
-
* are safe.
|
|
8
|
+
* are safe. When the exported content came from the document's draft, a `-draft`
|
|
9
|
+
* marker is appended so it's visible in Smartcat (`…-3f14f092-draft.locjson`).
|
|
9
10
|
*/
|
|
10
11
|
export declare function buildLocjsonFilename(
|
|
11
12
|
documentType: string,
|
|
12
13
|
documentId: string,
|
|
13
14
|
title?: string,
|
|
15
|
+
options?: {
|
|
16
|
+
draft?: boolean;
|
|
17
|
+
},
|
|
14
18
|
): string;
|
|
15
19
|
|
|
16
20
|
export declare interface DeserializedDocument {
|
package/dist/locjson.d.ts
CHANGED
|
@@ -5,12 +5,16 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The type acts as a folder; the id prefix guarantees uniqueness. Identity on
|
|
7
7
|
* import comes from the file's `x-sanity` metadata, not this name, so renames
|
|
8
|
-
* are safe.
|
|
8
|
+
* are safe. When the exported content came from the document's draft, a `-draft`
|
|
9
|
+
* marker is appended so it's visible in Smartcat (`…-3f14f092-draft.locjson`).
|
|
9
10
|
*/
|
|
10
11
|
export declare function buildLocjsonFilename(
|
|
11
12
|
documentType: string,
|
|
12
13
|
documentId: string,
|
|
13
14
|
title?: string,
|
|
15
|
+
options?: {
|
|
16
|
+
draft?: boolean;
|
|
17
|
+
},
|
|
14
18
|
): string;
|
|
15
19
|
|
|
16
20
|
export declare interface DeserializedDocument {
|
package/dist/progress.d.cts
CHANGED
|
@@ -58,13 +58,14 @@ export declare function hasConfirmedContent(stages: StageProgress[]): boolean;
|
|
|
58
58
|
/** A target is complete when it has stages and every one is at 100%. */
|
|
59
59
|
export declare function isTargetComplete(stages: StageProgress[]): boolean;
|
|
60
60
|
|
|
61
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Resolves a project's items into the lightweight shape we correlate on. The id
|
|
63
|
+
* is the item's stored `docId` — NOT a dereferenced document — so an item whose
|
|
64
|
+
* document exists only as a draft (never published) still correlates instead of
|
|
65
|
+
* being silently dropped. Tolerates the legacy `{doc}` projection.
|
|
66
|
+
*/
|
|
62
67
|
export declare function itemsFromProject(project: {
|
|
63
|
-
items?:
|
|
64
|
-
doc: {
|
|
65
|
-
_id: string;
|
|
66
|
-
} | null;
|
|
67
|
-
}[];
|
|
68
|
+
items?: ProjectItemRef[];
|
|
68
69
|
}): ProjectItem[];
|
|
69
70
|
|
|
70
71
|
/** Sanitizes an arbitrary string into a stable, Sanity-safe array `_key`. */
|
|
@@ -76,6 +77,17 @@ export declare interface ProjectItem {
|
|
|
76
77
|
title?: string;
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/** A project item as read for correlation: its stored id (always present) plus a
|
|
81
|
+
* best-effort title. `doc` is the legacy shape — some callers still project it. */
|
|
82
|
+
declare interface ProjectItemRef {
|
|
83
|
+
_id?: string;
|
|
84
|
+
title?: string;
|
|
85
|
+
doc?: {
|
|
86
|
+
_id?: string;
|
|
87
|
+
title?: string;
|
|
88
|
+
} | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
79
91
|
/** Sink for per-request tracing; attach with `SmartcatClient.setRequestLogger`. */
|
|
80
92
|
declare type RequestLogger = (info: RequestLogInfo) => void;
|
|
81
93
|
|
|
@@ -193,11 +205,14 @@ declare interface SmartcatWorkflowStage {
|
|
|
193
205
|
|
|
194
206
|
/**
|
|
195
207
|
* Extracts the 8-char source-id prefix Smartcat preserves in a document's
|
|
196
|
-
* filename (built as `<title>-<idPrefix>.locjson
|
|
208
|
+
* filename (built as `<title>-<idPrefix>.locjson`, or `<title>-<idPrefix>-draft.locjson`
|
|
209
|
+
* when the content came from the document's draft). Used to correlate a Smartcat
|
|
197
210
|
* document back to a Sanity source document.
|
|
198
211
|
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
212
|
+
* A trailing `-draft` marker is stripped first, so a draft-sourced file resolves
|
|
213
|
+
* to the same id as its published counterpart. The prefix is then always the
|
|
214
|
+
* final 8 characters before the (marker and) extension, so take the tail rather
|
|
215
|
+
* than splitting on a dash — the title or the id prefix itself can
|
|
201
216
|
* contain dashes (e.g. a custom id like `demo-pdpA` → prefix `demo-pdp`), which
|
|
202
217
|
* would make a last-dash split return the wrong fragment.
|
|
203
218
|
*
|
package/dist/progress.d.ts
CHANGED
|
@@ -58,13 +58,14 @@ export declare function hasConfirmedContent(stages: StageProgress[]): boolean;
|
|
|
58
58
|
/** A target is complete when it has stages and every one is at 100%. */
|
|
59
59
|
export declare function isTargetComplete(stages: StageProgress[]): boolean;
|
|
60
60
|
|
|
61
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Resolves a project's items into the lightweight shape we correlate on. The id
|
|
63
|
+
* is the item's stored `docId` — NOT a dereferenced document — so an item whose
|
|
64
|
+
* document exists only as a draft (never published) still correlates instead of
|
|
65
|
+
* being silently dropped. Tolerates the legacy `{doc}` projection.
|
|
66
|
+
*/
|
|
62
67
|
export declare function itemsFromProject(project: {
|
|
63
|
-
items?:
|
|
64
|
-
doc: {
|
|
65
|
-
_id: string;
|
|
66
|
-
} | null;
|
|
67
|
-
}[];
|
|
68
|
+
items?: ProjectItemRef[];
|
|
68
69
|
}): ProjectItem[];
|
|
69
70
|
|
|
70
71
|
/** Sanitizes an arbitrary string into a stable, Sanity-safe array `_key`. */
|
|
@@ -76,6 +77,17 @@ export declare interface ProjectItem {
|
|
|
76
77
|
title?: string;
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/** A project item as read for correlation: its stored id (always present) plus a
|
|
81
|
+
* best-effort title. `doc` is the legacy shape — some callers still project it. */
|
|
82
|
+
declare interface ProjectItemRef {
|
|
83
|
+
_id?: string;
|
|
84
|
+
title?: string;
|
|
85
|
+
doc?: {
|
|
86
|
+
_id?: string;
|
|
87
|
+
title?: string;
|
|
88
|
+
} | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
79
91
|
/** Sink for per-request tracing; attach with `SmartcatClient.setRequestLogger`. */
|
|
80
92
|
declare type RequestLogger = (info: RequestLogInfo) => void;
|
|
81
93
|
|
|
@@ -193,11 +205,14 @@ declare interface SmartcatWorkflowStage {
|
|
|
193
205
|
|
|
194
206
|
/**
|
|
195
207
|
* Extracts the 8-char source-id prefix Smartcat preserves in a document's
|
|
196
|
-
* filename (built as `<title>-<idPrefix>.locjson
|
|
208
|
+
* filename (built as `<title>-<idPrefix>.locjson`, or `<title>-<idPrefix>-draft.locjson`
|
|
209
|
+
* when the content came from the document's draft). Used to correlate a Smartcat
|
|
197
210
|
* document back to a Sanity source document.
|
|
198
211
|
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
212
|
+
* A trailing `-draft` marker is stripped first, so a draft-sourced file resolves
|
|
213
|
+
* to the same id as its published counterpart. The prefix is then always the
|
|
214
|
+
* final 8 characters before the (marker and) extension, so take the tail rather
|
|
215
|
+
* than splitting on a dash — the title or the id prefix itself can
|
|
201
216
|
* contain dashes (e.g. a custom id like `demo-pdpA` → prefix `demo-pdp`), which
|
|
202
217
|
* would make a last-dash split return the wrong fragment.
|
|
203
218
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {useCallback, useEffect, useMemo, useState} from 'react'
|
|
2
|
-
import {useClient, useSchema, type DocumentActionComponent, type DocumentActionProps} from 'sanity'
|
|
3
|
-
import {Box, Button, Card, Flex, Inline, Label, Select, Spinner, Stack, Switch, Text, TextInput, useToast} from '@sanity/ui'
|
|
2
|
+
import {useClient, usePerspective, useSchema, type DocumentActionComponent, type DocumentActionProps} from 'sanity'
|
|
3
|
+
import {Badge, Box, Button, Card, Flex, Inline, Label, Select, Spinner, Stack, Switch, Text, TextInput, useToast} from '@sanity/ui'
|
|
4
4
|
import {AddIcon, TranslateIcon} from '@sanity/icons'
|
|
5
5
|
import {uuid} from '@sanity/uuid'
|
|
6
6
|
import {API_VERSION, TRANSLATION_PROJECT_TYPE} from '../lib/constants'
|
|
@@ -43,6 +43,12 @@ export function createAddToProjectAction(config: ResolvedSmartcatConfig): Docume
|
|
|
43
43
|
const client = useClient({apiVersion: API_VERSION})
|
|
44
44
|
const schema = useSchema()
|
|
45
45
|
const toast = useToast()
|
|
46
|
+
// Which version is the translation source, decided here and stored on the
|
|
47
|
+
// item (the pill and the export both read it — no re-resolving later): the
|
|
48
|
+
// published document when the published perspective is selected or there is
|
|
49
|
+
// no draft; otherwise the draft, which is what the editor sees and edits.
|
|
50
|
+
const {selectedPerspectiveName} = usePerspective()
|
|
51
|
+
const sourceIsPublished = selectedPerspectiveName === 'published' || !draft
|
|
46
52
|
|
|
47
53
|
const [open, setOpen] = useState(false)
|
|
48
54
|
const [projects, setProjects] = useState<ProjectOption[] | null>(null)
|
|
@@ -96,6 +102,7 @@ export function createAddToProjectAction(config: ResolvedSmartcatConfig): Docume
|
|
|
96
102
|
_key: uuid(),
|
|
97
103
|
docId,
|
|
98
104
|
docType,
|
|
105
|
+
sourceIsPublished,
|
|
99
106
|
})
|
|
100
107
|
|
|
101
108
|
// The root document plus, optionally, everything it links to — de-duped by id.
|
|
@@ -152,7 +159,7 @@ export function createAddToProjectAction(config: ResolvedSmartcatConfig): Docume
|
|
|
152
159
|
} finally {
|
|
153
160
|
setBusy(false)
|
|
154
161
|
}
|
|
155
|
-
}, [selection, newName, workflow, publishedId, doc, includeLinked, schema, client, projects, close])
|
|
162
|
+
}, [selection, newName, workflow, publishedId, doc, includeLinked, sourceIsPublished, schema, client, projects, close])
|
|
156
163
|
|
|
157
164
|
const content = useMemo(() => {
|
|
158
165
|
if (projects === null) {
|
|
@@ -206,7 +213,16 @@ export function createAddToProjectAction(config: ResolvedSmartcatConfig): Docume
|
|
|
206
213
|
<Card padding={3} radius={2} tone="transparent" border>
|
|
207
214
|
<Stack space={4}>
|
|
208
215
|
<Text size={1} muted>
|
|
209
|
-
Adding
|
|
216
|
+
Adding{' '}
|
|
217
|
+
<Badge
|
|
218
|
+
tone={sourceIsPublished ? 'positive' : 'caution'}
|
|
219
|
+
fontSize={1}
|
|
220
|
+
paddingX={2}
|
|
221
|
+
style={{verticalAlign: 'middle'}}
|
|
222
|
+
>
|
|
223
|
+
{sourceIsPublished ? 'Published' : 'Draft'}
|
|
224
|
+
</Badge>{' '}
|
|
225
|
+
version of <strong>{docTitle}</strong> to the selected project.
|
|
210
226
|
</Text>
|
|
211
227
|
<Flex align="flex-start" gap={3}>
|
|
212
228
|
<Switch
|
|
@@ -239,7 +255,7 @@ export function createAddToProjectAction(config: ResolvedSmartcatConfig): Docume
|
|
|
239
255
|
</Flex>
|
|
240
256
|
</Stack>
|
|
241
257
|
)
|
|
242
|
-
}, [projects, selection, newName, workflow, templates, busy, includeLinked, docTitle, close, handleConfirm])
|
|
258
|
+
}, [projects, selection, newName, workflow, templates, busy, includeLinked, docTitle, sourceIsPublished, close, handleConfirm])
|
|
243
259
|
|
|
244
260
|
return {
|
|
245
261
|
label: 'Add to translation project',
|
package/src/import/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type {SmartcatDocument, SmartcatProject} from '../smartcat/types'
|
|
2
2
|
import {buildProgress, hasConfirmedContent, itemsFromProject, type DocProgress} from '../progress/core'
|
|
3
|
-
import {
|
|
3
|
+
import {ITEM_ID, itemDocDerefRespectingDraft} from '../lib/projectItems'
|
|
4
4
|
import type {SmartcatLanguageMapping} from '../lib/languageMap'
|
|
5
5
|
import type {LogLine} from '../lib/log'
|
|
6
6
|
|
|
@@ -90,7 +90,7 @@ const PROJECT_QUERY = `*[_id == $id][0]{
|
|
|
90
90
|
_id,
|
|
91
91
|
smartcatProjectId,
|
|
92
92
|
smartcatLanguages[]{sanityId, smartcatLanguage},
|
|
93
|
-
"items": items[]{ "
|
|
93
|
+
"items": items[]{ "_id": ${ITEM_ID}, "title": ${itemDocDerefRespectingDraft('.title')} },
|
|
94
94
|
progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}
|
|
95
95
|
}`
|
|
96
96
|
|
|
@@ -98,7 +98,7 @@ interface ProjectData {
|
|
|
98
98
|
_id: string
|
|
99
99
|
smartcatProjectId?: string
|
|
100
100
|
smartcatLanguages?: SmartcatLanguageMapping[]
|
|
101
|
-
items?: {
|
|
101
|
+
items?: {_id?: string; title?: string}[]
|
|
102
102
|
progress?: DocProgress[]
|
|
103
103
|
}
|
|
104
104
|
|
|
@@ -5,13 +5,20 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The type acts as a folder; the id prefix guarantees uniqueness. Identity on
|
|
7
7
|
* import comes from the file's `x-sanity` metadata, not this name, so renames
|
|
8
|
-
* are safe.
|
|
8
|
+
* are safe. When the exported content came from the document's draft, a `-draft`
|
|
9
|
+
* marker is appended so it's visible in Smartcat (`…-3f14f092-draft.locjson`).
|
|
9
10
|
*/
|
|
10
|
-
export function buildLocjsonFilename(
|
|
11
|
+
export function buildLocjsonFilename(
|
|
12
|
+
documentType: string,
|
|
13
|
+
documentId: string,
|
|
14
|
+
title?: string,
|
|
15
|
+
options: {draft?: boolean} = {},
|
|
16
|
+
): string {
|
|
11
17
|
const idPrefix = stripDraft(documentId).slice(0, 8) || 'unknown'
|
|
12
18
|
const safeTitle = sanitizeSegment(title || 'Untitled')
|
|
13
19
|
const safeType = sanitizeSegment(documentType) || 'document'
|
|
14
|
-
|
|
20
|
+
const draftMarker = options.draft ? '-draft' : ''
|
|
21
|
+
return `${safeType}/${safeTitle}-${idPrefix}${draftMarker}.locjson`
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
function stripDraft(id: string): string {
|
|
@@ -22,6 +22,9 @@ import type {LocJSON} from './types'
|
|
|
22
22
|
const schema = Schema.compile({
|
|
23
23
|
name: 'test',
|
|
24
24
|
types: [
|
|
25
|
+
// @sanity/table shape: rows of plain-string cells, embedded in Portable Text.
|
|
26
|
+
{name: 'tableRow', type: 'object', fields: [{name: 'cells', type: 'array', of: [{type: 'string'}]}]},
|
|
27
|
+
{name: 'table', type: 'object', fields: [{name: 'rows', type: 'array', of: [{type: 'tableRow'}]}]},
|
|
25
28
|
{
|
|
26
29
|
name: 'page',
|
|
27
30
|
type: 'document',
|
|
@@ -33,7 +36,7 @@ const schema = Schema.compile({
|
|
|
33
36
|
{name: 'featured', type: 'boolean'},
|
|
34
37
|
{name: 'tags', type: 'array', of: [{type: 'string'}]},
|
|
35
38
|
{name: 'seo', type: 'object', fields: [{name: 'metaTitle', type: 'string'}]},
|
|
36
|
-
{name: 'body', type: 'array', of: [{type: 'block'}]},
|
|
39
|
+
{name: 'body', type: 'array', of: [{type: 'block'}, {type: 'table'}]},
|
|
37
40
|
],
|
|
38
41
|
},
|
|
39
42
|
],
|
|
@@ -55,6 +58,14 @@ function makeBlock(text: string, marks: string[] = []) {
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
function makeTable(rows: string[][]) {
|
|
62
|
+
return {
|
|
63
|
+
_type: 'table',
|
|
64
|
+
_key: 't1',
|
|
65
|
+
rows: rows.map((cells, i) => ({_type: 'tableRow', _key: `row${i}`, cells})),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
58
69
|
describe('getTranslatableFields', () => {
|
|
59
70
|
it('selects string/text/portable-text and excludes the rest', () => {
|
|
60
71
|
const fields = getTranslatableFields(pageType)
|
|
@@ -176,26 +187,39 @@ describe('getTranslatableFields', () => {
|
|
|
176
187
|
|
|
177
188
|
describe('portableTextToHtml', () => {
|
|
178
189
|
it('renders unknown/custom block types as nothing, not a placeholder', () => {
|
|
179
|
-
// A custom
|
|
180
|
-
// hidden `<div style="display:none">Unknown block type "
|
|
190
|
+
// A custom block with no to-html component. The default behavior emits a
|
|
191
|
+
// hidden `<div style="display:none">Unknown block type "…"</div>`, whose
|
|
181
192
|
// English text would be translated into every locale and which Smartcat
|
|
182
193
|
// rejects as unparseable HTML. It must be dropped entirely instead.
|
|
183
194
|
const blocks = [
|
|
184
195
|
makeBlock('Delivery info'),
|
|
185
|
-
{_type: '
|
|
196
|
+
{_type: 'heroBanner', _key: 'h1', heading: 'Save 20%'},
|
|
186
197
|
]
|
|
187
198
|
const html = portableTextToHtml(blocks)
|
|
188
199
|
expect(html).not.toContain('Unknown block type')
|
|
189
200
|
expect(html).not.toContain('display:none')
|
|
190
201
|
expect(html).toContain('Delivery info')
|
|
191
202
|
})
|
|
203
|
+
|
|
204
|
+
it('renders a table block to an HTML <table>, escaping cell text', () => {
|
|
205
|
+
const html = portableTextToHtml([makeTable([['Region', 'Price'], ['EU', '<10 & up']])])
|
|
206
|
+
expect(html).toContain('<table><tbody>')
|
|
207
|
+
expect(html).toContain('<tr><td>Region</td><td>Price</td></tr>')
|
|
208
|
+
// cells are HTML-escaped so markup-looking text survives Smartcat's parser
|
|
209
|
+
expect(html).toContain('<td><10 & up</td>')
|
|
210
|
+
})
|
|
192
211
|
})
|
|
193
212
|
|
|
194
213
|
describe('findUnserializableBlockTypes', () => {
|
|
195
|
-
const table =
|
|
214
|
+
const table = makeTable([['A', 'B']])
|
|
196
215
|
|
|
197
216
|
it('flags custom top-level block types', () => {
|
|
198
|
-
|
|
217
|
+
const embed = {_type: 'heroBanner', _key: 'h1', heading: 'x'}
|
|
218
|
+
expect(findUnserializableBlockTypes([makeBlock('text'), embed])).toEqual(['heroBanner'])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('does NOT flag a table block (it round-trips through HTML)', () => {
|
|
222
|
+
expect(findUnserializableBlockTypes([makeBlock('text'), table])).toEqual([])
|
|
199
223
|
})
|
|
200
224
|
|
|
201
225
|
it('flags custom inline children (non-span)', () => {
|
|
@@ -295,7 +319,7 @@ describe('serializeToLocjson', () => {
|
|
|
295
319
|
_id: 'x',
|
|
296
320
|
_type: 'page',
|
|
297
321
|
title: 'Delivery',
|
|
298
|
-
body: [makeBlock('How much is delivery?'), {_type: '
|
|
322
|
+
body: [makeBlock('How much is delivery?'), {_type: 'heroBanner', _key: 'h1', heading: 'x'}],
|
|
299
323
|
},
|
|
300
324
|
fields,
|
|
301
325
|
{
|
|
@@ -306,7 +330,18 @@ describe('serializeToLocjson', () => {
|
|
|
306
330
|
)
|
|
307
331
|
// title still serialized; body left out entirely (not partially)
|
|
308
332
|
expect(result.units.map((u) => u.key)).toEqual(['title'])
|
|
309
|
-
expect(skipped).toEqual([{fieldPath: 'body', types: ['
|
|
333
|
+
expect(skipped).toEqual([{fieldPath: 'body', types: ['heroBanner']}])
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('serializes a body containing a table (renders it into the HTML unit)', () => {
|
|
337
|
+
const result = serializeToLocjson(
|
|
338
|
+
{_id: 'x', _type: 'page', title: '', body: [makeTable([['Region', 'Price']])]},
|
|
339
|
+
fields,
|
|
340
|
+
{sourceLanguage: 'en', fieldLanguageKey: 'language'},
|
|
341
|
+
)
|
|
342
|
+
const body = result.units.find((u) => u.key === 'body')!
|
|
343
|
+
expect(body.properties?.['x-smartcat-format']).toBe('html')
|
|
344
|
+
expect(body.source[0]).toContain('<td>Region</td>')
|
|
310
345
|
})
|
|
311
346
|
|
|
312
347
|
it('skips empty fields', () => {
|
|
@@ -402,6 +437,35 @@ describe('round-trip', () => {
|
|
|
402
437
|
const result = deserializeFromLocjson(translated, {parseHtml, getBlockContentType})
|
|
403
438
|
expect(result.fields.title).toBe('À propos')
|
|
404
439
|
})
|
|
440
|
+
|
|
441
|
+
it('round-trips a table block, translating its cells', () => {
|
|
442
|
+
const tableDoc = {
|
|
443
|
+
_id: 'abc12345-0000',
|
|
444
|
+
_type: 'page',
|
|
445
|
+
title: 'Pricing',
|
|
446
|
+
body: [makeBlock('Our plans:'), makeTable([['Region', 'Price'], ['EU', '€10']])],
|
|
447
|
+
}
|
|
448
|
+
const locjson = serializeToLocjson(tableDoc, fields, {sourceLanguage: 'en', fieldLanguageKey: 'language'})
|
|
449
|
+
// Translate the header cells by rewriting the HTML unit's target.
|
|
450
|
+
const translated: LocJSON = {
|
|
451
|
+
...locjson,
|
|
452
|
+
units: locjson.units.map((u) =>
|
|
453
|
+
u.key === 'body'
|
|
454
|
+
? {...u, target: [u.source[0].replace('Region', 'Région').replace('Price', 'Prix')]}
|
|
455
|
+
: {...u, target: [...u.source]},
|
|
456
|
+
),
|
|
457
|
+
}
|
|
458
|
+
const result = deserializeFromLocjson(translated, {parseHtml, getBlockContentType})
|
|
459
|
+
const body = result.fields.body as Array<Record<string, unknown>>
|
|
460
|
+
const table = body.find((b) => b._type === 'table')!
|
|
461
|
+
const rows = table.rows as Array<{_type: string; _key: string; cells: string[]}>
|
|
462
|
+
expect(rows.map((r) => r.cells)).toEqual([
|
|
463
|
+
['Région', 'Prix'],
|
|
464
|
+
['EU', '€10'],
|
|
465
|
+
])
|
|
466
|
+
// rows keep the tableRow type and carry a _key (required for array members)
|
|
467
|
+
expect(rows.every((r) => r._type === 'tableRow' && typeof r._key === 'string')).toBe(true)
|
|
468
|
+
})
|
|
405
469
|
})
|
|
406
470
|
|
|
407
471
|
// --- Field-level localization (sanity-plugin-internationalized-array) ---------
|
|
@@ -542,4 +606,10 @@ describe('buildLocjsonFilename', () => {
|
|
|
542
606
|
it('falls back when title is missing', () => {
|
|
543
607
|
expect(buildLocjsonFilename('page', 'abc12345-0000')).toBe('page/Untitled-abc12345.locjson')
|
|
544
608
|
})
|
|
609
|
+
|
|
610
|
+
it('appends a -draft marker when the source was a draft', () => {
|
|
611
|
+
expect(buildLocjsonFilename('page', '3f14f092-3475', 'About Us', {draft: true})).toBe(
|
|
612
|
+
'page/About Us-3f14f092-draft.locjson',
|
|
613
|
+
)
|
|
614
|
+
})
|
|
545
615
|
})
|
|
@@ -1,9 +1,51 @@
|
|
|
1
|
-
import {toHTML} from '@portabletext/to-html'
|
|
2
|
-
import {htmlToBlocks} from '@sanity/block-tools'
|
|
1
|
+
import {escapeHTML, toHTML} from '@portabletext/to-html'
|
|
2
|
+
import {htmlToBlocks, type DeserializerRule} from '@sanity/block-tools'
|
|
3
3
|
|
|
4
4
|
/** A Portable Text block (loosely typed — shape varies by schema). */
|
|
5
5
|
export type PortableTextBlock = Record<string, unknown>
|
|
6
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
|
+
|
|
7
49
|
/** Parses an HTML string into a DOM Document. Provided by the environment. */
|
|
8
50
|
export type ParseHtml = (html: string) => Document
|
|
9
51
|
|
|
@@ -23,18 +65,24 @@ export function portableTextToHtml(blocks: PortableTextBlock[] | undefined | nul
|
|
|
23
65
|
// (`onMissingComponent: false` only silences the warning; it does NOT stop the
|
|
24
66
|
// placeholder — the `unknownType` override is what suppresses it.)
|
|
25
67
|
onMissingComponent: false,
|
|
26
|
-
components: {
|
|
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
|
+
},
|
|
27
74
|
})
|
|
28
75
|
}
|
|
29
76
|
|
|
30
77
|
/**
|
|
31
78
|
* 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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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).
|
|
38
86
|
*/
|
|
39
87
|
export function findUnserializableBlockTypes(
|
|
40
88
|
blocks: PortableTextBlock[] | undefined | null,
|
|
@@ -45,7 +93,7 @@ export function findUnserializableBlockTypes(
|
|
|
45
93
|
if (!block || typeof block !== 'object') continue
|
|
46
94
|
const type = (block as {_type?: unknown})._type
|
|
47
95
|
if (type !== 'block') {
|
|
48
|
-
if (typeof type === 'string') types.add(type)
|
|
96
|
+
if (typeof type === 'string' && !SERIALIZABLE_CUSTOM_TYPES.has(type)) types.add(type)
|
|
49
97
|
continue
|
|
50
98
|
}
|
|
51
99
|
const children = (block as {children?: unknown}).children
|
|
@@ -73,9 +121,17 @@ export function portableTextToPlainText(blocks: PortableTextBlock[] | undefined
|
|
|
73
121
|
if (Array.isArray(node)) {
|
|
74
122
|
node.forEach(visit)
|
|
75
123
|
} else if (node && typeof node === 'object') {
|
|
76
|
-
const value = node as {text?: unknown; children?: unknown}
|
|
124
|
+
const value = node as {text?: unknown; children?: unknown; rows?: unknown}
|
|
77
125
|
if (typeof value.text === 'string') text += value.text
|
|
78
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
|
+
}
|
|
79
135
|
}
|
|
80
136
|
}
|
|
81
137
|
visit(blocks)
|
|
@@ -94,5 +150,8 @@ export function htmlToPortableText(
|
|
|
94
150
|
parseHtml: ParseHtml,
|
|
95
151
|
): PortableTextBlock[] {
|
|
96
152
|
if (!html.trim()) return []
|
|
97
|
-
return htmlToBlocks(html, blockContentType as never, {
|
|
153
|
+
return htmlToBlocks(html, blockContentType as never, {
|
|
154
|
+
parseHtml,
|
|
155
|
+
rules: [tableRule],
|
|
156
|
+
}) as unknown as PortableTextBlock[]
|
|
98
157
|
}
|
package/src/lib/projectItems.ts
CHANGED
|
@@ -15,3 +15,17 @@ export const ITEM_ID_FROM_SUBQUERY = 'coalesce(^.docId, ^._ref)'
|
|
|
15
15
|
|
|
16
16
|
/** Dereference an item's document, with an optional projection e.g. `{_id, _type}`. */
|
|
17
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
|
+
`)`
|
package/src/lib/resolveConfig.ts
CHANGED
|
@@ -17,6 +17,12 @@ export interface ResolvedSmartcatConfig {
|
|
|
17
17
|
* plugin's own `smartcat.*` types; otherwise it's list membership.
|
|
18
18
|
*/
|
|
19
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[]
|
|
20
26
|
/** Root/standalone types where the linked-documents crawl stops. */
|
|
21
27
|
rootTypes: string[]
|
|
22
28
|
sourceLanguage: string
|
|
@@ -35,6 +41,7 @@ export function resolveConfig(config: SmartcatPluginConfig): ResolvedSmartcatCon
|
|
|
35
41
|
|
|
36
42
|
return {
|
|
37
43
|
isTranslatableType,
|
|
44
|
+
translatableTypes,
|
|
38
45
|
// `rootTypes` inherits the *explicitly-configured* translatableTypes (not the
|
|
39
46
|
// resolved "everything") so an unconfigured Studio gets no crawl boundary
|
|
40
47
|
// rather than every type being a root (which would stop the crawl at once).
|