@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.
@@ -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 {
@@ -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
- /** Resolves a project's referenced items into the lightweight shape we correlate on. */
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`). Used to correlate a Smartcat
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
- * The prefix is always the final 8 characters before the extension, so take the
200
- * tail rather than splitting on a dash the title or the id prefix itself can
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
  *
@@ -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
- /** Resolves a project's referenced items into the lightweight shape we correlate on. */
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`). Used to correlate a Smartcat
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
- * The prefix is always the final 8 characters before the extension, so take the
200
- * tail rather than splitting on a dash the title or the id prefix itself can
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
  {
2
2
  "name": "@smartcat/sanity-plugin",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -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 <strong>{docTitle}</strong> to the selected project.
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',
@@ -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 {itemDocDeref} from '../lib/projectItems'
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[]{ "doc": ${itemDocDeref('{_id, title}')} },
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?: {doc: {_id: string; title?: string} | null}[]
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(documentType: string, documentId: string, title?: string): string {
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
- return `${safeType}/${safeTitle}-${idPrefix}.locjson`
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 `table` block (no to-html component). The default behavior emits a
180
- // hidden `<div style="display:none">Unknown block type "table"…</div>`, whose
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: 'table', _key: 't1', rows: [{_key: 'r1', _type: 'tableRow', cells: ['Region', 'Price']}]},
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>&lt;10 &amp; up</td>')
210
+ })
192
211
  })
193
212
 
194
213
  describe('findUnserializableBlockTypes', () => {
195
- const table = {_type: 'table', _key: 't1', rows: [{_key: 'r1', _type: 'tableRow', cells: ['A', 'B']}]}
214
+ const table = makeTable([['A', 'B']])
196
215
 
197
216
  it('flags custom top-level block types', () => {
198
- expect(findUnserializableBlockTypes([makeBlock('text'), table])).toEqual(['table'])
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: 'table', _key: 't1', rows: []}],
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: ['table']}])
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: {unknownType: () => ''},
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`, 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).
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, {parseHtml}) as unknown as PortableTextBlock[]
153
+ return htmlToBlocks(html, blockContentType as never, {
154
+ parseHtml,
155
+ rules: [tableRule],
156
+ }) as unknown as PortableTextBlock[]
98
157
  }
@@ -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
+ `)`
@@ -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).