@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.
Files changed (121) hide show
  1. package/README.md +294 -0
  2. package/dist/_chunks-cjs/constants.cjs +13 -0
  3. package/dist/_chunks-cjs/constants.cjs.map +1 -0
  4. package/dist/_chunks-cjs/index.cjs +168 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +311 -0
  7. package/dist/_chunks-cjs/index2.cjs.map +1 -0
  8. package/dist/_chunks-cjs/workflow.cjs +16 -0
  9. package/dist/_chunks-cjs/workflow.cjs.map +1 -0
  10. package/dist/_chunks-es/constants.js +14 -0
  11. package/dist/_chunks-es/constants.js.map +1 -0
  12. package/dist/_chunks-es/index.js +169 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +313 -0
  15. package/dist/_chunks-es/index2.js.map +1 -0
  16. package/dist/_chunks-es/workflow.js +17 -0
  17. package/dist/_chunks-es/workflow.js.map +1 -0
  18. package/dist/export.cjs +160 -0
  19. package/dist/export.cjs.map +1 -0
  20. package/dist/export.d.cts +162 -0
  21. package/dist/export.d.ts +162 -0
  22. package/dist/export.js +162 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/import.cjs +169 -0
  25. package/dist/import.cjs.map +1 -0
  26. package/dist/import.d.cts +155 -0
  27. package/dist/import.d.ts +155 -0
  28. package/dist/import.js +169 -0
  29. package/dist/import.js.map +1 -0
  30. package/dist/index.cjs +2103 -0
  31. package/dist/index.cjs.map +1 -0
  32. package/dist/index.d.cts +122 -0
  33. package/dist/index.d.ts +122 -0
  34. package/dist/index.js +2110 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/locjson.cjs +12 -0
  37. package/dist/locjson.cjs.map +1 -0
  38. package/dist/locjson.d.cts +287 -0
  39. package/dist/locjson.d.ts +287 -0
  40. package/dist/locjson.js +12 -0
  41. package/dist/locjson.js.map +1 -0
  42. package/dist/progress.cjs +16 -0
  43. package/dist/progress.cjs.map +1 -0
  44. package/dist/progress.d.cts +262 -0
  45. package/dist/progress.d.ts +262 -0
  46. package/dist/progress.js +16 -0
  47. package/dist/progress.js.map +1 -0
  48. package/dist/smartcat.cjs +287 -0
  49. package/dist/smartcat.cjs.map +1 -0
  50. package/dist/smartcat.d.cts +234 -0
  51. package/dist/smartcat.d.ts +234 -0
  52. package/dist/smartcat.js +287 -0
  53. package/dist/smartcat.js.map +1 -0
  54. package/dist/templates.cjs +12 -0
  55. package/dist/templates.cjs.map +1 -0
  56. package/dist/templates.d.cts +52 -0
  57. package/dist/templates.d.ts +52 -0
  58. package/dist/templates.js +12 -0
  59. package/dist/templates.js.map +1 -0
  60. package/package.json +101 -15
  61. package/src/actions/AddToProjectAction.tsx +258 -0
  62. package/src/export/export.test.ts +537 -0
  63. package/src/export/index.ts +393 -0
  64. package/src/form/TranslationStatusInput.tsx +212 -0
  65. package/src/import/import.test.ts +346 -0
  66. package/src/import/index.ts +418 -0
  67. package/src/index.ts +63 -0
  68. package/src/lib/constants.ts +23 -0
  69. package/src/lib/documentTitle.test.ts +43 -0
  70. package/src/lib/documentTitle.ts +30 -0
  71. package/src/lib/languageMap.test.ts +56 -0
  72. package/src/lib/languageMap.ts +71 -0
  73. package/src/lib/linkedDocuments.test.ts +56 -0
  74. package/src/lib/linkedDocuments.ts +100 -0
  75. package/src/lib/locjson/deserialize.ts +60 -0
  76. package/src/lib/locjson/fields.ts +355 -0
  77. package/src/lib/locjson/filename.ts +27 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +545 -0
  80. package/src/lib/locjson/paths.test.ts +41 -0
  81. package/src/lib/locjson/paths.ts +73 -0
  82. package/src/lib/locjson/portableText.ts +98 -0
  83. package/src/lib/locjson/serialize.ts +124 -0
  84. package/src/lib/locjson/types.ts +106 -0
  85. package/src/lib/log.ts +33 -0
  86. package/src/lib/projectItems.ts +17 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +47 -0
  89. package/src/lib/workflow.test.ts +37 -0
  90. package/src/lib/workflow.ts +48 -0
  91. package/src/lib/zip.fixtures.ts +75 -0
  92. package/src/lib/zip.test.ts +110 -0
  93. package/src/lib/zip.ts +164 -0
  94. package/src/progress/core.ts +387 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +268 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +264 -0
  100. package/src/smartcat/client.test.ts +222 -0
  101. package/src/smartcat/client.ts +351 -0
  102. package/src/smartcat/index.ts +10 -0
  103. package/src/smartcat/types.ts +99 -0
  104. package/src/templates/core.ts +55 -0
  105. package/src/templates/index.ts +5 -0
  106. package/src/templates/templates.test.ts +50 -0
  107. package/src/tool/Dashboard.tsx +44 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +975 -0
  111. package/src/tool/ProjectsView.tsx +208 -0
  112. package/src/tool/StatusBadge.tsx +33 -0
  113. package/src/tool/StudioInit.tsx +42 -0
  114. package/src/tool/WorkflowSelect.tsx +41 -0
  115. package/src/tool/data.ts +79 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +623 -0
  118. package/src/tool/processing.ts +766 -0
  119. package/src/tool/useTemplates.ts +84 -0
  120. package/src/tool/useWorkflowSelection.ts +65 -0
  121. package/src/types.ts +77 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Sanity ↔ Smartcat language-code mapping.
3
+ *
4
+ * Sanity locale tags (`id`) don't always match the codes Smartcat understands
5
+ * (e.g. Sanity "PL-PL" vs Smartcat "pl"). The browser resolves the mapping from
6
+ * plugin config and stamps the relevant pairs onto the project; the thin
7
+ * Functions read them back. This module is dependency-free so it bundles into
8
+ * both the Studio and the Functions.
9
+ *
10
+ * The invariant: everything stored in Sanity uses the Sanity `id`; only the
11
+ * Smartcat HTTP calls use `smartcatLanguage`. So callers map forward (id → code)
12
+ * when talking to Smartcat and invert (code → id) when storing what comes back.
13
+ */
14
+
15
+ export interface SmartcatLanguageMapping {
16
+ sanityId: string
17
+ smartcatLanguage: string
18
+ }
19
+
20
+ /** Minimal language shape needed to build mappings (a subset of SmartcatLanguage). */
21
+ interface MappableLanguage {
22
+ id: string
23
+ smartcatLanguage?: string
24
+ }
25
+
26
+ /**
27
+ * Builds the mapping pairs for the given Sanity ids, resolving each to its
28
+ * configured `smartcatLanguage` (falling back to the id when not configured).
29
+ */
30
+ export function buildLanguageMappings(
31
+ languages: MappableLanguage[],
32
+ ids: string[],
33
+ ): SmartcatLanguageMapping[] {
34
+ const byId = new Map(languages.map((l) => [l.id, l.smartcatLanguage || l.id]))
35
+ return ids.map((id) => ({sanityId: id, smartcatLanguage: byId.get(id) || id}))
36
+ }
37
+
38
+ /** Sanity id → Smartcat code. Identity when no mapping is found. */
39
+ export function toSmartcatLanguage(
40
+ mappings: SmartcatLanguageMapping[] | undefined,
41
+ sanityId: string,
42
+ ): string {
43
+ return mappings?.find((m) => m.sanityId === sanityId)?.smartcatLanguage ?? sanityId
44
+ }
45
+
46
+ /** Smartcat code → Sanity id. Identity when no mapping is found. */
47
+ export function toSanityLanguage(
48
+ mappings: SmartcatLanguageMapping[] | undefined,
49
+ smartcatLanguage: string,
50
+ ): string {
51
+ return mappings?.find((m) => m.smartcatLanguage === smartcatLanguage)?.sanityId ?? smartcatLanguage
52
+ }
53
+
54
+ /**
55
+ * Finds Smartcat codes that more than one Sanity id maps to. A non-empty result
56
+ * means the inverse (code → id) is ambiguous, so translations can't be routed
57
+ * back — callers should reject such a configuration.
58
+ */
59
+ export function findDuplicateSmartcatLanguages(
60
+ mappings: SmartcatLanguageMapping[],
61
+ ): {smartcatLanguage: string; sanityIds: string[]}[] {
62
+ const byCode = new Map<string, string[]>()
63
+ for (const m of mappings) {
64
+ const ids = byCode.get(m.smartcatLanguage) ?? []
65
+ ids.push(m.sanityId)
66
+ byCode.set(m.smartcatLanguage, ids)
67
+ }
68
+ return [...byCode.entries()]
69
+ .filter(([, ids]) => ids.length > 1)
70
+ .map(([smartcatLanguage, sanityIds]) => ({smartcatLanguage, sanityIds}))
71
+ }
@@ -0,0 +1,56 @@
1
+ import {describe, expect, it} from 'vitest'
2
+ import {collectRefs, gatherLinkedDocuments, type GatherClient} from './linkedDocuments'
3
+
4
+ const ref = (id: string) => ({_type: 'reference', _ref: id})
5
+
6
+ describe('collectRefs', () => {
7
+ it('finds refs nested in objects, arrays, and portable text; strips drafts prefix', () => {
8
+ const doc = {
9
+ _id: 'x',
10
+ hero: ref('a'),
11
+ content: [{blocks: [ref('b'), {asset: ref('drafts.c')}]}],
12
+ plain: 'no ref',
13
+ }
14
+ expect([...collectRefs(doc)].sort()).toEqual(['a', 'b', 'c'])
15
+ })
16
+ })
17
+
18
+ describe('gatherLinkedDocuments', () => {
19
+ // page1 → card1 (fragment), page2 (peer root), img1 (asset)
20
+ // card1 → page1 (cycle), button1 (fragment)
21
+ // page2 → card2 (behind a root — must NOT be reached)
22
+ const dataset: Record<string, Record<string, unknown>> = {
23
+ page1: {_id: 'page1', _type: 'page', a: ref('card1'), b: ref('page2'), c: ref('img1')},
24
+ card1: {_id: 'card1', _type: 'card', a: ref('page1'), b: ref('button1')},
25
+ button1: {_id: 'button1', _type: 'button'},
26
+ page2: {_id: 'page2', _type: 'page', a: ref('card2')},
27
+ card2: {_id: 'card2', _type: 'card'},
28
+ img1: {_id: 'img1', _type: 'sanity.imageAsset'},
29
+ }
30
+
31
+ const client: GatherClient = {
32
+ async fetch<T>(query: string, params: Record<string, unknown>): Promise<T> {
33
+ const ids = params.ids as string[]
34
+ const docs = ids.map((id) => dataset[id]).filter(Boolean)
35
+ const typeOnly = query.includes('_type}')
36
+ return (typeOnly ? docs.map((d) => ({_id: d._id, _type: d._type})) : docs) as T
37
+ },
38
+ }
39
+
40
+ const isRoot = (t: string) => t === 'page'
41
+ const isTranslatable = (t: string) => ['page', 'card', 'button'].includes(t)
42
+
43
+ it('gathers translatable fragments, stops at peer roots and assets, is cycle-safe', async () => {
44
+ const linked = await gatherLinkedDocuments({client, rootId: 'page1', isRoot, isTranslatable})
45
+ expect(linked.map((l) => l.docId).sort()).toEqual(['button1', 'card1'])
46
+ // page2 (peer root) skipped → card2 unreachable; img1 (asset) excluded.
47
+ expect(linked.find((l) => l.docId === 'card2')).toBeUndefined()
48
+ expect(linked.find((l) => l.docId === 'page2')).toBeUndefined()
49
+ })
50
+
51
+ it('carries the resolved docType for each linked document', async () => {
52
+ const linked = await gatherLinkedDocuments({client, rootId: 'page1', isRoot, isTranslatable})
53
+ expect(linked.find((l) => l.docId === 'card1')?.docType).toBe('card')
54
+ expect(linked.find((l) => l.docId === 'button1')?.docType).toBe('button')
55
+ })
56
+ })
@@ -0,0 +1,100 @@
1
+ /** Minimal client surface the gather needs. */
2
+ export interface GatherClient {
3
+ fetch<T>(query: string, params: Record<string, unknown>): Promise<T>
4
+ }
5
+
6
+ export interface LinkedDocument {
7
+ docId: string
8
+ docType: string
9
+ }
10
+
11
+ export interface GatherOptions {
12
+ client: GatherClient
13
+ /** Published id of the document to start from. */
14
+ rootId: string
15
+ /** Root/standalone types — traversal stops here (peer content, not pulled in). */
16
+ isRoot: (type: string) => boolean
17
+ /** Types with localizable content — added to the project and recursed into. */
18
+ isTranslatable: (type: string) => boolean
19
+ /** Documents fetched per round. */
20
+ batchSize?: number
21
+ }
22
+
23
+ const bareId = (id: string) => id.replace(/^drafts\./, '')
24
+
25
+ /** Every `_ref` string anywhere in a document's JSON, as bare (published) ids. */
26
+ export function collectRefs(node: unknown, out: Set<string> = new Set()): Set<string> {
27
+ if (!node || typeof node !== 'object') return out
28
+ if (Array.isArray(node)) {
29
+ for (const item of node) collectRefs(item, out)
30
+ return out
31
+ }
32
+ const obj = node as Record<string, unknown>
33
+ if (typeof obj._ref === 'string') out.add(bareId(obj._ref))
34
+ for (const [key, value] of Object.entries(obj)) {
35
+ if (key === '_ref') continue
36
+ collectRefs(value, out)
37
+ }
38
+ return out
39
+ }
40
+
41
+ // Prefer the draft copy (freshest refs) when both draft and published exist.
42
+ function pickPreferDraft<T extends {_id: string}>(docs: T[] | null): Map<string, T> {
43
+ const chosen = new Map<string, T>()
44
+ for (const doc of docs ?? []) {
45
+ const bare = bareId(doc._id)
46
+ if (!chosen.has(bare) || doc._id.startsWith('drafts.')) chosen.set(bare, doc)
47
+ }
48
+ return chosen
49
+ }
50
+
51
+ /**
52
+ * Breadth-first gather of documents linked (transitively) from `rootId`.
53
+ *
54
+ * Follows references only through translatable, non-root documents; stops at
55
+ * root types (peer pages, translated on their own) and at non-localizable leaves
56
+ * (assets, config). Cycle-safe. Returns the linked documents to add — excluding
57
+ * the root itself.
58
+ */
59
+ export async function gatherLinkedDocuments({
60
+ client,
61
+ rootId,
62
+ isRoot,
63
+ isTranslatable,
64
+ batchSize = 50,
65
+ }: GatherOptions): Promise<LinkedDocument[]> {
66
+ const start = bareId(rootId)
67
+ const seen = new Set<string>([start])
68
+ const result: LinkedDocument[] = []
69
+ let frontier: string[] = [start]
70
+
71
+ while (frontier.length) {
72
+ const batch = frontier.splice(0, batchSize)
73
+ // Full bodies of the docs we're expanding — needed to read their outgoing refs.
74
+ const bodies = await client.fetch<Array<Record<string, unknown> & {_id: string}>>(
75
+ `*[_id in $ids]`,
76
+ {ids: batch.flatMap((id) => [id, `drafts.${id}`])},
77
+ )
78
+ const candidates = new Set<string>()
79
+ for (const doc of pickPreferDraft(bodies).values()) {
80
+ for (const ref of collectRefs(doc)) candidates.add(ref)
81
+ }
82
+ const fresh = [...candidates].filter((id) => !seen.has(id))
83
+ if (!fresh.length) continue
84
+ fresh.forEach((id) => seen.add(id))
85
+
86
+ // Classify fresh candidates by type (light query — avoids pulling full
87
+ // bodies of peer pages and assets we won't recurse into).
88
+ const typed = await client.fetch<Array<{_id: string; _type: string}>>(
89
+ `*[_id in $ids]{_id, _type}`,
90
+ {ids: fresh.flatMap((id) => [id, `drafts.${id}`])},
91
+ )
92
+ for (const [id, doc] of pickPreferDraft(typed)) {
93
+ const type = doc._type
94
+ if (!type || isRoot(type) || !isTranslatable(type)) continue
95
+ result.push({docId: id, docType: type})
96
+ frontier.push(id)
97
+ }
98
+ }
99
+ return result
100
+ }
@@ -0,0 +1,60 @@
1
+ import {htmlToPortableText, type ParseHtml} from './portableText'
2
+ import type {LocJSON} from './types'
3
+
4
+ export interface DeserializeOptions {
5
+ /** DOM parser for HTML units (DOMParser in Studio, jsdom in Functions). */
6
+ parseHtml: ParseHtml
7
+ /** Returns the compiled block content type for a Portable Text field path. */
8
+ getBlockContentType: (fieldPath: string) => unknown
9
+ }
10
+
11
+ export interface DeserializedDocument {
12
+ documentId?: string
13
+ documentType?: string
14
+ sourceLanguage?: string
15
+ /** Translated field values, keyed by field path. */
16
+ fields: Record<string, unknown>
17
+ /**
18
+ * Source (pre-translation) text per field path, as the raw joined string. Used
19
+ * to decide whether a field is JSON (so its translation can be validated before
20
+ * import). Always the string form, even for HTML units.
21
+ */
22
+ sources: Record<string, string>
23
+ }
24
+
25
+ /**
26
+ * Parses a (translated) LocJSON file back into Sanity field values.
27
+ *
28
+ * Uses each unit's `target` (falling back to `source` when a unit hasn't been
29
+ * translated yet, so the result is always coherent). HTML units are converted
30
+ * back to Portable Text via the field's compiled block content type.
31
+ */
32
+ export function deserializeFromLocjson(locjson: LocJSON, options: DeserializeOptions): DeserializedDocument {
33
+ const fileMeta = locjson.properties?.['x-sanity']
34
+ const fields: Record<string, unknown> = {}
35
+ const sources: Record<string, string> = {}
36
+
37
+ for (const unit of locjson.units ?? []) {
38
+ const fieldPath = unit.properties?.['x-sanity']?.fieldPath ?? unit.key
39
+ const text = (unit.target?.length ? unit.target : unit.source) ?? []
40
+
41
+ // LocJSON splits a string into arbitrary chunks with no implied delimiter,
42
+ // so the original is always reconstructed by concatenating the segments.
43
+ const value = text.join('')
44
+ sources[fieldPath] = (unit.source ?? []).join('')
45
+
46
+ if (unit.properties?.['x-smartcat-format'] === 'html') {
47
+ fields[fieldPath] = htmlToPortableText(value, options.getBlockContentType(fieldPath), options.parseHtml)
48
+ } else {
49
+ fields[fieldPath] = value
50
+ }
51
+ }
52
+
53
+ return {
54
+ documentId: fileMeta?.documentId,
55
+ documentType: fileMeta?.documentType,
56
+ sourceLanguage: fileMeta?.sourceLanguage,
57
+ fields,
58
+ sources,
59
+ }
60
+ }
@@ -0,0 +1,355 @@
1
+ import type {LocalizationMode, TranslatableField, TranslatableLeaf, ValueShape} from './types'
2
+
3
+ export interface GetTranslatableFieldsOptions {
4
+ /**
5
+ * Field names to exclude (e.g. the i18n language field, passed by the caller
6
+ * as the configured `documentI18nPluginLanguageField`). Defaults to none.
7
+ */
8
+ exclude?: string[]
9
+ }
10
+
11
+ interface CompiledType {
12
+ jsonType?: string
13
+ name?: string
14
+ title?: string
15
+ of?: CompiledType[]
16
+ fields?: CompiledField[]
17
+ options?: {
18
+ documentInternationalization?: {exclude?: boolean}
19
+ smartcatTranslation?: {include?: boolean}
20
+ }
21
+ }
22
+
23
+ /**
24
+ * True when a field is marked non-translatable via document-internationalization's
25
+ * own field option (`options.documentInternationalization.exclude`). Honoring it
26
+ * means consumers exclude fields per type using the i18n plugin's native marker —
27
+ * no separate config here.
28
+ */
29
+ function isExcludedByDocumentI18n(field: CompiledField): boolean {
30
+ return field.type?.options?.documentInternationalization?.exclude === true
31
+ }
32
+
33
+ interface CompiledField {
34
+ name: string
35
+ type?: CompiledType
36
+ }
37
+
38
+ /**
39
+ * True for a Sanity **system** type — the reserved `sanity.` namespace, notably
40
+ * the `sanity.imageAsset` / `sanity.fileAsset` documents that image/file fields
41
+ * reference. Their string fields (`originalFilename`, `path`, `url`, …) look
42
+ * translatable but must never be sent: assets are shared across locales, and
43
+ * translating a filename or CDN path corrupts the reference. Excluding them here
44
+ * keeps them out of both the linked-document crawl and export serialization,
45
+ * since both decide translatability via {@link getTranslatableFields}.
46
+ */
47
+ export function isSystemType(typeName: unknown): boolean {
48
+ return typeof typeName === 'string' && typeName.startsWith('sanity.')
49
+ }
50
+
51
+ /**
52
+ * True for a `sanity-plugin-internationalized-array` array type (e.g.
53
+ * `internationalizedArrayString`). Detected structurally by naming convention so
54
+ * we never need a dependency on that plugin.
55
+ */
56
+ export function isInternationalizedArrayType(type?: CompiledType): boolean {
57
+ return (
58
+ type?.jsonType === 'array' &&
59
+ typeof type.name === 'string' &&
60
+ type.name.startsWith('internationalizedArray')
61
+ )
62
+ }
63
+
64
+ /** True for an array member `_type` like `internationalizedArrayStringValue`. */
65
+ export function isInternationalizedArrayMemberType(typeName: unknown): boolean {
66
+ return typeof typeName === 'string' && /^internationalizedArray.+Value$/.test(typeName)
67
+ }
68
+
69
+ /** The inner value type of an internationalized-array field (its member's `value` field). */
70
+ export function innerValueType(arrayType?: CompiledType): CompiledType | undefined {
71
+ return arrayType?.of?.[0]?.fields?.find((f) => f.name === 'value')?.type
72
+ }
73
+
74
+ /**
75
+ * Localization mode of a **compiled** schema type: `field` if it declares any
76
+ * internationalized-array field (in which case only those fields are
77
+ * translatable), otherwise `document`. A pure function of the type, so callers
78
+ * should compute it once per type.
79
+ */
80
+ export function localizationMode(compiledType: unknown): LocalizationMode {
81
+ const fields = (compiledType as CompiledType)?.fields
82
+ if (!Array.isArray(fields)) return 'document'
83
+ return fields.some((f) => isInternationalizedArrayType(f.type)) ? 'field' : 'document'
84
+ }
85
+
86
+ /**
87
+ * Selects translatable fields from a **compiled** Sanity schema type.
88
+ *
89
+ * Document-mode types (default policy, installation-agnostic):
90
+ * - `string` and `text` fields -> plain units
91
+ * - Portable Text (arrays containing `block` members) -> HTML units
92
+ * - everything else (slug, url, datetime, number, boolean, reference, image,
93
+ * non-block arrays, objects, system `_` fields, excluded names) is excluded.
94
+ *
95
+ * Field-mode types (any internationalized-array field present): only the
96
+ * internationalized-array fields are translatable; each is classified by its
97
+ * inner `value` type (string/text -> plain, Portable Text -> HTML).
98
+ *
99
+ * The compiled schema shape isn't strongly typed publicly, so this reads it
100
+ * defensively. Callers that need overrides can post-filter the result.
101
+ */
102
+ export function getTranslatableFields(
103
+ compiledType: unknown,
104
+ options: GetTranslatableFieldsOptions = {},
105
+ ): TranslatableField[] {
106
+ const rootType = compiledType as CompiledType
107
+ if (isSystemType(rootType?.name)) return []
108
+ if (!Array.isArray(rootType?.fields)) return []
109
+ const result: TranslatableField[] = []
110
+ walkFields(rootType, '', localizationMode(rootType), new Set(options.exclude ?? []), result)
111
+ return result
112
+ }
113
+
114
+ /**
115
+ * Recursively selects translatable fields. In field-mode it descends through
116
+ * plain object fields (so nested internationalized-array fields, e.g.
117
+ * `benefits.items`, are reached) and classifies each i18n field's member value
118
+ * shape. Document-mode keeps the flat top-level behavior.
119
+ */
120
+ function walkFields(
121
+ compiledType: CompiledType | undefined,
122
+ prefix: string,
123
+ mode: LocalizationMode,
124
+ exclude: Set<string>,
125
+ out: TranslatableField[],
126
+ ): void {
127
+ for (const field of compiledType?.fields ?? []) {
128
+ // `exclude` (the i18n language field) only ever appears at the top level.
129
+ if (!field?.name || field.name.startsWith('_') || (prefix === '' && exclude.has(field.name))) continue
130
+ const forceInclude = field.type?.options?.smartcatTranslation?.include === true
131
+ if (isExcludedByDocumentI18n(field) && !forceInclude) continue
132
+ const path = prefix ? `${prefix}.${field.name}` : field.name
133
+ const title = field.type?.title || titleCase(field.name)
134
+
135
+ if (mode === 'field') {
136
+ if (isInternationalizedArrayType(field.type)) {
137
+ const inner = innerValueType(field.type)
138
+ const shape = classifyValueShape(inner) ?? (forceInclude ? classifyValueShapeByJsonType(inner) : null)
139
+ if (shape) out.push({path, localization: 'field', kind: shape.container === 'scalar' ? shape.kind : 'plain', title, value: shape})
140
+ else if (forceInclude) warnUntranslatableInclude(field.name, inner)
141
+ } else if (isPlainObject(field.type)) {
142
+ walkFields(field.type, path, mode, exclude, out)
143
+ }
144
+ } else {
145
+ const kind = classifyField(field.type) ?? (forceInclude ? classifyByJsonType(field.type) : null)
146
+ if (kind) out.push({path, localization: 'document', kind, title, value: {container: 'scalar', kind}})
147
+ else if (forceInclude) warnUntranslatableInclude(field.name, field.type)
148
+ }
149
+ }
150
+ }
151
+
152
+ /** One translatable leaf of a field, expanded against a concrete source value. */
153
+ export interface FieldLeaf {
154
+ /** Unique unit key / x-sanity fieldPath. */
155
+ fullKey: string
156
+ /** Path within the member value to the leaf (empty = the value itself). */
157
+ valuePath: string
158
+ kind: TranslatableField['kind']
159
+ title?: string
160
+ }
161
+
162
+ /**
163
+ * Expand a field into its translatable leaves against a concrete source value
164
+ * (the source-language member value for field-mode). Serialize and import both
165
+ * call this with the SAME source value, so the unit keys they compute match.
166
+ */
167
+ export function enumerateLeaves(field: TranslatableField, sourceValue: unknown): FieldLeaf[] {
168
+ const base = field.path
169
+ const shape: ValueShape = field.value ?? {container: 'scalar', kind: field.kind}
170
+ if (shape.container === 'scalar') {
171
+ return [{fullKey: base, valuePath: '', kind: shape.kind, title: field.title}]
172
+ }
173
+ if (shape.container === 'arrayOfScalar') {
174
+ const arr = Array.isArray(sourceValue) ? sourceValue : []
175
+ return arr.map((_, i) => ({fullKey: `${base}[${i}]`, valuePath: `[${i}]`, kind: shape.kind, title: field.title}))
176
+ }
177
+ if (shape.container === 'object') {
178
+ return shape.leaves.map((l) => ({fullKey: `${base}.${l.subPath}`, valuePath: l.subPath, kind: l.kind, title: l.title ?? field.title}))
179
+ }
180
+ const arr = Array.isArray(sourceValue) ? sourceValue : []
181
+ const out: FieldLeaf[] = []
182
+ for (const item of arr) {
183
+ const key = (item as Record<string, unknown>)?._key
184
+ if (typeof key !== 'string') continue
185
+ for (const l of shape.leaves) {
186
+ const valuePath = l.subPath ? `[${key}].${l.subPath}` : `[${key}]`
187
+ out.push({fullKey: `${base}${valuePath}`, valuePath, kind: l.kind, title: l.title ?? field.title})
188
+ }
189
+ }
190
+ return out
191
+ }
192
+
193
+ /** A recursable plain object (not a reference/image/file/slug/geopoint). */
194
+ function isPlainObject(type?: CompiledType): boolean {
195
+ if (type?.jsonType !== 'object' || !Array.isArray(type.fields)) return false
196
+ if (type.fields.some((f) => f.name === '_ref')) return false
197
+ return !['reference', 'image', 'file', 'slug', 'geopoint'].includes(type.name ?? '')
198
+ }
199
+
200
+ /** Classify an internationalized-array member's `value` type into a ValueShape. */
201
+ function classifyValueShape(inner?: CompiledType): ValueShape | null {
202
+ if (!inner) return null
203
+ const scalar = classifyField(inner)
204
+ if (scalar) return {container: 'scalar', kind: scalar}
205
+ if (inner.jsonType === 'array' && Array.isArray(inner.of)) {
206
+ const member = inner.of[0]
207
+ if (member?.jsonType === 'string' && (member.name === 'string' || member.name === 'text')) {
208
+ return {container: 'arrayOfScalar', kind: 'plain'}
209
+ }
210
+ if (isPlainObject(member)) {
211
+ const leaves = collectLeaves(member, '')
212
+ if (leaves.length) return {container: 'arrayOfObject', leaves}
213
+ }
214
+ return null
215
+ }
216
+ if (isPlainObject(inner)) {
217
+ const leaves = collectLeaves(inner, '')
218
+ if (leaves.length) return {container: 'object', leaves}
219
+ }
220
+ return null
221
+ }
222
+
223
+ /** ValueShape by jsonType only (for `smartcatTranslation.include` overrides). */
224
+ function classifyValueShapeByJsonType(inner?: CompiledType): ValueShape | null {
225
+ const kind = classifyByJsonType(inner)
226
+ return kind ? {container: 'scalar', kind} : null
227
+ }
228
+
229
+ /** Translatable string/text/Portable-Text leaves within an object (recursing plain objects). */
230
+ function collectLeaves(objType: CompiledType | undefined, prefix: string): TranslatableLeaf[] {
231
+ const leaves: TranslatableLeaf[] = []
232
+ for (const f of objType?.fields ?? []) {
233
+ if (!f?.name || f.name.startsWith('_')) continue
234
+ if (isExcludedByDocumentI18n(f) && f.type?.options?.smartcatTranslation?.include !== true) continue
235
+ const subPath = prefix ? `${prefix}.${f.name}` : f.name
236
+ const kind = classifyField(f.type)
237
+ if (kind) leaves.push({subPath, kind, title: f.type?.title || titleCase(f.name)})
238
+ else if (isPlainObject(f.type)) leaves.push(...collectLeaves(f.type, subPath))
239
+ }
240
+ return leaves
241
+ }
242
+
243
+ /** True for an array whose members include a Portable Text `block` type. */
244
+ function isPortableTextArray(type: CompiledType): boolean {
245
+ return (
246
+ type.jsonType === 'array' &&
247
+ Array.isArray(type.of) &&
248
+ type.of.some((member) => member?.name === 'block' || member?.jsonType === 'block')
249
+ )
250
+ }
251
+
252
+ function classifyField(type?: CompiledType): TranslatableField['kind'] | null {
253
+ if (!type) return null
254
+
255
+ // Plain text: only the `string` and `text` base types (not url, slug, datetime…).
256
+ if (type.jsonType === 'string' && (type.name === 'string' || type.name === 'text')) {
257
+ return 'plain'
258
+ }
259
+
260
+ if (isPortableTextArray(type)) return 'portableText'
261
+
262
+ return null
263
+ }
264
+
265
+ /**
266
+ * Classify by base `jsonType` only, ignoring the type name. Used for fields
267
+ * explicitly opted in with `smartcatTranslation.include`, where the name isn't in
268
+ * the default allowlist (e.g. a custom string alias) but the underlying shape is
269
+ * still translatable.
270
+ */
271
+ function classifyByJsonType(type?: CompiledType): TranslatableField['kind'] | null {
272
+ if (!type) return null
273
+ if (type.jsonType === 'string') return 'plain'
274
+ if (isPortableTextArray(type)) return 'portableText'
275
+ return null
276
+ }
277
+
278
+ function warnUntranslatableInclude(fieldName: string, type?: CompiledType): void {
279
+ // eslint-disable-next-line no-console
280
+ console.warn(
281
+ `[smartcat-translation] Field "${fieldName}" is marked smartcatTranslation.include ` +
282
+ `but its type (name: ${JSON.stringify(type?.name)}, jsonType: ${JSON.stringify(type?.jsonType)}) ` +
283
+ `has no translatable representation; skipping.`,
284
+ )
285
+ }
286
+
287
+ function titleCase(name: string): string {
288
+ return name.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())
289
+ }
290
+
291
+ /**
292
+ * Selects translatable fields from a document's **data** (no schema needed) —
293
+ * for use in Sanity Functions, which run server-side without the Studio schema.
294
+ *
295
+ * Applies the same homogeneity rule as {@link getTranslatableFields}: if any
296
+ * field holds internationalized-array members, the document is field-mode and
297
+ * only those fields are selected (classified by a sample member's `value`);
298
+ * otherwise top-level `string`/Portable Text fields are selected (document-mode).
299
+ */
300
+ export function inferTranslatableFields(
301
+ doc: Record<string, unknown>,
302
+ options: GetTranslatableFieldsOptions = {},
303
+ ): TranslatableField[] {
304
+ if (isSystemType(doc._type)) return []
305
+ const exclude = new Set(options.exclude ?? [])
306
+
307
+ const isI18nArray = (value: unknown): value is Array<{value?: unknown}> =>
308
+ Array.isArray(value) &&
309
+ value.some((m) => m && typeof m === 'object' && isInternationalizedArrayMemberType((m as {_type?: unknown})._type))
310
+
311
+ const entries = Object.entries(doc).filter(([key]) => !key.startsWith('_') && !exclude.has(key))
312
+ const fieldMode = entries.some(([, value]) => isI18nArray(value))
313
+
314
+ const result: TranslatableField[] = []
315
+ for (const [key, value] of entries) {
316
+ if (fieldMode) {
317
+ if (!isI18nArray(value)) continue
318
+ const sample = value.find((m) => m && typeof m === 'object')?.value
319
+ const kind = sampleKind(sample)
320
+ if (kind) result.push({path: key, kind, localization: 'field', title: titleCase(key)})
321
+ } else if (typeof value === 'string') {
322
+ if (value.length > 0) result.push({path: key, kind: 'plain', localization: 'document', title: titleCase(key)})
323
+ } else if (Array.isArray(value) && value.some(isPortableTextBlock)) {
324
+ result.push({path: key, kind: 'portableText', localization: 'document', title: titleCase(key)})
325
+ }
326
+ }
327
+ return result
328
+ }
329
+
330
+ function sampleKind(value: unknown): TranslatableField['kind'] | null {
331
+ if (typeof value === 'string') return 'plain'
332
+ if (Array.isArray(value) && value.some(isPortableTextBlock)) return 'portableText'
333
+ return null
334
+ }
335
+
336
+ function isPortableTextBlock(value: unknown): boolean {
337
+ return Boolean(value && typeof value === 'object' && (value as {_type?: string})._type === 'block')
338
+ }
339
+
340
+ /**
341
+ * Reorders fields to match `orderedPaths` (e.g. a schema's field order). Fields
342
+ * not listed keep their original relative order and go last. Stable.
343
+ */
344
+ export function orderFieldsBy(
345
+ fields: TranslatableField[],
346
+ orderedPaths: string[] | undefined,
347
+ ): TranslatableField[] {
348
+ if (!orderedPaths || orderedPaths.length === 0) return fields
349
+ const rank = new Map(orderedPaths.map((path, i) => [path, i]))
350
+ const at = (path: string) => (rank.has(path) ? (rank.get(path) as number) : Number.MAX_SAFE_INTEGER)
351
+ return fields
352
+ .map((field, i) => ({field, i}))
353
+ .sort((a, b) => at(a.field.path) - at(b.field.path) || a.i - b.i)
354
+ .map((x) => x.field)
355
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Builds the Smartcat document filename for a Sanity document:
3
+ * "<type>/<sanitized title>-<id prefix>.locjson"
4
+ * e.g. "page/About Us-3f14f092.locjson".
5
+ *
6
+ * The type acts as a folder; the id prefix guarantees uniqueness. Identity on
7
+ * import comes from the file's `x-sanity` metadata, not this name, so renames
8
+ * are safe.
9
+ */
10
+ export function buildLocjsonFilename(documentType: string, documentId: string, title?: string): string {
11
+ const idPrefix = stripDraft(documentId).slice(0, 8) || 'unknown'
12
+ const safeTitle = sanitizeSegment(title || 'Untitled')
13
+ const safeType = sanitizeSegment(documentType) || 'document'
14
+ return `${safeType}/${safeTitle}-${idPrefix}.locjson`
15
+ }
16
+
17
+ function stripDraft(id: string): string {
18
+ return id.replace(/^drafts\./, '')
19
+ }
20
+
21
+ /** Removes characters unsafe for file paths while keeping it human-readable. */
22
+ function sanitizeSegment(value: string): string {
23
+ return value
24
+ .replace(/[/\\?%*:|"<>]/g, ' ') // path-unsafe characters
25
+ .replace(/\s+/g, ' ')
26
+ .trim()
27
+ }
@@ -0,0 +1,10 @@
1
+ export * from './types'
2
+ export {getTranslatableFields, inferTranslatableFields, orderFieldsBy} from './fields'
3
+ export {buildLocjsonFilename} from './filename'
4
+ export {serializeToLocjson, type SerializeOptions, type SerializableDocument} from './serialize'
5
+ export {
6
+ deserializeFromLocjson,
7
+ type DeserializeOptions,
8
+ type DeserializedDocument,
9
+ } from './deserialize'
10
+ export {portableTextToHtml, htmlToPortableText, type PortableTextBlock, type ParseHtml} from './portableText'