@smartcat/sanity-plugin 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +294 -0
- package/dist/_chunks-cjs/constants.cjs +13 -0
- package/dist/_chunks-cjs/constants.cjs.map +1 -0
- package/dist/_chunks-cjs/index.cjs +168 -0
- package/dist/_chunks-cjs/index.cjs.map +1 -0
- package/dist/_chunks-cjs/index2.cjs +311 -0
- package/dist/_chunks-cjs/index2.cjs.map +1 -0
- package/dist/_chunks-cjs/workflow.cjs +16 -0
- package/dist/_chunks-cjs/workflow.cjs.map +1 -0
- package/dist/_chunks-es/constants.js +14 -0
- package/dist/_chunks-es/constants.js.map +1 -0
- package/dist/_chunks-es/index.js +169 -0
- package/dist/_chunks-es/index.js.map +1 -0
- package/dist/_chunks-es/index2.js +313 -0
- package/dist/_chunks-es/index2.js.map +1 -0
- package/dist/_chunks-es/workflow.js +17 -0
- package/dist/_chunks-es/workflow.js.map +1 -0
- package/dist/export.cjs +160 -0
- package/dist/export.cjs.map +1 -0
- package/dist/export.d.cts +162 -0
- package/dist/export.d.ts +162 -0
- package/dist/export.js +162 -0
- package/dist/export.js.map +1 -0
- package/dist/import.cjs +169 -0
- package/dist/import.cjs.map +1 -0
- package/dist/import.d.cts +155 -0
- package/dist/import.d.ts +155 -0
- package/dist/import.js +169 -0
- package/dist/import.js.map +1 -0
- package/dist/index.cjs +2103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +122 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2110 -0
- package/dist/index.js.map +1 -0
- package/dist/locjson.cjs +12 -0
- package/dist/locjson.cjs.map +1 -0
- package/dist/locjson.d.cts +287 -0
- package/dist/locjson.d.ts +287 -0
- package/dist/locjson.js +12 -0
- package/dist/locjson.js.map +1 -0
- package/dist/progress.cjs +16 -0
- package/dist/progress.cjs.map +1 -0
- package/dist/progress.d.cts +262 -0
- package/dist/progress.d.ts +262 -0
- package/dist/progress.js +16 -0
- package/dist/progress.js.map +1 -0
- package/dist/smartcat.cjs +287 -0
- package/dist/smartcat.cjs.map +1 -0
- package/dist/smartcat.d.cts +234 -0
- package/dist/smartcat.d.ts +234 -0
- package/dist/smartcat.js +287 -0
- package/dist/smartcat.js.map +1 -0
- package/dist/templates.cjs +12 -0
- package/dist/templates.cjs.map +1 -0
- package/dist/templates.d.cts +52 -0
- package/dist/templates.d.ts +52 -0
- package/dist/templates.js +12 -0
- package/dist/templates.js.map +1 -0
- package/package.json +101 -15
- package/src/actions/AddToProjectAction.tsx +258 -0
- package/src/export/export.test.ts +537 -0
- package/src/export/index.ts +393 -0
- package/src/form/TranslationStatusInput.tsx +212 -0
- package/src/import/import.test.ts +346 -0
- package/src/import/index.ts +418 -0
- package/src/index.ts +63 -0
- package/src/lib/constants.ts +23 -0
- package/src/lib/documentTitle.test.ts +43 -0
- package/src/lib/documentTitle.ts +30 -0
- package/src/lib/languageMap.test.ts +56 -0
- package/src/lib/languageMap.ts +71 -0
- package/src/lib/linkedDocuments.test.ts +56 -0
- package/src/lib/linkedDocuments.ts +100 -0
- package/src/lib/locjson/deserialize.ts +60 -0
- package/src/lib/locjson/fields.ts +355 -0
- package/src/lib/locjson/filename.ts +27 -0
- package/src/lib/locjson/index.ts +10 -0
- package/src/lib/locjson/locjson.test.ts +545 -0
- package/src/lib/locjson/paths.test.ts +41 -0
- package/src/lib/locjson/paths.ts +73 -0
- package/src/lib/locjson/portableText.ts +98 -0
- package/src/lib/locjson/serialize.ts +124 -0
- package/src/lib/locjson/types.ts +106 -0
- package/src/lib/log.ts +33 -0
- package/src/lib/projectItems.ts +17 -0
- package/src/lib/resolveConfig.test.ts +62 -0
- package/src/lib/resolveConfig.ts +47 -0
- package/src/lib/workflow.test.ts +37 -0
- package/src/lib/workflow.ts +48 -0
- package/src/lib/zip.fixtures.ts +75 -0
- package/src/lib/zip.test.ts +110 -0
- package/src/lib/zip.ts +164 -0
- package/src/progress/core.ts +387 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/progress.test.ts +268 -0
- package/src/schema/settings.ts +55 -0
- package/src/schema/translatableOptions.ts +25 -0
- package/src/schema/translationProject.ts +264 -0
- package/src/smartcat/client.test.ts +222 -0
- package/src/smartcat/client.ts +351 -0
- package/src/smartcat/index.ts +10 -0
- package/src/smartcat/types.ts +99 -0
- package/src/templates/core.ts +55 -0
- package/src/templates/index.ts +5 -0
- package/src/templates/templates.test.ts +50 -0
- package/src/tool/Dashboard.tsx +44 -0
- package/src/tool/ItemProgress.tsx +176 -0
- package/src/tool/LogPanel.tsx +207 -0
- package/src/tool/ProjectView.tsx +975 -0
- package/src/tool/ProjectsView.tsx +208 -0
- package/src/tool/StatusBadge.tsx +33 -0
- package/src/tool/StudioInit.tsx +42 -0
- package/src/tool/WorkflowSelect.tsx +41 -0
- package/src/tool/data.ts +79 -0
- package/src/tool/index.ts +19 -0
- package/src/tool/processing.test.ts +623 -0
- package/src/tool/processing.ts +766 -0
- package/src/tool/useTemplates.ts +84 -0
- package/src/tool/useWorkflowSelection.ts +65 -0
- package/src/types.ts +77 -0
|
@@ -0,0 +1,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'
|