@smartcat/sanity-plugin 1.0.1 → 1.2.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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../../src/lib/languageMap.ts","../../src/lib/projectItems.ts","../../src/progress/core.ts"],"sourcesContent":["/**\n * Sanity ↔ Smartcat language-code mapping.\n *\n * Sanity locale tags (`id`) don't always match the codes Smartcat understands\n * (e.g. Sanity \"PL-PL\" vs Smartcat \"pl\"). The browser resolves the mapping from\n * plugin config and stamps the relevant pairs onto the project; the thin\n * Functions read them back. This module is dependency-free so it bundles into\n * both the Studio and the Functions.\n *\n * The invariant: everything stored in Sanity uses the Sanity `id`; only the\n * Smartcat HTTP calls use `smartcatLanguage`. So callers map forward (id → code)\n * when talking to Smartcat and invert (code → id) when storing what comes back.\n */\n\nexport interface SmartcatLanguageMapping {\n sanityId: string\n smartcatLanguage: string\n}\n\n/** Minimal language shape needed to build mappings (a subset of SmartcatLanguage). */\ninterface MappableLanguage {\n id: string\n smartcatLanguage?: string\n}\n\n/**\n * Builds the mapping pairs for the given Sanity ids, resolving each to its\n * configured `smartcatLanguage` (falling back to the id when not configured).\n */\nexport function buildLanguageMappings(\n languages: MappableLanguage[],\n ids: string[],\n): SmartcatLanguageMapping[] {\n const byId = new Map(languages.map((l) => [l.id, l.smartcatLanguage || l.id]))\n return ids.map((id) => ({sanityId: id, smartcatLanguage: byId.get(id) || id}))\n}\n\n/** Sanity id → Smartcat code. Identity when no mapping is found. */\nexport function toSmartcatLanguage(\n mappings: SmartcatLanguageMapping[] | undefined,\n sanityId: string,\n): string {\n return mappings?.find((m) => m.sanityId === sanityId)?.smartcatLanguage ?? sanityId\n}\n\n/** Smartcat code → Sanity id. Identity when no mapping is found. */\nexport function toSanityLanguage(\n mappings: SmartcatLanguageMapping[] | undefined,\n smartcatLanguage: string,\n): string {\n return mappings?.find((m) => m.smartcatLanguage === smartcatLanguage)?.sanityId ?? smartcatLanguage\n}\n\n/**\n * Finds Smartcat codes that more than one Sanity id maps to. A non-empty result\n * means the inverse (code → id) is ambiguous, so translations can't be routed\n * back — callers should reject such a configuration.\n */\nexport function findDuplicateSmartcatLanguages(\n mappings: SmartcatLanguageMapping[],\n): {smartcatLanguage: string; sanityIds: string[]}[] {\n const byCode = new Map<string, string[]>()\n for (const m of mappings) {\n const ids = byCode.get(m.smartcatLanguage) ?? []\n ids.push(m.sanityId)\n byCode.set(m.smartcatLanguage, ids)\n }\n return [...byCode.entries()]\n .filter(([, ids]) => ids.length > 1)\n .map(([smartcatLanguage, sanityIds]) => ({smartcatLanguage, sanityIds}))\n}\n","/**\n * GROQ fragments for reading a translation project's `items`.\n *\n * An item is either the current `smartcat.projectItem` shape (`{docId, docType}`)\n * or a legacy weak reference (`{_ref}`) from before the migration. These\n * fragments normalize across both so the old/new tolerance lives in one place —\n * interpolate them into item queries rather than re-writing `coalesce(...)`.\n */\n\n/** Normalized item id, evaluated in the item's own scope (inside `items[...]`). */\nexport const ITEM_ID = 'coalesce(docId, _ref)'\n\n/** Normalized item id, evaluated from a subquery nested under an item (via `^`). */\nexport const ITEM_ID_FROM_SUBQUERY = 'coalesce(^.docId, ^._ref)'\n\n/** Dereference an item's document, with an optional projection e.g. `{_id, _type}`. */\nexport const itemDocDeref = (projection = ''): string => `*[_id == ${ITEM_ID_FROM_SUBQUERY}][0]${projection}`\n","import type {SmartcatProject, SmartcatDocument, RequestLogger} from '../smartcat/types'\nimport {toSanityLanguage, type SmartcatLanguageMapping} from '../lib/languageMap'\nimport {itemDocDeref} from '../lib/projectItems'\nimport type {LogLine} from '../lib/log'\n\n/**\n * Per-document, per-language, per-stage translation progress mirrored from\n * Smartcat onto a `smartcat.translationProject`.\n *\n * This module is intentionally **dependency-free** (no DOM, no @sanity/* runtime)\n * so it bundles cleanly into the thin `smartcat-progress` / `smartcat-import`\n * Functions. All keys are deterministic, so repeated refreshes patch the same\n * array members in place instead of churning `_key`s.\n */\n\nexport interface StageProgress {\n /** Stable key for the Sanity array item (always set by `stagesFromDocument`). */\n _key?: string\n name: string\n /** 0–100, rounded. */\n percent: number\n}\n\nexport interface TargetProgress {\n _key: string\n language: string\n /** Smartcat target document id (`<fileGuid>_<languageNumber>`). */\n smartcatDocumentId?: string\n stages: StageProgress[]\n /** All stages at 100%. */\n complete: boolean\n /** A locale variant has been built from this target (set by the browser). */\n imported: boolean\n syncedAt?: string\n}\n\nexport interface DocProgress {\n _key: string\n sourceDocId: string\n title?: string\n targets: TargetProgress[]\n}\n\n/** Sanitizes an arbitrary string into a stable, Sanity-safe array `_key`. */\nexport function keyify(value: string): string {\n return value.replace(/[^a-zA-Z0-9_-]/g, '-') || 'x'\n}\n\n/** Builds a progress target's `_key` from its document + language. */\nexport function targetKey(sourceDocId: string, language: string): string {\n return keyify(`${sourceDocId}__${language}`)\n}\n\nfunction clampPercent(value: number | undefined): number {\n if (typeof value !== 'number' || Number.isNaN(value)) return 0\n return Math.max(0, Math.min(100, Math.round(value)))\n}\n\n/** Strips a `drafts.` prefix from a Sanity document id. */\nfunction stripDraft(id: string): string {\n return id.replace(/^drafts\\./, '')\n}\n\nfunction titleCase(value: string): string {\n return value ? value.charAt(0).toUpperCase() + value.slice(1) : value\n}\n\n/**\n * Builds a stage-id → display-name map from a project's `workflowStages`. On\n * Smartcat, document-level stages carry only `{id, progress}` — the human name\n * (`stageType`, e.g. \"translation\") lives on the project-level stages.\n */\nexport function buildStageNameMap(scProject: SmartcatProject): Map<string, string> {\n const map = new Map<string, string>()\n ;(scProject.workflowStages ?? []).forEach((stage, i) => {\n if (stage.id) map.set(stage.id, titleCase(stage.stageType || stage.stageName || `Stage ${i + 1}`))\n })\n return map\n}\n\n/** Maps a Smartcat document's workflow stages to display-ready stage progress. */\nexport function stagesFromDocument(\n doc: SmartcatDocument,\n stageNames: Map<string, string>,\n): StageProgress[] {\n const stages = doc.workflowStages ?? []\n return stages.map((stage, i) => ({\n _key: stage.id || `s${i}`,\n name:\n (stage.id && stageNames.get(stage.id)) ||\n titleCase(stage.stageType || stage.stageName || `Stage ${i + 1}`),\n percent: clampPercent(stage.progress),\n }))\n}\n\n/** A target is complete when it has stages and every one is at 100%. */\nexport function isTargetComplete(stages: StageProgress[]): boolean {\n return stages.length > 0 && stages.every((s) => s.percent >= 100)\n}\n\n/**\n * Whether a target has any content confirmed at any stage — i.e. there is\n * something worth importing. Used to gate `mode=confirmed` downloads so we skip\n * targets with nothing confirmed yet (which would otherwise come back as source).\n */\nexport function hasConfirmedContent(stages: StageProgress[]): boolean {\n return stages.some((s) => s.percent > 0)\n}\n\n/** Average completion across a target's stages (0–100). */\nexport function targetPercent(stages: StageProgress[]): number {\n if (stages.length === 0) return 0\n return Math.round(stages.reduce((sum, s) => sum + s.percent, 0) / stages.length)\n}\n\n/** A project item we correlate Smartcat documents back to. */\nexport interface ProjectItem {\n _id: string\n title?: string\n}\n\n/**\n * Extracts the 8-char source-id prefix Smartcat preserves in a document's\n * filename (built as `<title>-<idPrefix>.locjson`). Used to correlate a Smartcat\n * document back to a Sanity source document.\n *\n * The prefix is always the final 8 characters before the extension, so take the\n * tail rather than splitting on a dash — the title or the id prefix itself can\n * contain dashes (e.g. a custom id like `demo-pdpA` → prefix `demo-pdp`), which\n * would make a last-dash split return the wrong fragment.\n *\n * Only a literal `.locjson` extension is stripped (Smartcat's API often returns\n * the name without it). A generic strip-after-last-dot would eat the tail of any\n * DOTTED title — e.g. micro-copy keys like `aiAssistantModal.resetChat-11AKrV8G`\n * — leaving the wrong 8 chars, so those documents silently never correlate,\n * never import, and re-syncs duplicate them instead of updating.\n */\nexport function sourceIdPrefix(doc: SmartcatDocument): string {\n const base = doc.name || doc.filename || doc.fullPath || ''\n const leaf = base.split('/').pop() ?? ''\n const noExt = leaf.replace(/\\.locjson$/i, '')\n return noExt.slice(-8)\n}\n\n/**\n * Builds the full progress array from a freshly fetched Smartcat project and the\n * project's items — independent of any export-time skeleton, so it works for\n * projects exported before progress tracking existed.\n *\n * Documents are correlated to items by the 8-char id prefix in their filename;\n * `imported` flags are carried over from the previous progress (by source doc +\n * language) so importing isn't forgotten on refresh.\n *\n * `mappings` invert Smartcat's returned target codes back to Sanity ids, so all\n * stored progress uses Sanity locale tags. When omitted, codes pass through\n * unchanged (identity) — keeping legacy projects working.\n */\nexport function buildProgress(\n items: ProjectItem[],\n scProject: SmartcatProject,\n previous: DocProgress[] | undefined,\n now: string,\n mappings?: SmartcatLanguageMapping[],\n): DocProgress[] {\n const stageNames = buildStageNameMap(scProject)\n\n const importedSet = new Set<string>()\n // Titles are computed Sanity-native in the Studio and shipped on the outbox, so\n // they may not be re-derivable server-side. Carry the previously-stored title\n // forward so a progress sync (which only has the raw `doc.title`) can't clobber\n // it with an \"Untitled\" for types that have no literal title field.\n const titleByDoc = new Map<string, string>()\n for (const doc of previous ?? []) {\n if (doc.title) titleByDoc.set(doc.sourceDocId, doc.title)\n for (const target of doc.targets) {\n if (target.imported) importedSet.add(`${doc.sourceDocId}__${target.language}`)\n }\n }\n\n const itemByPrefix = new Map<string, ProjectItem>()\n for (const item of items) itemByPrefix.set(stripDraft(item._id).slice(0, 8), item)\n\n const byDoc = new Map<string, DocProgress>()\n for (const scDoc of scProject.documents ?? []) {\n if (!scDoc.targetLanguage) continue\n const item = itemByPrefix.get(sourceIdPrefix(scDoc))\n if (!item) continue\n\n // Store the Sanity locale tag, not Smartcat's code.\n const language = toSanityLanguage(mappings, scDoc.targetLanguage)\n\n let docProgress = byDoc.get(item._id)\n if (!docProgress) {\n const title = item.title || titleByDoc.get(item._id)\n docProgress = {_key: targetKey(item._id, 'doc'), sourceDocId: item._id, title, targets: []}\n byDoc.set(item._id, docProgress)\n }\n const stages = stagesFromDocument(scDoc, stageNames)\n docProgress.targets.push({\n _key: targetKey(item._id, language),\n language,\n smartcatDocumentId: scDoc.id,\n stages,\n complete: isTargetComplete(stages),\n imported: importedSet.has(`${item._id}__${language}`),\n syncedAt: now,\n })\n }\n\n // Preserve item order; sort each doc's targets by language for stable display.\n const result: DocProgress[] = []\n for (const item of items) {\n const docProgress = byDoc.get(item._id)\n if (!docProgress) continue\n docProgress.targets.sort((a, b) => a.language.localeCompare(b.language))\n result.push(docProgress)\n }\n return result\n}\n\nexport interface CompletionSummary {\n /** Total (document × language) targets. */\n total: number\n /** Targets whose stages are all at 100%. */\n complete: number\n /** Targets a variant has been built for. */\n imported: number\n /** True when every target is complete and imported (and there is ≥1 target). */\n allDone: boolean\n /** True when at least one stage shows any progress. */\n anyProgress: boolean\n}\n\n/** Summarizes a progress array for status decisions and the dashboard. */\nexport function summarizeCompletion(progress: DocProgress[]): CompletionSummary {\n let total = 0\n let complete = 0\n let imported = 0\n let anyProgress = false\n for (const doc of progress) {\n for (const target of doc.targets) {\n total++\n if (target.complete) complete++\n if (target.imported) imported++\n if (target.stages.some((s) => s.percent > 0)) anyProgress = true\n }\n }\n return {\n total,\n complete,\n imported,\n allDone: total > 0 && complete === total && imported === total,\n anyProgress,\n }\n}\n\n// ---------------------------------------------------------------------------\n// Function-side orchestration: refresh progress from Smartcat\n// ---------------------------------------------------------------------------\n\n/** Minimal structural subset of @sanity/client used by the progress refresh. */\nexport interface SanityLikeClient {\n fetch<T = unknown>(query: string, params?: Record<string, unknown>): Promise<T>\n patch(id: string): {\n set(attrs: Record<string, unknown>): {commit(): Promise<unknown>}\n }\n}\n\n/** Structural subset of SmartcatClient the progress refresh needs. */\nexport interface SmartcatProgressClient {\n getProject(projectId: string): Promise<SmartcatProject>\n /** Optional: trace every HTTP call so a timeout/error reaches the functionLog. */\n setRequestLogger?(logger: RequestLogger): void\n}\n\nexport interface RunProgressSyncOptions {\n sanity: SanityLikeClient\n smartcat: SmartcatProgressClient\n /** `_id` of the `smartcat.translationProject` to refresh. */\n projectId: string\n now?: () => string\n}\n\nexport interface RunProgressSyncResult {\n summary: CompletionSummary\n /** Set when the Smartcat fetch failed (e.g. timed out); the run was recorded, not thrown. */\n error?: string\n}\n\nconst PROGRESS_QUERY = `*[_id == $id][0]{\n _id,\n status,\n smartcatProjectId,\n smartcatLanguages[]{sanityId, smartcatLanguage},\n \"items\": items[]{ \"doc\": ${itemDocDeref('{_id}')} },\n progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}\n}`\n\ninterface ProgressProjectData {\n _id: string\n status?: string\n smartcatProjectId?: string\n smartcatLanguages?: SmartcatLanguageMapping[]\n items?: {doc: {_id: string} | null}[]\n progress?: DocProgress[]\n}\n\n/** Resolves a project's referenced items into the lightweight shape we correlate on. */\nexport function itemsFromProject(project: {\n items?: {doc: {_id: string} | null}[]\n}): ProjectItem[] {\n return (project.items ?? [])\n .map((i) => i?.doc)\n .filter((d): d is {_id: string} => Boolean(d))\n .map((d) => ({_id: d._id}))\n}\n\n/**\n * Thin progress step run by the `smartcat-progress` Function: fetches the\n * Smartcat project and mirrors its per-stage progress onto the project's\n * `progress` array. Read-only with respect to Smartcat — it never downloads or\n * creates variants, so it is cheap to run on every dashboard page load.\n */\nexport async function runProgressSync(\n options: RunProgressSyncOptions,\n): Promise<RunProgressSyncResult> {\n const {sanity, smartcat, projectId, now = () => new Date().toISOString()} = options\n\n const project = await sanity.fetch<ProgressProjectData | null>(PROGRESS_QUERY, {id: projectId})\n if (!project) throw new Error(`Translation project ${projectId} not found`)\n if (!project.smartcatProjectId) {\n throw new Error('Project has no smartcatProjectId — export it first')\n }\n\n const timestamp = now()\n\n // Trace HTTP failures into a functionLog so a timeout/error surfaces in the UI.\n // Successful calls are left unlogged: this Function runs on every page load, and\n // functionLog is shared with export/import — writing it on every success would\n // wipe a useful export/import log.\n const log: LogLine[] = []\n smartcat.setRequestLogger?.(({method, path, status, body}) => {\n if (status >= 200 && status < 300) return\n log.push({\n level: 'error',\n message: status ? `Error ${status} while performing ${method} ${path}` : `${method} ${path} did not complete`,\n })\n if (body) log.push({level: 'error', indent: true, message: body})\n })\n\n try {\n const scProject = await smartcat.getProject(project.smartcatProjectId)\n const progress = buildProgress(\n itemsFromProject(project),\n scProject,\n project.progress,\n timestamp,\n project.smartcatLanguages,\n )\n const summary = summarizeCompletion(progress)\n\n const patch: Record<string, unknown> = {progress, progressSyncedAt: timestamp, lastError: null}\n // Gentle one-way nudge: once Smartcat reports any work, reflect it in the badge.\n // Completion remains owned by the import flow.\n if (project.status === 'sent' && summary.anyProgress) {\n patch.status = 'translating'\n }\n\n await sanity.patch(projectId).set(patch).commit()\n\n return {summary}\n } catch (err) {\n // Record the failure and return instead of throwing: a throw would let the\n // platform retry, and a timed-out fetch has nothing transient to gain. Leave\n // the existing `progress` untouched (no zeroing-out) and only stamp the error\n // plus a fresh `progressSyncedAt`, which releases the UI refresh spinner and\n // makes the log land where the dashboard reads it.\n const message = err instanceof Error ? err.message : String(err)\n if (!log.some((l) => l.level === 'error')) log.push({level: 'error', message})\n await sanity\n .patch(projectId)\n .set({lastError: message, functionLog: JSON.stringify(log), progressSyncedAt: timestamp})\n .commit()\n .catch(() => {})\n return {summary: summarizeCompletion(project.progress ?? []), error: message}\n }\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sources":["../../src/lib/languageMap.ts","../../src/lib/projectItems.ts","../../src/progress/core.ts"],"sourcesContent":["/**\n * Sanity ↔ Smartcat language-code mapping.\n *\n * Sanity locale tags (`id`) don't always match the codes Smartcat understands\n * (e.g. Sanity \"PL-PL\" vs Smartcat \"pl\"). The browser resolves the mapping from\n * plugin config and stamps the relevant pairs onto the project; the thin\n * Functions read them back. This module is dependency-free so it bundles into\n * both the Studio and the Functions.\n *\n * The invariant: everything stored in Sanity uses the Sanity `id`; only the\n * Smartcat HTTP calls use `smartcatLanguage`. So callers map forward (id → code)\n * when talking to Smartcat and invert (code → id) when storing what comes back.\n */\n\nexport interface SmartcatLanguageMapping {\n sanityId: string\n smartcatLanguage: string\n}\n\n/** Minimal language shape needed to build mappings (a subset of SmartcatLanguage). */\ninterface MappableLanguage {\n id: string\n smartcatLanguage?: string\n}\n\n/**\n * Builds the mapping pairs for the given Sanity ids, resolving each to its\n * configured `smartcatLanguage` (falling back to the id when not configured).\n */\nexport function buildLanguageMappings(\n languages: MappableLanguage[],\n ids: string[],\n): SmartcatLanguageMapping[] {\n const byId = new Map(languages.map((l) => [l.id, l.smartcatLanguage || l.id]))\n return ids.map((id) => ({sanityId: id, smartcatLanguage: byId.get(id) || id}))\n}\n\n/** Sanity id → Smartcat code. Identity when no mapping is found. */\nexport function toSmartcatLanguage(\n mappings: SmartcatLanguageMapping[] | undefined,\n sanityId: string,\n): string {\n return mappings?.find((m) => m.sanityId === sanityId)?.smartcatLanguage ?? sanityId\n}\n\n/** Smartcat code → Sanity id. Identity when no mapping is found. */\nexport function toSanityLanguage(\n mappings: SmartcatLanguageMapping[] | undefined,\n smartcatLanguage: string,\n): string {\n return mappings?.find((m) => m.smartcatLanguage === smartcatLanguage)?.sanityId ?? smartcatLanguage\n}\n\n/**\n * Finds Smartcat codes that more than one Sanity id maps to. A non-empty result\n * means the inverse (code → id) is ambiguous, so translations can't be routed\n * back — callers should reject such a configuration.\n */\nexport function findDuplicateSmartcatLanguages(\n mappings: SmartcatLanguageMapping[],\n): {smartcatLanguage: string; sanityIds: string[]}[] {\n const byCode = new Map<string, string[]>()\n for (const m of mappings) {\n const ids = byCode.get(m.smartcatLanguage) ?? []\n ids.push(m.sanityId)\n byCode.set(m.smartcatLanguage, ids)\n }\n return [...byCode.entries()]\n .filter(([, ids]) => ids.length > 1)\n .map(([smartcatLanguage, sanityIds]) => ({smartcatLanguage, sanityIds}))\n}\n","/**\n * GROQ fragments for reading a translation project's `items`.\n *\n * An item is either the current `smartcat.projectItem` shape (`{docId, docType}`)\n * or a legacy weak reference (`{_ref}`) from before the migration. These\n * fragments normalize across both so the old/new tolerance lives in one place —\n * interpolate them into item queries rather than re-writing `coalesce(...)`.\n */\n\n/** Normalized item id, evaluated in the item's own scope (inside `items[...]`). */\nexport const ITEM_ID = 'coalesce(docId, _ref)'\n\n/** Normalized item id, evaluated from a subquery nested under an item (via `^`). */\nexport const ITEM_ID_FROM_SUBQUERY = 'coalesce(^.docId, ^._ref)'\n\n/** Dereference an item's document, with an optional projection e.g. `{_id, _type}`. */\nexport const itemDocDeref = (projection = ''): string => `*[_id == ${ITEM_ID_FROM_SUBQUERY}][0]${projection}`\n\n/**\n * Dereference an item's document, honoring where it was added from: items added\n * in the **published** perspective (`sourceIsPublished == true`) resolve the\n * published document only; every other item (including legacy items with no\n * flag) prefers the **draft**, falling back to published. This makes the export\n * translate the content the editor was actually viewing, and gives pre-existing\n * projects the draft-preferring default for free.\n */\nexport const itemDocDerefRespectingDraft = (projection = ''): string =>\n `select(` +\n `sourceIsPublished == true => *[_id == ${ITEM_ID_FROM_SUBQUERY}][0]${projection}, ` +\n `coalesce(*[_id == \"drafts.\" + ${ITEM_ID_FROM_SUBQUERY}][0]${projection}, *[_id == ${ITEM_ID_FROM_SUBQUERY}][0]${projection})` +\n `)`\n","import type {SmartcatProject, SmartcatDocument, RequestLogger} from '../smartcat/types'\nimport {toSanityLanguage, type SmartcatLanguageMapping} from '../lib/languageMap'\nimport {ITEM_ID, itemDocDerefRespectingDraft} from '../lib/projectItems'\nimport type {LogLine} from '../lib/log'\n\n/**\n * Per-document, per-language, per-stage translation progress mirrored from\n * Smartcat onto a `smartcat.translationProject`.\n *\n * This module is intentionally **dependency-free** (no DOM, no @sanity/* runtime)\n * so it bundles cleanly into the thin `smartcat-progress` / `smartcat-import`\n * Functions. All keys are deterministic, so repeated refreshes patch the same\n * array members in place instead of churning `_key`s.\n */\n\nexport interface StageProgress {\n /** Stable key for the Sanity array item (always set by `stagesFromDocument`). */\n _key?: string\n name: string\n /** 0–100, rounded. */\n percent: number\n}\n\nexport interface TargetProgress {\n _key: string\n language: string\n /** Smartcat target document id (`<fileGuid>_<languageNumber>`). */\n smartcatDocumentId?: string\n stages: StageProgress[]\n /** All stages at 100%. */\n complete: boolean\n /** A locale variant has been built from this target (set by the browser). */\n imported: boolean\n syncedAt?: string\n}\n\nexport interface DocProgress {\n _key: string\n sourceDocId: string\n title?: string\n targets: TargetProgress[]\n}\n\n/** Sanitizes an arbitrary string into a stable, Sanity-safe array `_key`. */\nexport function keyify(value: string): string {\n return value.replace(/[^a-zA-Z0-9_-]/g, '-') || 'x'\n}\n\n/** Builds a progress target's `_key` from its document + language. */\nexport function targetKey(sourceDocId: string, language: string): string {\n return keyify(`${sourceDocId}__${language}`)\n}\n\nfunction clampPercent(value: number | undefined): number {\n if (typeof value !== 'number' || Number.isNaN(value)) return 0\n return Math.max(0, Math.min(100, Math.round(value)))\n}\n\n/** Strips a `drafts.` prefix from a Sanity document id. */\nfunction stripDraft(id: string): string {\n return id.replace(/^drafts\\./, '')\n}\n\nfunction titleCase(value: string): string {\n return value ? value.charAt(0).toUpperCase() + value.slice(1) : value\n}\n\n/**\n * Builds a stage-id → display-name map from a project's `workflowStages`. On\n * Smartcat, document-level stages carry only `{id, progress}` — the human name\n * (`stageType`, e.g. \"translation\") lives on the project-level stages.\n */\nexport function buildStageNameMap(scProject: SmartcatProject): Map<string, string> {\n const map = new Map<string, string>()\n ;(scProject.workflowStages ?? []).forEach((stage, i) => {\n if (stage.id) map.set(stage.id, titleCase(stage.stageType || stage.stageName || `Stage ${i + 1}`))\n })\n return map\n}\n\n/** Maps a Smartcat document's workflow stages to display-ready stage progress. */\nexport function stagesFromDocument(\n doc: SmartcatDocument,\n stageNames: Map<string, string>,\n): StageProgress[] {\n const stages = doc.workflowStages ?? []\n return stages.map((stage, i) => ({\n _key: stage.id || `s${i}`,\n name:\n (stage.id && stageNames.get(stage.id)) ||\n titleCase(stage.stageType || stage.stageName || `Stage ${i + 1}`),\n percent: clampPercent(stage.progress),\n }))\n}\n\n/** A target is complete when it has stages and every one is at 100%. */\nexport function isTargetComplete(stages: StageProgress[]): boolean {\n return stages.length > 0 && stages.every((s) => s.percent >= 100)\n}\n\n/**\n * Whether a target has any content confirmed at any stage — i.e. there is\n * something worth importing. Used to gate `mode=confirmed` downloads so we skip\n * targets with nothing confirmed yet (which would otherwise come back as source).\n */\nexport function hasConfirmedContent(stages: StageProgress[]): boolean {\n return stages.some((s) => s.percent > 0)\n}\n\n/** Average completion across a target's stages (0–100). */\nexport function targetPercent(stages: StageProgress[]): number {\n if (stages.length === 0) return 0\n return Math.round(stages.reduce((sum, s) => sum + s.percent, 0) / stages.length)\n}\n\n/** A project item we correlate Smartcat documents back to. */\nexport interface ProjectItem {\n _id: string\n title?: string\n}\n\n/**\n * Extracts the 8-char source-id prefix Smartcat preserves in a document's\n * filename (built as `<title>-<idPrefix>.locjson`, or `<title>-<idPrefix>-draft.locjson`\n * when the content came from the document's draft). Used to correlate a Smartcat\n * document back to a Sanity source document.\n *\n * A trailing `-draft` marker is stripped first, so a draft-sourced file resolves\n * to the same id as its published counterpart. The prefix is then always the\n * final 8 characters before the (marker and) extension, so take the tail rather\n * than splitting on a dash — the title or the id prefix itself can\n * contain dashes (e.g. a custom id like `demo-pdpA` → prefix `demo-pdp`), which\n * would make a last-dash split return the wrong fragment.\n *\n * Only a literal `.locjson` extension is stripped (Smartcat's API often returns\n * the name without it). A generic strip-after-last-dot would eat the tail of any\n * DOTTED title — e.g. micro-copy keys like `aiAssistantModal.resetChat-11AKrV8G`\n * — leaving the wrong 8 chars, so those documents silently never correlate,\n * never import, and re-syncs duplicate them instead of updating.\n */\nexport function sourceIdPrefix(doc: SmartcatDocument): string {\n const base = doc.name || doc.filename || doc.fullPath || ''\n const leaf = base.split('/').pop() ?? ''\n const noExt = leaf.replace(/\\.locjson$/i, '')\n // Draft-sourced files carry a trailing `-draft` marker (informational only, so\n // a project never holds both a document's draft and its published version).\n // Strip it before taking the tail so a draft file correlates to the same source\n // id as its published counterpart — the id prefix is what identifies the item.\n const noDraft = noExt.replace(/-draft$/, '')\n return noDraft.slice(-8)\n}\n\n/**\n * Builds the full progress array from a freshly fetched Smartcat project and the\n * project's items — independent of any export-time skeleton, so it works for\n * projects exported before progress tracking existed.\n *\n * Documents are correlated to items by the 8-char id prefix in their filename;\n * `imported` flags are carried over from the previous progress (by source doc +\n * language) so importing isn't forgotten on refresh.\n *\n * `mappings` invert Smartcat's returned target codes back to Sanity ids, so all\n * stored progress uses Sanity locale tags. When omitted, codes pass through\n * unchanged (identity) — keeping legacy projects working.\n */\nexport function buildProgress(\n items: ProjectItem[],\n scProject: SmartcatProject,\n previous: DocProgress[] | undefined,\n now: string,\n mappings?: SmartcatLanguageMapping[],\n): DocProgress[] {\n const stageNames = buildStageNameMap(scProject)\n\n const importedSet = new Set<string>()\n // Titles are computed Sanity-native in the Studio and shipped on the outbox, so\n // they may not be re-derivable server-side. Carry the previously-stored title\n // forward so a progress sync (which only has the raw `doc.title`) can't clobber\n // it with an \"Untitled\" for types that have no literal title field.\n const titleByDoc = new Map<string, string>()\n for (const doc of previous ?? []) {\n if (doc.title) titleByDoc.set(doc.sourceDocId, doc.title)\n for (const target of doc.targets) {\n if (target.imported) importedSet.add(`${doc.sourceDocId}__${target.language}`)\n }\n }\n\n const itemByPrefix = new Map<string, ProjectItem>()\n for (const item of items) itemByPrefix.set(stripDraft(item._id).slice(0, 8), item)\n\n const byDoc = new Map<string, DocProgress>()\n for (const scDoc of scProject.documents ?? []) {\n if (!scDoc.targetLanguage) continue\n const item = itemByPrefix.get(sourceIdPrefix(scDoc))\n if (!item) continue\n\n // Store the Sanity locale tag, not Smartcat's code.\n const language = toSanityLanguage(mappings, scDoc.targetLanguage)\n\n let docProgress = byDoc.get(item._id)\n if (!docProgress) {\n const title = item.title || titleByDoc.get(item._id)\n docProgress = {_key: targetKey(item._id, 'doc'), sourceDocId: item._id, title, targets: []}\n byDoc.set(item._id, docProgress)\n }\n const stages = stagesFromDocument(scDoc, stageNames)\n docProgress.targets.push({\n _key: targetKey(item._id, language),\n language,\n smartcatDocumentId: scDoc.id,\n stages,\n complete: isTargetComplete(stages),\n imported: importedSet.has(`${item._id}__${language}`),\n syncedAt: now,\n })\n }\n\n // Preserve item order; sort each doc's targets by language for stable display.\n const result: DocProgress[] = []\n for (const item of items) {\n const docProgress = byDoc.get(item._id)\n if (!docProgress) continue\n docProgress.targets.sort((a, b) => a.language.localeCompare(b.language))\n result.push(docProgress)\n }\n return result\n}\n\nexport interface CompletionSummary {\n /** Total (document × language) targets. */\n total: number\n /** Targets whose stages are all at 100%. */\n complete: number\n /** Targets a variant has been built for. */\n imported: number\n /** True when every target is complete and imported (and there is ≥1 target). */\n allDone: boolean\n /** True when at least one stage shows any progress. */\n anyProgress: boolean\n}\n\n/** Summarizes a progress array for status decisions and the dashboard. */\nexport function summarizeCompletion(progress: DocProgress[]): CompletionSummary {\n let total = 0\n let complete = 0\n let imported = 0\n let anyProgress = false\n for (const doc of progress) {\n for (const target of doc.targets) {\n total++\n if (target.complete) complete++\n if (target.imported) imported++\n if (target.stages.some((s) => s.percent > 0)) anyProgress = true\n }\n }\n return {\n total,\n complete,\n imported,\n allDone: total > 0 && complete === total && imported === total,\n anyProgress,\n }\n}\n\n// ---------------------------------------------------------------------------\n// Function-side orchestration: refresh progress from Smartcat\n// ---------------------------------------------------------------------------\n\n/** Minimal structural subset of @sanity/client used by the progress refresh. */\nexport interface SanityLikeClient {\n fetch<T = unknown>(query: string, params?: Record<string, unknown>): Promise<T>\n patch(id: string): {\n set(attrs: Record<string, unknown>): {commit(): Promise<unknown>}\n }\n}\n\n/** Structural subset of SmartcatClient the progress refresh needs. */\nexport interface SmartcatProgressClient {\n getProject(projectId: string): Promise<SmartcatProject>\n /** Optional: trace every HTTP call so a timeout/error reaches the functionLog. */\n setRequestLogger?(logger: RequestLogger): void\n}\n\nexport interface RunProgressSyncOptions {\n sanity: SanityLikeClient\n smartcat: SmartcatProgressClient\n /** `_id` of the `smartcat.translationProject` to refresh. */\n projectId: string\n now?: () => string\n}\n\nexport interface RunProgressSyncResult {\n summary: CompletionSummary\n /** Set when the Smartcat fetch failed (e.g. timed out); the run was recorded, not thrown. */\n error?: string\n}\n\nconst PROGRESS_QUERY = `*[_id == $id][0]{\n _id,\n status,\n smartcatProjectId,\n smartcatLanguages[]{sanityId, smartcatLanguage},\n \"items\": items[]{ \"_id\": ${ITEM_ID}, \"title\": ${itemDocDerefRespectingDraft('.title')} },\n progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}\n}`\n\ninterface ProgressProjectData {\n _id: string\n status?: string\n smartcatProjectId?: string\n smartcatLanguages?: SmartcatLanguageMapping[]\n items?: ProjectItemRef[]\n progress?: DocProgress[]\n}\n\n/** A project item as read for correlation: its stored id (always present) plus a\n * best-effort title. `doc` is the legacy shape — some callers still project it. */\ninterface ProjectItemRef {\n _id?: string\n title?: string\n doc?: {_id?: string; title?: string} | null\n}\n\n/**\n * Resolves a project's items into the lightweight shape we correlate on. The id\n * is the item's stored `docId` — NOT a dereferenced document — so an item whose\n * document exists only as a draft (never published) still correlates instead of\n * being silently dropped. Tolerates the legacy `{doc}` projection.\n */\nexport function itemsFromProject(project: {items?: ProjectItemRef[]}): ProjectItem[] {\n const result: ProjectItem[] = []\n for (const item of project.items ?? []) {\n const _id = item?._id ?? item?.doc?._id\n if (!_id) continue\n result.push({_id, title: item?.title ?? item?.doc?.title ?? undefined})\n }\n return result\n}\n\n/**\n * Thin progress step run by the `smartcat-progress` Function: fetches the\n * Smartcat project and mirrors its per-stage progress onto the project's\n * `progress` array. Read-only with respect to Smartcat — it never downloads or\n * creates variants, so it is cheap to run on every dashboard page load.\n */\nexport async function runProgressSync(\n options: RunProgressSyncOptions,\n): Promise<RunProgressSyncResult> {\n const {sanity, smartcat, projectId, now = () => new Date().toISOString()} = options\n\n const project = await sanity.fetch<ProgressProjectData | null>(PROGRESS_QUERY, {id: projectId})\n if (!project) throw new Error(`Translation project ${projectId} not found`)\n if (!project.smartcatProjectId) {\n throw new Error('Project has no smartcatProjectId — export it first')\n }\n\n const timestamp = now()\n\n // Trace HTTP failures into a functionLog so a timeout/error surfaces in the UI.\n // Successful calls are left unlogged: this Function runs on every page load, and\n // functionLog is shared with export/import — writing it on every success would\n // wipe a useful export/import log.\n const log: LogLine[] = []\n smartcat.setRequestLogger?.(({method, path, status, body}) => {\n if (status >= 200 && status < 300) return\n log.push({\n level: 'error',\n message: status ? `Error ${status} while performing ${method} ${path}` : `${method} ${path} did not complete`,\n })\n if (body) log.push({level: 'error', indent: true, message: body})\n })\n\n try {\n const scProject = await smartcat.getProject(project.smartcatProjectId)\n const progress = buildProgress(\n itemsFromProject(project),\n scProject,\n project.progress,\n timestamp,\n project.smartcatLanguages,\n )\n const summary = summarizeCompletion(progress)\n\n const patch: Record<string, unknown> = {progress, progressSyncedAt: timestamp, lastError: null}\n // Gentle one-way nudge: once Smartcat reports any work, reflect it in the badge.\n // Completion remains owned by the import flow.\n if (project.status === 'sent' && summary.anyProgress) {\n patch.status = 'translating'\n }\n\n await sanity.patch(projectId).set(patch).commit()\n\n return {summary}\n } catch (err) {\n // Record the failure and return instead of throwing: a throw would let the\n // platform retry, and a timed-out fetch has nothing transient to gain. Leave\n // the existing `progress` untouched (no zeroing-out) and only stamp the error\n // plus a fresh `progressSyncedAt`, which releases the UI refresh spinner and\n // makes the log land where the dashboard reads it.\n const message = err instanceof Error ? err.message : String(err)\n if (!log.some((l) => l.level === 'error')) log.push({level: 'error', message})\n await sanity\n .patch(projectId)\n .set({lastError: message, functionLog: JSON.stringify(log), progressSyncedAt: timestamp})\n .commit()\n .catch(() => {})\n return {summary: summarizeCompletion(project.progress ?? []), error: message}\n }\n}\n"],"names":[],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { toHTML } from "@portabletext/to-html";
1
+ import { toHTML, escapeHTML } from "@portabletext/to-html";
2
2
  import { htmlToBlocks } from "@sanity/block-tools";
3
3
  function isExcludedByDocumentI18n(field) {
4
4
  return field.type?.options?.documentInternationalization?.exclude === !0;
@@ -170,6 +170,21 @@ function setAtPath(root, path, value) {
170
170
  }
171
171
  return clone;
172
172
  }
173
+ const SERIALIZABLE_CUSTOM_TYPES = /* @__PURE__ */ new Set(["table"]);
174
+ function tableToHtml(value) {
175
+ return `<table><tbody>${(Array.isArray(value?.rows) ? value.rows : []).map((row) => `<tr>${(Array.isArray(row?.cells) ? row.cells : []).map((cell) => `<td>${escapeHTML(typeof cell == "string" ? cell : "")}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
176
+ }
177
+ const tableRule = {
178
+ deserialize(el, _next, createBlock) {
179
+ if (el.tagName?.toLowerCase() !== "table") return;
180
+ const rows = Array.from(el.querySelectorAll("tr")).map((tr, i) => ({
181
+ _type: "tableRow",
182
+ _key: `row${i}`,
183
+ cells: Array.from(tr.querySelectorAll("td, th")).map((cell) => cell.textContent ?? "")
184
+ }));
185
+ return createBlock({ _type: "table", rows });
186
+ }
187
+ };
173
188
  function portableTextToHtml(blocks) {
174
189
  return !blocks || blocks.length === 0 ? "" : toHTML(blocks, {
175
190
  // Fallback only. Serialization skips any field containing an unserializable
@@ -181,7 +196,12 @@ function portableTextToHtml(blocks) {
181
196
  // (`onMissingComponent: false` only silences the warning; it does NOT stop the
182
197
  // placeholder — the `unknownType` override is what suppresses it.)
183
198
  onMissingComponent: !1,
184
- components: { unknownType: () => "" }
199
+ components: {
200
+ unknownType: () => "",
201
+ // Serializable custom block types get real markup so their text is
202
+ // translatable and can be parsed back (see SERIALIZABLE_CUSTOM_TYPES).
203
+ types: { table: ({ value }) => tableToHtml(value) }
204
+ }
185
205
  });
186
206
  }
187
207
  function findUnserializableBlockTypes(blocks) {
@@ -191,7 +211,7 @@ function findUnserializableBlockTypes(blocks) {
191
211
  if (!block || typeof block != "object") continue;
192
212
  const type = block._type;
193
213
  if (type !== "block") {
194
- typeof type == "string" && types.add(type);
214
+ typeof type == "string" && !SERIALIZABLE_CUSTOM_TYPES.has(type) && types.add(type);
195
215
  continue;
196
216
  }
197
217
  const children = block.children;
@@ -211,13 +231,20 @@ function portableTextToPlainText(blocks) {
211
231
  node.forEach(visit);
212
232
  else if (node && typeof node == "object") {
213
233
  const value = node;
214
- typeof value.text == "string" && (text += value.text), Array.isArray(value.children) && visit(value.children);
234
+ if (typeof value.text == "string" && (text += value.text), Array.isArray(value.children) && visit(value.children), Array.isArray(value.rows))
235
+ for (const row of value.rows) {
236
+ const cells = row?.cells;
237
+ if (Array.isArray(cells)) for (const cell of cells) typeof cell == "string" && (text += cell);
238
+ }
215
239
  }
216
240
  };
217
241
  return visit(blocks), text;
218
242
  }
219
243
  function htmlToPortableText(html, blockContentType, parseHtml) {
220
- return html.trim() ? htmlToBlocks(html, blockContentType, { parseHtml }) : [];
244
+ return html.trim() ? htmlToBlocks(html, blockContentType, {
245
+ parseHtml,
246
+ rules: [tableRule]
247
+ }) : [];
221
248
  }
222
249
  function serializeToLocjson(doc, fields, options) {
223
250
  const units = [];
@@ -270,9 +297,9 @@ function memberValue(field, language, languageKey) {
270
297
  (m) => m && typeof m == "object" && m[languageKey] === language
271
298
  )?.value : void 0;
272
299
  }
273
- function buildLocjsonFilename(documentType, documentId, title) {
274
- const idPrefix = stripDraft(documentId).slice(0, 8) || "unknown", safeTitle = sanitizeSegment(title || "Untitled");
275
- return `${sanitizeSegment(documentType) || "document"}/${safeTitle}-${idPrefix}.locjson`;
300
+ function buildLocjsonFilename(documentType, documentId, title, options = {}) {
301
+ const idPrefix = stripDraft(documentId).slice(0, 8) || "unknown", safeTitle = sanitizeSegment(title || "Untitled"), safeType = sanitizeSegment(documentType) || "document", draftMarker = options.draft ? "-draft" : "";
302
+ return `${safeType}/${safeTitle}-${idPrefix}${draftMarker}.locjson`;
276
303
  }
277
304
  function stripDraft(id) {
278
305
  return id.replace(/^drafts\./, "");
@@ -1 +1 @@
1
- {"version":3,"file":"index2.js","sources":["../../src/lib/locjson/fields.ts","../../src/lib/locjson/paths.ts","../../src/lib/locjson/portableText.ts","../../src/lib/locjson/serialize.ts","../../src/lib/locjson/filename.ts","../../src/lib/locjson/deserialize.ts"],"sourcesContent":["import type {LocalizationMode, TranslatableField, TranslatableLeaf, ValueShape} from './types'\n\nexport interface GetTranslatableFieldsOptions {\n /**\n * Field names to exclude (e.g. the i18n language field, passed by the caller\n * as the configured `documentI18nPluginLanguageField`). Defaults to none.\n */\n exclude?: string[]\n}\n\ninterface CompiledType {\n jsonType?: string\n name?: string\n title?: string\n of?: CompiledType[]\n fields?: CompiledField[]\n options?: {\n documentInternationalization?: {exclude?: boolean}\n smartcatTranslation?: {include?: boolean}\n }\n}\n\n/**\n * True when a field is marked non-translatable via document-internationalization's\n * own field option (`options.documentInternationalization.exclude`). Honoring it\n * means consumers exclude fields per type using the i18n plugin's native marker —\n * no separate config here.\n */\nfunction isExcludedByDocumentI18n(field: CompiledField): boolean {\n return field.type?.options?.documentInternationalization?.exclude === true\n}\n\ninterface CompiledField {\n name: string\n type?: CompiledType\n}\n\n/**\n * True for a Sanity **system** type — the reserved `sanity.` namespace, notably\n * the `sanity.imageAsset` / `sanity.fileAsset` documents that image/file fields\n * reference. Their string fields (`originalFilename`, `path`, `url`, …) look\n * translatable but must never be sent: assets are shared across locales, and\n * translating a filename or CDN path corrupts the reference. Excluding them here\n * keeps them out of both the linked-document crawl and export serialization,\n * since both decide translatability via {@link getTranslatableFields}.\n */\nexport function isSystemType(typeName: unknown): boolean {\n return typeof typeName === 'string' && typeName.startsWith('sanity.')\n}\n\n/**\n * True for a `sanity-plugin-internationalized-array` array type (e.g.\n * `internationalizedArrayString`). Detected structurally by naming convention so\n * we never need a dependency on that plugin.\n */\nexport function isInternationalizedArrayType(type?: CompiledType): boolean {\n return (\n type?.jsonType === 'array' &&\n typeof type.name === 'string' &&\n type.name.startsWith('internationalizedArray')\n )\n}\n\n/** True for an array member `_type` like `internationalizedArrayStringValue`. */\nexport function isInternationalizedArrayMemberType(typeName: unknown): boolean {\n return typeof typeName === 'string' && /^internationalizedArray.+Value$/.test(typeName)\n}\n\n/** The inner value type of an internationalized-array field (its member's `value` field). */\nexport function innerValueType(arrayType?: CompiledType): CompiledType | undefined {\n return arrayType?.of?.[0]?.fields?.find((f) => f.name === 'value')?.type\n}\n\n/**\n * Localization mode of a **compiled** schema type: `field` if it declares any\n * internationalized-array field (in which case only those fields are\n * translatable), otherwise `document`. A pure function of the type, so callers\n * should compute it once per type.\n */\nexport function localizationMode(compiledType: unknown): LocalizationMode {\n const fields = (compiledType as CompiledType)?.fields\n if (!Array.isArray(fields)) return 'document'\n return fields.some((f) => isInternationalizedArrayType(f.type)) ? 'field' : 'document'\n}\n\n/**\n * Selects translatable fields from a **compiled** Sanity schema type.\n *\n * Document-mode types (default policy, installation-agnostic):\n * - `string` and `text` fields -> plain units\n * - Portable Text (arrays containing `block` members) -> HTML units\n * - everything else (slug, url, datetime, number, boolean, reference, image,\n * non-block arrays, objects, system `_` fields, excluded names) is excluded.\n *\n * Field-mode types (any internationalized-array field present): only the\n * internationalized-array fields are translatable; each is classified by its\n * inner `value` type (string/text -> plain, Portable Text -> HTML).\n *\n * The compiled schema shape isn't strongly typed publicly, so this reads it\n * defensively. Callers that need overrides can post-filter the result.\n */\nexport function getTranslatableFields(\n compiledType: unknown,\n options: GetTranslatableFieldsOptions = {},\n): TranslatableField[] {\n const rootType = compiledType as CompiledType\n if (isSystemType(rootType?.name)) return []\n if (!Array.isArray(rootType?.fields)) return []\n const result: TranslatableField[] = []\n walkFields(rootType, '', localizationMode(rootType), new Set(options.exclude ?? []), result)\n return result\n}\n\n/**\n * Recursively selects translatable fields. In field-mode it descends through\n * plain object fields (so nested internationalized-array fields, e.g.\n * `benefits.items`, are reached) and classifies each i18n field's member value\n * shape. Document-mode keeps the flat top-level behavior.\n */\nfunction walkFields(\n compiledType: CompiledType | undefined,\n prefix: string,\n mode: LocalizationMode,\n exclude: Set<string>,\n out: TranslatableField[],\n): void {\n for (const field of compiledType?.fields ?? []) {\n // `exclude` (the i18n language field) only ever appears at the top level.\n if (!field?.name || field.name.startsWith('_') || (prefix === '' && exclude.has(field.name))) continue\n const forceInclude = field.type?.options?.smartcatTranslation?.include === true\n if (isExcludedByDocumentI18n(field) && !forceInclude) continue\n const path = prefix ? `${prefix}.${field.name}` : field.name\n const title = field.type?.title || titleCase(field.name)\n\n if (mode === 'field') {\n if (isInternationalizedArrayType(field.type)) {\n const inner = innerValueType(field.type)\n const shape = classifyValueShape(inner) ?? (forceInclude ? classifyValueShapeByJsonType(inner) : null)\n if (shape) out.push({path, localization: 'field', kind: shape.container === 'scalar' ? shape.kind : 'plain', title, value: shape})\n else if (forceInclude) warnUntranslatableInclude(field.name, inner)\n } else if (isPlainObject(field.type)) {\n walkFields(field.type, path, mode, exclude, out)\n }\n } else {\n const kind = classifyField(field.type) ?? (forceInclude ? classifyByJsonType(field.type) : null)\n if (kind) out.push({path, localization: 'document', kind, title, value: {container: 'scalar', kind}})\n else if (forceInclude) warnUntranslatableInclude(field.name, field.type)\n }\n }\n}\n\n/** One translatable leaf of a field, expanded against a concrete source value. */\nexport interface FieldLeaf {\n /** Unique unit key / x-sanity fieldPath. */\n fullKey: string\n /** Path within the member value to the leaf (empty = the value itself). */\n valuePath: string\n kind: TranslatableField['kind']\n title?: string\n}\n\n/**\n * Expand a field into its translatable leaves against a concrete source value\n * (the source-language member value for field-mode). Serialize and import both\n * call this with the SAME source value, so the unit keys they compute match.\n */\nexport function enumerateLeaves(field: TranslatableField, sourceValue: unknown): FieldLeaf[] {\n const base = field.path\n const shape: ValueShape = field.value ?? {container: 'scalar', kind: field.kind}\n if (shape.container === 'scalar') {\n return [{fullKey: base, valuePath: '', kind: shape.kind, title: field.title}]\n }\n if (shape.container === 'arrayOfScalar') {\n const arr = Array.isArray(sourceValue) ? sourceValue : []\n return arr.map((_, i) => ({fullKey: `${base}[${i}]`, valuePath: `[${i}]`, kind: shape.kind, title: field.title}))\n }\n if (shape.container === 'object') {\n return shape.leaves.map((l) => ({fullKey: `${base}.${l.subPath}`, valuePath: l.subPath, kind: l.kind, title: l.title ?? field.title}))\n }\n const arr = Array.isArray(sourceValue) ? sourceValue : []\n const out: FieldLeaf[] = []\n for (const item of arr) {\n const key = (item as Record<string, unknown>)?._key\n if (typeof key !== 'string') continue\n for (const l of shape.leaves) {\n const valuePath = l.subPath ? `[${key}].${l.subPath}` : `[${key}]`\n out.push({fullKey: `${base}${valuePath}`, valuePath, kind: l.kind, title: l.title ?? field.title})\n }\n }\n return out\n}\n\n/** A recursable plain object (not a reference/image/file/slug/geopoint). */\nfunction isPlainObject(type?: CompiledType): boolean {\n if (type?.jsonType !== 'object' || !Array.isArray(type.fields)) return false\n if (type.fields.some((f) => f.name === '_ref')) return false\n return !['reference', 'image', 'file', 'slug', 'geopoint'].includes(type.name ?? '')\n}\n\n/** Classify an internationalized-array member's `value` type into a ValueShape. */\nfunction classifyValueShape(inner?: CompiledType): ValueShape | null {\n if (!inner) return null\n const scalar = classifyField(inner)\n if (scalar) return {container: 'scalar', kind: scalar}\n if (inner.jsonType === 'array' && Array.isArray(inner.of)) {\n const member = inner.of[0]\n if (member?.jsonType === 'string' && (member.name === 'string' || member.name === 'text')) {\n return {container: 'arrayOfScalar', kind: 'plain'}\n }\n if (isPlainObject(member)) {\n const leaves = collectLeaves(member, '')\n if (leaves.length) return {container: 'arrayOfObject', leaves}\n }\n return null\n }\n if (isPlainObject(inner)) {\n const leaves = collectLeaves(inner, '')\n if (leaves.length) return {container: 'object', leaves}\n }\n return null\n}\n\n/** ValueShape by jsonType only (for `smartcatTranslation.include` overrides). */\nfunction classifyValueShapeByJsonType(inner?: CompiledType): ValueShape | null {\n const kind = classifyByJsonType(inner)\n return kind ? {container: 'scalar', kind} : null\n}\n\n/** Translatable string/text/Portable-Text leaves within an object (recursing plain objects). */\nfunction collectLeaves(objType: CompiledType | undefined, prefix: string): TranslatableLeaf[] {\n const leaves: TranslatableLeaf[] = []\n for (const f of objType?.fields ?? []) {\n if (!f?.name || f.name.startsWith('_')) continue\n if (isExcludedByDocumentI18n(f) && f.type?.options?.smartcatTranslation?.include !== true) continue\n const subPath = prefix ? `${prefix}.${f.name}` : f.name\n const kind = classifyField(f.type)\n if (kind) leaves.push({subPath, kind, title: f.type?.title || titleCase(f.name)})\n else if (isPlainObject(f.type)) leaves.push(...collectLeaves(f.type, subPath))\n }\n return leaves\n}\n\n/** True for an array whose members include a Portable Text `block` type. */\nfunction isPortableTextArray(type: CompiledType): boolean {\n return (\n type.jsonType === 'array' &&\n Array.isArray(type.of) &&\n type.of.some((member) => member?.name === 'block' || member?.jsonType === 'block')\n )\n}\n\nfunction classifyField(type?: CompiledType): TranslatableField['kind'] | null {\n if (!type) return null\n\n // Plain text: only the `string` and `text` base types (not url, slug, datetime…).\n if (type.jsonType === 'string' && (type.name === 'string' || type.name === 'text')) {\n return 'plain'\n }\n\n if (isPortableTextArray(type)) return 'portableText'\n\n return null\n}\n\n/**\n * Classify by base `jsonType` only, ignoring the type name. Used for fields\n * explicitly opted in with `smartcatTranslation.include`, where the name isn't in\n * the default allowlist (e.g. a custom string alias) but the underlying shape is\n * still translatable.\n */\nfunction classifyByJsonType(type?: CompiledType): TranslatableField['kind'] | null {\n if (!type) return null\n if (type.jsonType === 'string') return 'plain'\n if (isPortableTextArray(type)) return 'portableText'\n return null\n}\n\nfunction warnUntranslatableInclude(fieldName: string, type?: CompiledType): void {\n // eslint-disable-next-line no-console\n console.warn(\n `[smartcat-translation] Field \"${fieldName}\" is marked smartcatTranslation.include ` +\n `but its type (name: ${JSON.stringify(type?.name)}, jsonType: ${JSON.stringify(type?.jsonType)}) ` +\n `has no translatable representation; skipping.`,\n )\n}\n\nfunction titleCase(name: string): string {\n return name.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())\n}\n\n/**\n * Selects translatable fields from a document's **data** (no schema needed) —\n * for use in Sanity Functions, which run server-side without the Studio schema.\n *\n * Applies the same homogeneity rule as {@link getTranslatableFields}: if any\n * field holds internationalized-array members, the document is field-mode and\n * only those fields are selected (classified by a sample member's `value`);\n * otherwise top-level `string`/Portable Text fields are selected (document-mode).\n */\nexport function inferTranslatableFields(\n doc: Record<string, unknown>,\n options: GetTranslatableFieldsOptions = {},\n): TranslatableField[] {\n if (isSystemType(doc._type)) return []\n const exclude = new Set(options.exclude ?? [])\n\n const isI18nArray = (value: unknown): value is Array<{value?: unknown}> =>\n Array.isArray(value) &&\n value.some((m) => m && typeof m === 'object' && isInternationalizedArrayMemberType((m as {_type?: unknown})._type))\n\n const entries = Object.entries(doc).filter(([key]) => !key.startsWith('_') && !exclude.has(key))\n const fieldMode = entries.some(([, value]) => isI18nArray(value))\n\n const result: TranslatableField[] = []\n for (const [key, value] of entries) {\n if (fieldMode) {\n if (!isI18nArray(value)) continue\n const sample = value.find((m) => m && typeof m === 'object')?.value\n const kind = sampleKind(sample)\n if (kind) result.push({path: key, kind, localization: 'field', title: titleCase(key)})\n } else if (typeof value === 'string') {\n if (value.length > 0) result.push({path: key, kind: 'plain', localization: 'document', title: titleCase(key)})\n } else if (Array.isArray(value) && value.some(isPortableTextBlock)) {\n result.push({path: key, kind: 'portableText', localization: 'document', title: titleCase(key)})\n }\n }\n return result\n}\n\nfunction sampleKind(value: unknown): TranslatableField['kind'] | null {\n if (typeof value === 'string') return 'plain'\n if (Array.isArray(value) && value.some(isPortableTextBlock)) return 'portableText'\n return null\n}\n\nfunction isPortableTextBlock(value: unknown): boolean {\n return Boolean(value && typeof value === 'object' && (value as {_type?: string})._type === 'block')\n}\n\n/**\n * Reorders fields to match `orderedPaths` (e.g. a schema's field order). Fields\n * not listed keep their original relative order and go last. Stable.\n */\nexport function orderFieldsBy(\n fields: TranslatableField[],\n orderedPaths: string[] | undefined,\n): TranslatableField[] {\n if (!orderedPaths || orderedPaths.length === 0) return fields\n const rank = new Map(orderedPaths.map((path, i) => [path, i]))\n const at = (path: string) => (rank.has(path) ? (rank.get(path) as number) : Number.MAX_SAFE_INTEGER)\n return fields\n .map((field, i) => ({field, i}))\n .sort((a, b) => at(a.field.path) - at(b.field.path) || a.i - b.i)\n .map((x) => x.field)\n}\n","/**\n * Tiny path grammar for addressing translatable leaves nested inside a field's\n * value: `.field` for object properties, `[key]` for array members by `_key`,\n * `[n]` for array members by index. Used to expand array/object member values\n * into per-leaf LocJSON units and to overlay translations back onto a cloned\n * source value.\n *\n * e.g. `items[b1].value`, `tabs[2]`, `` (empty = the value itself).\n */\nexport type PathSeg = {field: string} | {key: string} | {index: number}\n\nexport function parsePath(path: string): PathSeg[] {\n const segs: PathSeg[] = []\n const re = /\\.?([^.[\\]]+)|\\[([^\\]]+)\\]/g\n let m: RegExpExecArray | null\n while ((m = re.exec(path)) !== null) {\n if (m[1] !== undefined) segs.push({field: m[1]})\n else if (/^\\d+$/.test(m[2])) segs.push({index: Number(m[2])})\n else segs.push({key: m[2]})\n }\n return segs\n}\n\n/** Append a segment to a base path string, producing a valid path. */\nexport function joinPath(base: string, seg: PathSeg): string {\n if ('field' in seg) return base ? `${base}.${seg.field}` : seg.field\n if ('index' in seg) return `${base}[${seg.index}]`\n return `${base}[${seg.key}]`\n}\n\nfunction child(container: unknown, seg: PathSeg): unknown {\n if (container == null || typeof container !== 'object') return undefined\n if ('field' in seg) return (container as Record<string, unknown>)[seg.field]\n if ('index' in seg) return Array.isArray(container) ? container[seg.index] : undefined\n return Array.isArray(container)\n ? container.find((m) => m && typeof m === 'object' && (m as Record<string, unknown>)._key === seg.key)\n : undefined\n}\n\n/** Read the value at a path from a root value (undefined if any segment misses). */\nexport function getAtPath(root: unknown, path: string): unknown {\n let cur = root\n for (const seg of parsePath(path)) {\n cur = child(cur, seg)\n if (cur === undefined) return undefined\n }\n return cur\n}\n\n/**\n * Return a deep clone of `root` with the leaf at `path` replaced by `value`.\n * An empty path replaces the whole value. Missing intermediate segments are a\n * no-op (the clone is returned unchanged), so a template that lacks a leaf can't\n * be corrupted.\n */\nexport function setAtPath<T>(root: T, path: string, value: unknown): T {\n const segs = parsePath(path)\n if (segs.length === 0) return value as T\n const clone = structuredClone(root)\n let cur: unknown = clone\n for (let i = 0; i < segs.length - 1; i++) {\n cur = child(cur, segs[i])\n if (cur == null || typeof cur !== 'object') return clone\n }\n const last = segs[segs.length - 1]\n if ('field' in last) (cur as Record<string, unknown>)[last.field] = value\n else if ('index' in last && Array.isArray(cur)) cur[last.index] = value\n else if ('key' in last && Array.isArray(cur)) {\n const idx = cur.findIndex((m) => m && typeof m === 'object' && (m as Record<string, unknown>)._key === last.key)\n if (idx >= 0) cur[idx] = value\n }\n return clone\n}\n","import {toHTML} from '@portabletext/to-html'\nimport {htmlToBlocks} from '@sanity/block-tools'\n\n/** A Portable Text block (loosely typed — shape varies by schema). */\nexport type PortableTextBlock = Record<string, unknown>\n\n/** Parses an HTML string into a DOM Document. Provided by the environment. */\nexport type ParseHtml = (html: string) => Document\n\n/**\n * Renders Portable Text to an HTML string for translation.\n * Whole-field: the entire block array becomes one HTML document fragment.\n */\nexport function portableTextToHtml(blocks: PortableTextBlock[] | undefined | null): string {\n if (!blocks || blocks.length === 0) return ''\n return toHTML(blocks as never, {\n // Fallback only. Serialization skips any field containing an unserializable\n // block up front (see findUnserializableBlockTypes), so to-html should never\n // meet an unknown type here. If one slips through, render it as empty rather\n // than let @portabletext/to-html emit its default placeholder — a hidden\n // `<div style=\"display:none\">Unknown block type \"…\"</div>` whose English text\n // would otherwise be sent to Smartcat and translated into every locale.\n // (`onMissingComponent: false` only silences the warning; it does NOT stop the\n // placeholder — the `unknownType` override is what suppresses it.)\n onMissingComponent: false,\n components: {unknownType: () => ''},\n })\n}\n\n/**\n * Distinct `_type`s in a Portable Text value that `@portabletext/to-html` cannot\n * serialize: any top-level member that isn't a standard `block`, or any inline\n * child that isn't a `span` (e.g. a custom `table` block or an inline object).\n * Such nodes render to nothing, so a field containing them can't survive the\n * HTML round-trip — the caller skips the whole field and warns rather than\n * silently drop or corrupt it. Marks/annotations are intentionally not included:\n * their text survives (only the annotation would be lost).\n */\nexport function findUnserializableBlockTypes(\n blocks: PortableTextBlock[] | undefined | null,\n): string[] {\n if (!Array.isArray(blocks)) return []\n const types = new Set<string>()\n for (const block of blocks) {\n if (!block || typeof block !== 'object') continue\n const type = (block as {_type?: unknown})._type\n if (type !== 'block') {\n if (typeof type === 'string') types.add(type)\n continue\n }\n const children = (block as {children?: unknown}).children\n if (!Array.isArray(children)) continue\n for (const child of children) {\n const childType = (child as {_type?: unknown} | null)?._type\n if (typeof childType === 'string' && childType !== 'span') types.add(childType)\n }\n }\n return [...types].sort()\n}\n\n/**\n * Extracts the visible text of Portable Text — the concatenation of every span's\n * `text` across all blocks (recursing into `children`). Used to decide whether a\n * field carries anything translatable: structural-but-textless blocks render to\n * non-empty HTML (`<p></p>`, `<br/>`, `<ul><li></li></ul>`), so emptiness must be\n * judged from the source blocks, not from stripping tags/entities out of the HTML\n * (which would mishandle `&nbsp;` and void elements).\n */\nexport function portableTextToPlainText(blocks: PortableTextBlock[] | undefined | null): string {\n if (!Array.isArray(blocks)) return ''\n let text = ''\n const visit = (node: unknown): void => {\n if (Array.isArray(node)) {\n node.forEach(visit)\n } else if (node && typeof node === 'object') {\n const value = node as {text?: unknown; children?: unknown}\n if (typeof value.text === 'string') text += value.text\n if (Array.isArray(value.children)) visit(value.children)\n }\n }\n visit(blocks)\n return text\n}\n\n/**\n * Parses translated HTML back into Portable Text using the field's compiled\n * block content type. `parseHtml` supplies a DOM parser:\n * - Studio: (html) => new DOMParser().parseFromString(html, 'text/html')\n * - Functions: (html) => new JSDOM(html).window.document\n */\nexport function htmlToPortableText(\n html: string,\n blockContentType: unknown,\n parseHtml: ParseHtml,\n): PortableTextBlock[] {\n if (!html.trim()) return []\n return htmlToBlocks(html, blockContentType as never, {parseHtml}) as unknown as PortableTextBlock[]\n}\n","import {\n findUnserializableBlockTypes,\n portableTextToHtml,\n portableTextToPlainText,\n type PortableTextBlock,\n} from './portableText'\nimport {enumerateLeaves} from './fields'\nimport {getAtPath} from './paths'\nimport type {LocJSON, LocUnit, TranslatableField} from './types'\n\nexport interface SerializeOptions {\n sourceLanguage: string\n /**\n * How internationalized-array members identify their locale: a field name\n * (e.g. `'language'`) or `'_key'` (v4.x and lower). Resolved by the caller (see\n * `resolveConfig`); no default is assumed here.\n */\n fieldLanguageKey: string\n /**\n * Called when a Portable Text field is skipped because it contains block types\n * that can't be serialized (see {@link findUnserializableBlockTypes}). The\n * whole field is left out so its content isn't dropped or corrupted on the\n * round-trip; the caller surfaces this to the user's log.\n */\n onSkippedField?: (info: {fieldPath: string; title?: string; types: string[]}) => void\n}\n\n/** A minimal view of a Sanity document needed for serialization. */\nexport interface SerializableDocument {\n _id: string\n _type: string\n _rev?: string\n [key: string]: unknown\n}\n\n/**\n * Serializes a Sanity document into a LocJSON file: one unit per translatable\n * field. Plain fields become plain units (segments split on newlines); Portable\n * Text fields become whole-field HTML units (Smartcat segments them internally).\n *\n * Fields with no value are skipped, so the file only contains real source text.\n */\nexport function serializeToLocjson(\n doc: SerializableDocument,\n fields: TranslatableField[],\n options: SerializeOptions,\n): LocJSON {\n const units: LocUnit[] = []\n\n for (const field of fields) {\n // For field-level (internationalized-array) fields the source content is the\n // source-language member's `value` (the field may be nested through objects).\n const source =\n field.localization === 'field'\n ? memberValue(getAtPath(doc, field.path), options.sourceLanguage, options.fieldLanguageKey)\n : getAtPath(doc, field.path)\n\n // A scalar field yields one leaf; array/object member values yield one leaf\n // per translatable string inside (addressed by valuePath).\n for (const leaf of enumerateLeaves(field, source)) {\n const value = leaf.valuePath ? getAtPath(source, leaf.valuePath) : source\n const comments = leaf.title ? [leaf.title] : undefined\n\n if (leaf.kind === 'plain') {\n // Skip when there's no translatable text. Trim only for the check — the\n // stored source keeps its original whitespace so the round-trip is exact.\n if (typeof value !== 'string' || value.trim().length === 0) continue\n units.push({\n key: leaf.fullKey,\n properties: {comments, 'x-smartcat-split': 'full', 'x-sanity': {fieldPath: leaf.fullKey}},\n // Split into line segments but keep the trailing newline on each, so\n // concatenating the segments (LocJSON's '' join) reconstructs the original.\n source: value.split(/(?<=\\n)/),\n target: [],\n })\n } else {\n const blocks = value as PortableTextBlock[] | undefined\n // A field with a block to-html can't serialize (e.g. a custom `table`)\n // is skipped whole: partial HTML would drop that block on the round-trip.\n const unserializable = findUnserializableBlockTypes(blocks)\n if (unserializable.length > 0) {\n options.onSkippedField?.({fieldPath: field.path, title: field.title, types: unserializable})\n continue\n }\n // `html` is non-empty for structural-but-textless blocks (e.g. `<p></p>`,\n // `<br/>`), so also require actual visible text before emitting a unit.\n const html = portableTextToHtml(blocks)\n if (!html || portableTextToPlainText(blocks).trim().length === 0) continue\n units.push({\n key: leaf.fullKey,\n properties: {comments, 'x-smartcat-format': 'html', 'x-smartcat-split': 'full', 'x-sanity': {fieldPath: leaf.fullKey}},\n source: [html],\n target: [],\n })\n }\n }\n }\n\n return {\n properties: {\n version: 1,\n 'x-sanity': {\n documentId: doc._id,\n documentType: doc._type,\n rev: doc._rev,\n sourceLanguage: options.sourceLanguage,\n },\n },\n units,\n }\n}\n\n/**\n * Reads the `value` of an internationalized-array field's member for a given\n * language. Members are identified strictly by `languageKey` — a field name\n * (`'language'`, v5+) or `'_key'` (v4.x and lower).\n */\nfunction memberValue(field: unknown, language: string, languageKey: string): unknown {\n if (!Array.isArray(field)) return undefined\n const member = field.find(\n (m) => m && typeof m === 'object' && (m as Record<string, unknown>)[languageKey] === language,\n )\n return (member as {value?: unknown} | undefined)?.value\n}\n","/**\n * Builds the Smartcat document filename for a Sanity document:\n * \"<type>/<sanitized title>-<id prefix>.locjson\"\n * e.g. \"page/About Us-3f14f092.locjson\".\n *\n * The type acts as a folder; the id prefix guarantees uniqueness. Identity on\n * import comes from the file's `x-sanity` metadata, not this name, so renames\n * are safe.\n */\nexport function buildLocjsonFilename(documentType: string, documentId: string, title?: string): string {\n const idPrefix = stripDraft(documentId).slice(0, 8) || 'unknown'\n const safeTitle = sanitizeSegment(title || 'Untitled')\n const safeType = sanitizeSegment(documentType) || 'document'\n return `${safeType}/${safeTitle}-${idPrefix}.locjson`\n}\n\nfunction stripDraft(id: string): string {\n return id.replace(/^drafts\\./, '')\n}\n\n/** Removes characters unsafe for file paths while keeping it human-readable. */\nfunction sanitizeSegment(value: string): string {\n return value\n .replace(/[/\\\\?%*:|\"<>]/g, ' ') // path-unsafe characters\n .replace(/\\s+/g, ' ')\n .trim()\n}\n","import {htmlToPortableText, type ParseHtml} from './portableText'\nimport type {LocJSON} from './types'\n\nexport interface DeserializeOptions {\n /** DOM parser for HTML units (DOMParser in Studio, jsdom in Functions). */\n parseHtml: ParseHtml\n /** Returns the compiled block content type for a Portable Text field path. */\n getBlockContentType: (fieldPath: string) => unknown\n}\n\nexport interface DeserializedDocument {\n documentId?: string\n documentType?: string\n sourceLanguage?: string\n /** Translated field values, keyed by field path. */\n fields: Record<string, unknown>\n /**\n * Source (pre-translation) text per field path, as the raw joined string. Used\n * to decide whether a field is JSON (so its translation can be validated before\n * import). Always the string form, even for HTML units.\n */\n sources: Record<string, string>\n}\n\n/**\n * Parses a (translated) LocJSON file back into Sanity field values.\n *\n * Uses each unit's `target` (falling back to `source` when a unit hasn't been\n * translated yet, so the result is always coherent). HTML units are converted\n * back to Portable Text via the field's compiled block content type.\n */\nexport function deserializeFromLocjson(locjson: LocJSON, options: DeserializeOptions): DeserializedDocument {\n const fileMeta = locjson.properties?.['x-sanity']\n const fields: Record<string, unknown> = {}\n const sources: Record<string, string> = {}\n\n for (const unit of locjson.units ?? []) {\n const fieldPath = unit.properties?.['x-sanity']?.fieldPath ?? unit.key\n const text = (unit.target?.length ? unit.target : unit.source) ?? []\n\n // LocJSON splits a string into arbitrary chunks with no implied delimiter,\n // so the original is always reconstructed by concatenating the segments.\n const value = text.join('')\n sources[fieldPath] = (unit.source ?? []).join('')\n\n if (unit.properties?.['x-smartcat-format'] === 'html') {\n fields[fieldPath] = htmlToPortableText(value, options.getBlockContentType(fieldPath), options.parseHtml)\n } else {\n fields[fieldPath] = value\n }\n }\n\n return {\n documentId: fileMeta?.documentId,\n documentType: fileMeta?.documentType,\n sourceLanguage: fileMeta?.sourceLanguage,\n fields,\n sources,\n }\n}\n"],"names":["child"],"mappings":";;AA4BA,SAAS,yBAAyB,OAA+B;AAC/D,SAAO,MAAM,MAAM,SAAS,8BAA8B,YAAY;AACxE;AAgBO,SAAS,aAAa,UAA4B;AACvD,SAAO,OAAO,YAAa,YAAY,SAAS,WAAW,SAAS;AACtE;AAOO,SAAS,6BAA6B,MAA8B;AACzE,SACE,MAAM,aAAa,WACnB,OAAO,KAAK,QAAS,YACrB,KAAK,KAAK,WAAW,wBAAwB;AAEjD;AAGO,SAAS,mCAAmC,UAA4B;AAC7E,SAAO,OAAO,YAAa,YAAY,kCAAkC,KAAK,QAAQ;AACxF;AAGO,SAAS,eAAe,WAAoD;AACjF,SAAO,WAAW,KAAK,CAAC,GAAG,QAAQ,KAAK,CAAC,MAAM,EAAE,SAAS,OAAO,GAAG;AACtE;AAQO,SAAS,iBAAiB,cAAyC;AACxE,QAAM,SAAU,cAA+B;AAC/C,SAAK,MAAM,QAAQ,MAAM,KAClB,OAAO,KAAK,CAAC,MAAM,6BAA6B,EAAE,IAAI,CAAC,IAAI,UAD/B;AAErC;AAkBO,SAAS,sBACd,cACA,UAAwC,IACnB;AACrB,QAAM,WAAW;AACjB,MAAI,aAAa,UAAU,IAAI,UAAU,CAAA;AACzC,MAAI,CAAC,MAAM,QAAQ,UAAU,MAAM,UAAU,CAAA;AAC7C,QAAM,SAA8B,CAAA;AACpC,SAAA,WAAW,UAAU,IAAI,iBAAiB,QAAQ,GAAG,IAAI,IAAI,QAAQ,WAAW,CAAA,CAAE,GAAG,MAAM,GACpF;AACT;AAQA,SAAS,WACP,cACA,QACA,MACA,SACA,KACM;AACN,aAAW,SAAS,cAAc,UAAU,CAAA,GAAI;AAE9C,QAAI,CAAC,OAAO,QAAQ,MAAM,KAAK,WAAW,GAAG,KAAM,WAAW,MAAM,QAAQ,IAAI,MAAM,IAAI,EAAI;AAC9F,UAAM,eAAe,MAAM,MAAM,SAAS,qBAAqB,YAAY;AAC3E,QAAI,yBAAyB,KAAK,KAAK,CAAC,aAAc;AACtD,UAAM,OAAO,SAAS,GAAG,MAAM,IAAI,MAAM,IAAI,KAAK,MAAM,MAClD,QAAQ,MAAM,MAAM,SAAS,UAAU,MAAM,IAAI;AAEvD,QAAI,SAAS;AACX,UAAI,6BAA6B,MAAM,IAAI,GAAG;AAC5C,cAAM,QAAQ,eAAe,MAAM,IAAI,GACjC,QAAQ,mBAAmB,KAAK,MAAM,eAAe,6BAA6B,KAAK,IAAI;AAC7F,gBAAO,IAAI,KAAK,EAAC,MAAM,cAAc,SAAS,MAAM,MAAM,cAAc,WAAW,MAAM,OAAO,SAAS,OAAO,OAAO,MAAA,CAAM,IACxH,gBAAc,0BAA0B,MAAM,MAAM,KAAK;AAAA,MACpE,MAAW,eAAc,MAAM,IAAI,KACjC,WAAW,MAAM,MAAM,MAAM,MAAM,SAAS,GAAG;AAAA,SAE5C;AACL,YAAM,OAAO,cAAc,MAAM,IAAI,MAAM,eAAe,mBAAmB,MAAM,IAAI,IAAI;AACvF,aAAM,IAAI,KAAK,EAAC,MAAM,cAAc,YAAY,MAAM,OAAO,OAAO,EAAC,WAAW,UAAU,QAAM,IAC3F,gBAAc,0BAA0B,MAAM,MAAM,MAAM,IAAI;AAAA,IACzE;AAAA,EACF;AACF;AAiBO,SAAS,gBAAgB,OAA0B,aAAmC;AAC3F,QAAM,OAAO,MAAM,MACb,QAAoB,MAAM,SAAS,EAAC,WAAW,UAAU,MAAM,MAAM,KAAA;AAC3E,MAAI,MAAM,cAAc;AACtB,WAAO,CAAC,EAAC,SAAS,MAAM,WAAW,IAAI,MAAM,MAAM,MAAM,OAAO,MAAM,MAAA,CAAM;AAE9E,MAAI,MAAM,cAAc;AAEtB,YADY,MAAM,QAAQ,WAAW,IAAI,cAAc,CAAA,GAC5C,IAAI,CAAC,GAAG,OAAO,EAAC,SAAS,GAAG,IAAI,IAAI,CAAC,KAAK,WAAW,IAAI,CAAC,KAAK,MAAM,MAAM,MAAM,OAAO,MAAM,MAAA,EAAO;AAElH,MAAI,MAAM,cAAc;AACtB,WAAO,MAAM,OAAO,IAAI,CAAC,OAAO,EAAC,SAAS,GAAG,IAAI,IAAI,EAAE,OAAO,IAAI,WAAW,EAAE,SAAS,MAAM,EAAE,MAAM,OAAO,EAAE,SAAS,MAAM,MAAA,EAAO;AAEvI,QAAM,MAAM,MAAM,QAAQ,WAAW,IAAI,cAAc,CAAA,GACjD,MAAmB,CAAA;AACzB,aAAW,QAAQ,KAAK;AACtB,UAAM,MAAO,MAAkC;AAC/C,QAAI,OAAO,OAAQ;AACnB,iBAAW,KAAK,MAAM,QAAQ;AAC5B,cAAM,YAAY,EAAE,UAAU,IAAI,GAAG,KAAK,EAAE,OAAO,KAAK,IAAI,GAAG;AAC/D,YAAI,KAAK,EAAC,SAAS,GAAG,IAAI,GAAG,SAAS,IAAI,WAAW,MAAM,EAAE,MAAM,OAAO,EAAE,SAAS,MAAM,OAAM;AAAA,MACnG;AAAA,EACF;AACA,SAAO;AACT;AAGA,SAAS,cAAc,MAA8B;AAEnD,SADI,MAAM,aAAa,YAAY,CAAC,MAAM,QAAQ,KAAK,MAAM,KACzD,KAAK,OAAO,KAAK,CAAC,MAAM,EAAE,SAAS,MAAM,IAAU,KAChD,CAAC,CAAC,aAAa,SAAS,QAAQ,QAAQ,UAAU,EAAE,SAAS,KAAK,QAAQ,EAAE;AACrF;AAGA,SAAS,mBAAmB,OAAyC;AACnE,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,SAAS,cAAc,KAAK;AAClC,MAAI,OAAQ,QAAO,EAAC,WAAW,UAAU,MAAM,OAAA;AAC/C,MAAI,MAAM,aAAa,WAAW,MAAM,QAAQ,MAAM,EAAE,GAAG;AACzD,UAAM,SAAS,MAAM,GAAG,CAAC;AACzB,QAAI,QAAQ,aAAa,aAAa,OAAO,SAAS,YAAY,OAAO,SAAS;AAChF,aAAO,EAAC,WAAW,iBAAiB,MAAM,QAAA;AAE5C,QAAI,cAAc,MAAM,GAAG;AACzB,YAAM,SAAS,cAAc,QAAQ,EAAE;AACvC,UAAI,OAAO,OAAQ,QAAO,EAAC,WAAW,iBAAiB,OAAA;AAAA,IACzD;AACA,WAAO;AAAA,EACT;AACA,MAAI,cAAc,KAAK,GAAG;AACxB,UAAM,SAAS,cAAc,OAAO,EAAE;AACtC,QAAI,OAAO,OAAQ,QAAO,EAAC,WAAW,UAAU,OAAA;AAAA,EAClD;AACA,SAAO;AACT;AAGA,SAAS,6BAA6B,OAAyC;AAC7E,QAAM,OAAO,mBAAmB,KAAK;AACrC,SAAO,OAAO,EAAC,WAAW,UAAU,SAAQ;AAC9C;AAGA,SAAS,cAAc,SAAmC,QAAoC;AAC5F,QAAM,SAA6B,CAAA;AACnC,aAAW,KAAK,SAAS,UAAU,CAAA,GAAI;AAErC,QADI,CAAC,GAAG,QAAQ,EAAE,KAAK,WAAW,GAAG,KACjC,yBAAyB,CAAC,KAAK,EAAE,MAAM,SAAS,qBAAqB,YAAY,GAAM;AAC3F,UAAM,UAAU,SAAS,GAAG,MAAM,IAAI,EAAE,IAAI,KAAK,EAAE,MAC7C,OAAO,cAAc,EAAE,IAAI;AAC7B,WAAM,OAAO,KAAK,EAAC,SAAS,MAAM,OAAO,EAAE,MAAM,SAAS,UAAU,EAAE,IAAI,EAAA,CAAE,IACvE,cAAc,EAAE,IAAI,KAAG,OAAO,KAAK,GAAG,cAAc,EAAE,MAAM,OAAO,CAAC;AAAA,EAC/E;AACA,SAAO;AACT;AAGA,SAAS,oBAAoB,MAA6B;AACxD,SACE,KAAK,aAAa,WAClB,MAAM,QAAQ,KAAK,EAAE,KACrB,KAAK,GAAG,KAAK,CAAC,WAAW,QAAQ,SAAS,WAAW,QAAQ,aAAa,OAAO;AAErF;AAEA,SAAS,cAAc,MAAuD;AAC5E,SAAK,OAGD,KAAK,aAAa,aAAa,KAAK,SAAS,YAAY,KAAK,SAAS,UAClE,UAGL,oBAAoB,IAAI,IAAU,iBAE/B,OATW;AAUpB;AAQA,SAAS,mBAAmB,MAAuD;AACjF,SAAK,OACD,KAAK,aAAa,WAAiB,UACnC,oBAAoB,IAAI,IAAU,iBAC/B,OAHW;AAIpB;AAEA,SAAS,0BAA0B,WAAmB,MAA2B;AAE/E,UAAQ;AAAA,IACN,iCAAiC,SAAS,+DACjB,KAAK,UAAU,MAAM,IAAI,CAAC,eAAe,KAAK,UAAU,MAAM,QAAQ,CAAC;AAAA,EAAA;AAGpG;AAEA,SAAS,UAAU,MAAsB;AACvC,SAAO,KAAK,QAAQ,YAAY,KAAK,EAAE,QAAQ,MAAM,CAAC,MAAM,EAAE,YAAA,CAAa;AAC7E;AAWO,SAAS,wBACd,KACA,UAAwC,IACnB;AACrB,MAAI,aAAa,IAAI,KAAK,UAAU,CAAA;AACpC,QAAM,UAAU,IAAI,IAAI,QAAQ,WAAW,EAAE,GAEvC,cAAc,CAAC,UACnB,MAAM,QAAQ,KAAK,KACnB,MAAM,KAAK,CAAC,MAAM,KAAK,OAAO,KAAM,YAAY,mCAAoC,EAAwB,KAAK,CAAC,GAE9G,UAAU,OAAO,QAAQ,GAAG,EAAE,OAAO,CAAC,CAAC,GAAG,MAAM,CAAC,IAAI,WAAW,GAAG,KAAK,CAAC,QAAQ,IAAI,GAAG,CAAC,GACzF,YAAY,QAAQ,KAAK,CAAC,CAAA,EAAG,KAAK,MAAM,YAAY,KAAK,CAAC,GAE1D,SAA8B,CAAA;AACpC,aAAW,CAAC,KAAK,KAAK,KAAK;AACzB,QAAI,WAAW;AACb,UAAI,CAAC,YAAY,KAAK,EAAG;AACzB,YAAM,SAAS,MAAM,KAAK,CAAC,MAAM,KAAK,OAAO,KAAM,QAAQ,GAAG,OACxD,OAAO,WAAW,MAAM;AAC1B,cAAM,OAAO,KAAK,EAAC,MAAM,KAAK,MAAM,cAAc,SAAS,OAAO,UAAU,GAAG,GAAE;AAAA,IACvF,MAAW,QAAO,SAAU,WACtB,MAAM,SAAS,KAAG,OAAO,KAAK,EAAC,MAAM,KAAK,MAAM,SAAS,cAAc,YAAY,OAAO,UAAU,GAAG,EAAA,CAAE,IACpG,MAAM,QAAQ,KAAK,KAAK,MAAM,KAAK,mBAAmB,KAC/D,OAAO,KAAK,EAAC,MAAM,KAAK,MAAM,gBAAgB,cAAc,YAAY,OAAO,UAAU,GAAG,GAAE;AAGlG,SAAO;AACT;AAEA,SAAS,WAAW,OAAkD;AACpE,SAAI,OAAO,SAAU,WAAiB,UAClC,MAAM,QAAQ,KAAK,KAAK,MAAM,KAAK,mBAAmB,IAAU,iBAC7D;AACT;AAEA,SAAS,oBAAoB,OAAyB;AACpD,SAAO,GAAQ,SAAS,OAAO,SAAU,YAAa,MAA2B,UAAU;AAC7F;AAMO,SAAS,cACd,QACA,cACqB;AACrB,MAAI,CAAC,gBAAgB,aAAa,WAAW,EAAG,QAAO;AACvD,QAAM,OAAO,IAAI,IAAI,aAAa,IAAI,CAAC,MAAM,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,GACvD,KAAK,CAAC,SAAkB,KAAK,IAAI,IAAI,IAAK,KAAK,IAAI,IAAI,IAAe,OAAO;AACnF,SAAO,OACJ,IAAI,CAAC,OAAO,OAAO,EAAC,OAAO,EAAA,EAAG,EAC9B,KAAK,CAAC,GAAG,MAAM,GAAG,EAAE,MAAM,IAAI,IAAI,GAAG,EAAE,MAAM,IAAI,KAAK,EAAE,IAAI,EAAE,CAAC,EAC/D,IAAI,CAAC,MAAM,EAAE,KAAK;AACvB;ACvVO,SAAS,UAAU,MAAyB;AACjD,QAAM,OAAkB,IAClB,KAAK;AACX,MAAI;AACJ,UAAQ,IAAI,GAAG,KAAK,IAAI,OAAO;AACzB,MAAE,CAAC,MAAM,SAAW,KAAK,KAAK,EAAC,OAAO,EAAE,CAAC,GAAE,IACtC,QAAQ,KAAK,EAAE,CAAC,CAAC,IAAG,KAAK,KAAK,EAAC,OAAO,OAAO,EAAE,CAAC,CAAC,EAAA,CAAE,IACvD,KAAK,KAAK,EAAC,KAAK,EAAE,CAAC,GAAE;AAE5B,SAAO;AACT;AASA,SAAS,MAAM,WAAoB,KAAuB;AACxD,MAAI,EAAA,aAAa,QAAQ,OAAO,aAAc;AAC9C,WAAI,WAAW,MAAa,UAAsC,IAAI,KAAK,IACvE,WAAW,MAAY,MAAM,QAAQ,SAAS,IAAI,UAAU,IAAI,KAAK,IAAI,SACtE,MAAM,QAAQ,SAAS,IAC1B,UAAU,KAAK,CAAC,MAAM,KAAK,OAAO,KAAM,YAAa,EAA8B,SAAS,IAAI,GAAG,IACnG;AACN;AAGO,SAAS,UAAU,MAAe,MAAuB;AAC9D,MAAI,MAAM;AACV,aAAW,OAAO,UAAU,IAAI;AAE9B,QADA,MAAM,MAAM,KAAK,GAAG,GAChB,QAAQ,OAAW;AAEzB,SAAO;AACT;AAQO,SAAS,UAAa,MAAS,MAAc,OAAmB;AACrE,QAAM,OAAO,UAAU,IAAI;AAC3B,MAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,QAAM,QAAQ,gBAAgB,IAAI;AAClC,MAAI,MAAe;AACnB,WAAS,IAAI,GAAG,IAAI,KAAK,SAAS,GAAG;AAEnC,QADA,MAAM,MAAM,KAAK,KAAK,CAAC,CAAC,GACpB,OAAO,QAAQ,OAAO,OAAQ,SAAU,QAAO;AAErD,QAAM,OAAO,KAAK,KAAK,SAAS,CAAC;AACjC,MAAI,WAAW,KAAO,KAAgC,KAAK,KAAK,IAAI;AAAA,WAC3D,WAAW,QAAQ,MAAM,QAAQ,GAAG,EAAG,KAAI,KAAK,KAAK,IAAI;AAAA,WACzD,SAAS,QAAQ,MAAM,QAAQ,GAAG,GAAG;AAC5C,UAAM,MAAM,IAAI,UAAU,CAAC,MAAM,KAAK,OAAO,KAAM,YAAa,EAA8B,SAAS,KAAK,GAAG;AAC3G,WAAO,MAAG,IAAI,GAAG,IAAI;AAAA,EAC3B;AACA,SAAO;AACT;AC3DO,SAAS,mBAAmB,QAAwD;AACzF,SAAI,CAAC,UAAU,OAAO,WAAW,IAAU,KACpC,OAAO,QAAiB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAS7B,oBAAoB;AAAA,IACpB,YAAY,EAAC,aAAa,MAAM,GAAA;AAAA,EAAE,CACnC;AACH;AAWO,SAAS,6BACd,QACU;AACV,MAAI,CAAC,MAAM,QAAQ,MAAM,UAAU,CAAA;AACnC,QAAM,4BAAY,IAAA;AAClB,aAAW,SAAS,QAAQ;AAC1B,QAAI,CAAC,SAAS,OAAO,SAAU,SAAU;AACzC,UAAM,OAAQ,MAA4B;AAC1C,QAAI,SAAS,SAAS;AAChB,aAAO,QAAS,YAAU,MAAM,IAAI,IAAI;AAC5C;AAAA,IACF;AACA,UAAM,WAAY,MAA+B;AACjD,QAAK,MAAM,QAAQ,QAAQ;AAC3B,iBAAWA,UAAS,UAAU;AAC5B,cAAM,YAAaA,QAAoC;AACnD,eAAO,aAAc,YAAY,cAAc,UAAQ,MAAM,IAAI,SAAS;AAAA,MAChF;AAAA,EACF;AACA,SAAO,CAAC,GAAG,KAAK,EAAE,KAAA;AACpB;AAUO,SAAS,wBAAwB,QAAwD;AAC9F,MAAI,CAAC,MAAM,QAAQ,MAAM,EAAG,QAAO;AACnC,MAAI,OAAO;AACX,QAAM,QAAQ,CAAC,SAAwB;AACrC,QAAI,MAAM,QAAQ,IAAI;AACpB,WAAK,QAAQ,KAAK;AAAA,aACT,QAAQ,OAAO,QAAS,UAAU;AAC3C,YAAM,QAAQ;AACV,aAAO,MAAM,QAAS,aAAU,QAAQ,MAAM,OAC9C,MAAM,QAAQ,MAAM,QAAQ,KAAG,MAAM,MAAM,QAAQ;AAAA,IACzD;AAAA,EACF;AACA,SAAA,MAAM,MAAM,GACL;AACT;AAQO,SAAS,mBACd,MACA,kBACA,WACqB;AACrB,SAAK,KAAK,KAAA,IACH,aAAa,MAAM,kBAA2B,EAAC,UAAA,CAAU,IADvC,CAAA;AAE3B;ACvDO,SAAS,mBACd,KACA,QACA,SACS;AACT,QAAM,QAAmB,CAAA;AAEzB,aAAW,SAAS,QAAQ;AAG1B,UAAM,SACJ,MAAM,iBAAiB,UACnB,YAAY,UAAU,KAAK,MAAM,IAAI,GAAG,QAAQ,gBAAgB,QAAQ,gBAAgB,IACxF,UAAU,KAAK,MAAM,IAAI;AAI/B,eAAW,QAAQ,gBAAgB,OAAO,MAAM,GAAG;AACjD,YAAM,QAAQ,KAAK,YAAY,UAAU,QAAQ,KAAK,SAAS,IAAI,QAC7D,WAAW,KAAK,QAAQ,CAAC,KAAK,KAAK,IAAI;AAE7C,UAAI,KAAK,SAAS,SAAS;AAGzB,YAAI,OAAO,SAAU,YAAY,MAAM,KAAA,EAAO,WAAW,EAAG;AAC5D,cAAM,KAAK;AAAA,UACT,KAAK,KAAK;AAAA,UACV,YAAY,EAAC,UAAU,oBAAoB,QAAQ,YAAY,EAAC,WAAW,KAAK,UAAO;AAAA;AAAA;AAAA,UAGvF,QAAQ,MAAM,MAAM,IAAA,OAAC,UAAQ,CAAA;AAAA,UAC7B,QAAQ,CAAA;AAAA,QAAC,CACV;AAAA,MACH,OAAO;AACL,cAAM,SAAS,OAGT,iBAAiB,6BAA6B,MAAM;AAC1D,YAAI,eAAe,SAAS,GAAG;AAC7B,kBAAQ,iBAAiB,EAAC,WAAW,MAAM,MAAM,OAAO,MAAM,OAAO,OAAO,eAAA,CAAe;AAC3F;AAAA,QACF;AAGA,cAAM,OAAO,mBAAmB,MAAM;AACtC,YAAI,CAAC,QAAQ,wBAAwB,MAAM,EAAE,KAAA,EAAO,WAAW,EAAG;AAClE,cAAM,KAAK;AAAA,UACT,KAAK,KAAK;AAAA,UACV,YAAY,EAAC,UAAU,qBAAqB,QAAQ,oBAAoB,QAAQ,YAAY,EAAC,WAAW,KAAK,QAAA,EAAO;AAAA,UACpH,QAAQ,CAAC,IAAI;AAAA,UACb,QAAQ,CAAA;AAAA,QAAC,CACV;AAAA,MACH;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AAAA,IACL,YAAY;AAAA,MACV,SAAS;AAAA,MACT,YAAY;AAAA,QACV,YAAY,IAAI;AAAA,QAChB,cAAc,IAAI;AAAA,QAClB,KAAK,IAAI;AAAA,QACT,gBAAgB,QAAQ;AAAA,MAAA;AAAA,IAC1B;AAAA,IAEF;AAAA,EAAA;AAEJ;AAOA,SAAS,YAAY,OAAgB,UAAkB,aAA8B;AACnF,SAAK,MAAM,QAAQ,KAAK,IACT,MAAM;AAAA,IACnB,CAAC,MAAM,KAAK,OAAO,KAAM,YAAa,EAA8B,WAAW,MAAM;AAAA,EAAA,GAErC,QAJvB;AAK7B;AClHO,SAAS,qBAAqB,cAAsB,YAAoB,OAAwB;AACrG,QAAM,WAAW,WAAW,UAAU,EAAE,MAAM,GAAG,CAAC,KAAK,WACjD,YAAY,gBAAgB,SAAS,UAAU;AAErD,SAAO,GADU,gBAAgB,YAAY,KAAK,UAChC,IAAI,SAAS,IAAI,QAAQ;AAC7C;AAEA,SAAS,WAAW,IAAoB;AACtC,SAAO,GAAG,QAAQ,aAAa,EAAE;AACnC;AAGA,SAAS,gBAAgB,OAAuB;AAC9C,SAAO,MACJ,QAAQ,kBAAkB,GAAG,EAC7B,QAAQ,QAAQ,GAAG,EACnB,KAAA;AACL;ACKO,SAAS,uBAAuB,SAAkB,SAAmD;AAC1G,QAAM,WAAW,QAAQ,aAAa,UAAU,GAC1C,SAAkC,CAAA,GAClC,UAAkC,CAAA;AAExC,aAAW,QAAQ,QAAQ,SAAS,CAAA,GAAI;AACtC,UAAM,YAAY,KAAK,aAAa,UAAU,GAAG,aAAa,KAAK,KAK7D,UAJQ,KAAK,QAAQ,SAAS,KAAK,SAAS,KAAK,WAAW,CAAA,GAI/C,KAAK,EAAE;AAC1B,YAAQ,SAAS,KAAK,KAAK,UAAU,CAAA,GAAI,KAAK,EAAE,GAE5C,KAAK,aAAa,mBAAmB,MAAM,SAC7C,OAAO,SAAS,IAAI,mBAAmB,OAAO,QAAQ,oBAAoB,SAAS,GAAG,QAAQ,SAAS,IAEvG,OAAO,SAAS,IAAI;AAAA,EAExB;AAEA,SAAO;AAAA,IACL,YAAY,UAAU;AAAA,IACtB,cAAc,UAAU;AAAA,IACxB,gBAAgB,UAAU;AAAA,IAC1B;AAAA,IACA;AAAA,EAAA;AAEJ;"}
1
+ {"version":3,"file":"index2.js","sources":["../../src/lib/locjson/fields.ts","../../src/lib/locjson/paths.ts","../../src/lib/locjson/portableText.ts","../../src/lib/locjson/serialize.ts","../../src/lib/locjson/filename.ts","../../src/lib/locjson/deserialize.ts"],"sourcesContent":["import type {LocalizationMode, TranslatableField, TranslatableLeaf, ValueShape} from './types'\n\nexport interface GetTranslatableFieldsOptions {\n /**\n * Field names to exclude (e.g. the i18n language field, passed by the caller\n * as the configured `documentI18nPluginLanguageField`). Defaults to none.\n */\n exclude?: string[]\n}\n\ninterface CompiledType {\n jsonType?: string\n name?: string\n title?: string\n of?: CompiledType[]\n fields?: CompiledField[]\n options?: {\n documentInternationalization?: {exclude?: boolean}\n smartcatTranslation?: {include?: boolean}\n }\n}\n\n/**\n * True when a field is marked non-translatable via document-internationalization's\n * own field option (`options.documentInternationalization.exclude`). Honoring it\n * means consumers exclude fields per type using the i18n plugin's native marker —\n * no separate config here.\n */\nfunction isExcludedByDocumentI18n(field: CompiledField): boolean {\n return field.type?.options?.documentInternationalization?.exclude === true\n}\n\ninterface CompiledField {\n name: string\n type?: CompiledType\n}\n\n/**\n * True for a Sanity **system** type — the reserved `sanity.` namespace, notably\n * the `sanity.imageAsset` / `sanity.fileAsset` documents that image/file fields\n * reference. Their string fields (`originalFilename`, `path`, `url`, …) look\n * translatable but must never be sent: assets are shared across locales, and\n * translating a filename or CDN path corrupts the reference. Excluding them here\n * keeps them out of both the linked-document crawl and export serialization,\n * since both decide translatability via {@link getTranslatableFields}.\n */\nexport function isSystemType(typeName: unknown): boolean {\n return typeof typeName === 'string' && typeName.startsWith('sanity.')\n}\n\n/**\n * True for a `sanity-plugin-internationalized-array` array type (e.g.\n * `internationalizedArrayString`). Detected structurally by naming convention so\n * we never need a dependency on that plugin.\n */\nexport function isInternationalizedArrayType(type?: CompiledType): boolean {\n return (\n type?.jsonType === 'array' &&\n typeof type.name === 'string' &&\n type.name.startsWith('internationalizedArray')\n )\n}\n\n/** True for an array member `_type` like `internationalizedArrayStringValue`. */\nexport function isInternationalizedArrayMemberType(typeName: unknown): boolean {\n return typeof typeName === 'string' && /^internationalizedArray.+Value$/.test(typeName)\n}\n\n/** The inner value type of an internationalized-array field (its member's `value` field). */\nexport function innerValueType(arrayType?: CompiledType): CompiledType | undefined {\n return arrayType?.of?.[0]?.fields?.find((f) => f.name === 'value')?.type\n}\n\n/**\n * Localization mode of a **compiled** schema type: `field` if it declares any\n * internationalized-array field (in which case only those fields are\n * translatable), otherwise `document`. A pure function of the type, so callers\n * should compute it once per type.\n */\nexport function localizationMode(compiledType: unknown): LocalizationMode {\n const fields = (compiledType as CompiledType)?.fields\n if (!Array.isArray(fields)) return 'document'\n return fields.some((f) => isInternationalizedArrayType(f.type)) ? 'field' : 'document'\n}\n\n/**\n * Selects translatable fields from a **compiled** Sanity schema type.\n *\n * Document-mode types (default policy, installation-agnostic):\n * - `string` and `text` fields -> plain units\n * - Portable Text (arrays containing `block` members) -> HTML units\n * - everything else (slug, url, datetime, number, boolean, reference, image,\n * non-block arrays, objects, system `_` fields, excluded names) is excluded.\n *\n * Field-mode types (any internationalized-array field present): only the\n * internationalized-array fields are translatable; each is classified by its\n * inner `value` type (string/text -> plain, Portable Text -> HTML).\n *\n * The compiled schema shape isn't strongly typed publicly, so this reads it\n * defensively. Callers that need overrides can post-filter the result.\n */\nexport function getTranslatableFields(\n compiledType: unknown,\n options: GetTranslatableFieldsOptions = {},\n): TranslatableField[] {\n const rootType = compiledType as CompiledType\n if (isSystemType(rootType?.name)) return []\n if (!Array.isArray(rootType?.fields)) return []\n const result: TranslatableField[] = []\n walkFields(rootType, '', localizationMode(rootType), new Set(options.exclude ?? []), result)\n return result\n}\n\n/**\n * Recursively selects translatable fields. In field-mode it descends through\n * plain object fields (so nested internationalized-array fields, e.g.\n * `benefits.items`, are reached) and classifies each i18n field's member value\n * shape. Document-mode keeps the flat top-level behavior.\n */\nfunction walkFields(\n compiledType: CompiledType | undefined,\n prefix: string,\n mode: LocalizationMode,\n exclude: Set<string>,\n out: TranslatableField[],\n): void {\n for (const field of compiledType?.fields ?? []) {\n // `exclude` (the i18n language field) only ever appears at the top level.\n if (!field?.name || field.name.startsWith('_') || (prefix === '' && exclude.has(field.name))) continue\n const forceInclude = field.type?.options?.smartcatTranslation?.include === true\n if (isExcludedByDocumentI18n(field) && !forceInclude) continue\n const path = prefix ? `${prefix}.${field.name}` : field.name\n const title = field.type?.title || titleCase(field.name)\n\n if (mode === 'field') {\n if (isInternationalizedArrayType(field.type)) {\n const inner = innerValueType(field.type)\n const shape = classifyValueShape(inner) ?? (forceInclude ? classifyValueShapeByJsonType(inner) : null)\n if (shape) out.push({path, localization: 'field', kind: shape.container === 'scalar' ? shape.kind : 'plain', title, value: shape})\n else if (forceInclude) warnUntranslatableInclude(field.name, inner)\n } else if (isPlainObject(field.type)) {\n walkFields(field.type, path, mode, exclude, out)\n }\n } else {\n const kind = classifyField(field.type) ?? (forceInclude ? classifyByJsonType(field.type) : null)\n if (kind) out.push({path, localization: 'document', kind, title, value: {container: 'scalar', kind}})\n else if (forceInclude) warnUntranslatableInclude(field.name, field.type)\n }\n }\n}\n\n/** One translatable leaf of a field, expanded against a concrete source value. */\nexport interface FieldLeaf {\n /** Unique unit key / x-sanity fieldPath. */\n fullKey: string\n /** Path within the member value to the leaf (empty = the value itself). */\n valuePath: string\n kind: TranslatableField['kind']\n title?: string\n}\n\n/**\n * Expand a field into its translatable leaves against a concrete source value\n * (the source-language member value for field-mode). Serialize and import both\n * call this with the SAME source value, so the unit keys they compute match.\n */\nexport function enumerateLeaves(field: TranslatableField, sourceValue: unknown): FieldLeaf[] {\n const base = field.path\n const shape: ValueShape = field.value ?? {container: 'scalar', kind: field.kind}\n if (shape.container === 'scalar') {\n return [{fullKey: base, valuePath: '', kind: shape.kind, title: field.title}]\n }\n if (shape.container === 'arrayOfScalar') {\n const arr = Array.isArray(sourceValue) ? sourceValue : []\n return arr.map((_, i) => ({fullKey: `${base}[${i}]`, valuePath: `[${i}]`, kind: shape.kind, title: field.title}))\n }\n if (shape.container === 'object') {\n return shape.leaves.map((l) => ({fullKey: `${base}.${l.subPath}`, valuePath: l.subPath, kind: l.kind, title: l.title ?? field.title}))\n }\n const arr = Array.isArray(sourceValue) ? sourceValue : []\n const out: FieldLeaf[] = []\n for (const item of arr) {\n const key = (item as Record<string, unknown>)?._key\n if (typeof key !== 'string') continue\n for (const l of shape.leaves) {\n const valuePath = l.subPath ? `[${key}].${l.subPath}` : `[${key}]`\n out.push({fullKey: `${base}${valuePath}`, valuePath, kind: l.kind, title: l.title ?? field.title})\n }\n }\n return out\n}\n\n/** A recursable plain object (not a reference/image/file/slug/geopoint). */\nfunction isPlainObject(type?: CompiledType): boolean {\n if (type?.jsonType !== 'object' || !Array.isArray(type.fields)) return false\n if (type.fields.some((f) => f.name === '_ref')) return false\n return !['reference', 'image', 'file', 'slug', 'geopoint'].includes(type.name ?? '')\n}\n\n/** Classify an internationalized-array member's `value` type into a ValueShape. */\nfunction classifyValueShape(inner?: CompiledType): ValueShape | null {\n if (!inner) return null\n const scalar = classifyField(inner)\n if (scalar) return {container: 'scalar', kind: scalar}\n if (inner.jsonType === 'array' && Array.isArray(inner.of)) {\n const member = inner.of[0]\n if (member?.jsonType === 'string' && (member.name === 'string' || member.name === 'text')) {\n return {container: 'arrayOfScalar', kind: 'plain'}\n }\n if (isPlainObject(member)) {\n const leaves = collectLeaves(member, '')\n if (leaves.length) return {container: 'arrayOfObject', leaves}\n }\n return null\n }\n if (isPlainObject(inner)) {\n const leaves = collectLeaves(inner, '')\n if (leaves.length) return {container: 'object', leaves}\n }\n return null\n}\n\n/** ValueShape by jsonType only (for `smartcatTranslation.include` overrides). */\nfunction classifyValueShapeByJsonType(inner?: CompiledType): ValueShape | null {\n const kind = classifyByJsonType(inner)\n return kind ? {container: 'scalar', kind} : null\n}\n\n/** Translatable string/text/Portable-Text leaves within an object (recursing plain objects). */\nfunction collectLeaves(objType: CompiledType | undefined, prefix: string): TranslatableLeaf[] {\n const leaves: TranslatableLeaf[] = []\n for (const f of objType?.fields ?? []) {\n if (!f?.name || f.name.startsWith('_')) continue\n if (isExcludedByDocumentI18n(f) && f.type?.options?.smartcatTranslation?.include !== true) continue\n const subPath = prefix ? `${prefix}.${f.name}` : f.name\n const kind = classifyField(f.type)\n if (kind) leaves.push({subPath, kind, title: f.type?.title || titleCase(f.name)})\n else if (isPlainObject(f.type)) leaves.push(...collectLeaves(f.type, subPath))\n }\n return leaves\n}\n\n/** True for an array whose members include a Portable Text `block` type. */\nfunction isPortableTextArray(type: CompiledType): boolean {\n return (\n type.jsonType === 'array' &&\n Array.isArray(type.of) &&\n type.of.some((member) => member?.name === 'block' || member?.jsonType === 'block')\n )\n}\n\nfunction classifyField(type?: CompiledType): TranslatableField['kind'] | null {\n if (!type) return null\n\n // Plain text: only the `string` and `text` base types (not url, slug, datetime…).\n if (type.jsonType === 'string' && (type.name === 'string' || type.name === 'text')) {\n return 'plain'\n }\n\n if (isPortableTextArray(type)) return 'portableText'\n\n return null\n}\n\n/**\n * Classify by base `jsonType` only, ignoring the type name. Used for fields\n * explicitly opted in with `smartcatTranslation.include`, where the name isn't in\n * the default allowlist (e.g. a custom string alias) but the underlying shape is\n * still translatable.\n */\nfunction classifyByJsonType(type?: CompiledType): TranslatableField['kind'] | null {\n if (!type) return null\n if (type.jsonType === 'string') return 'plain'\n if (isPortableTextArray(type)) return 'portableText'\n return null\n}\n\nfunction warnUntranslatableInclude(fieldName: string, type?: CompiledType): void {\n // eslint-disable-next-line no-console\n console.warn(\n `[smartcat-translation] Field \"${fieldName}\" is marked smartcatTranslation.include ` +\n `but its type (name: ${JSON.stringify(type?.name)}, jsonType: ${JSON.stringify(type?.jsonType)}) ` +\n `has no translatable representation; skipping.`,\n )\n}\n\nfunction titleCase(name: string): string {\n return name.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())\n}\n\n/**\n * Selects translatable fields from a document's **data** (no schema needed) —\n * for use in Sanity Functions, which run server-side without the Studio schema.\n *\n * Applies the same homogeneity rule as {@link getTranslatableFields}: if any\n * field holds internationalized-array members, the document is field-mode and\n * only those fields are selected (classified by a sample member's `value`);\n * otherwise top-level `string`/Portable Text fields are selected (document-mode).\n */\nexport function inferTranslatableFields(\n doc: Record<string, unknown>,\n options: GetTranslatableFieldsOptions = {},\n): TranslatableField[] {\n if (isSystemType(doc._type)) return []\n const exclude = new Set(options.exclude ?? [])\n\n const isI18nArray = (value: unknown): value is Array<{value?: unknown}> =>\n Array.isArray(value) &&\n value.some((m) => m && typeof m === 'object' && isInternationalizedArrayMemberType((m as {_type?: unknown})._type))\n\n const entries = Object.entries(doc).filter(([key]) => !key.startsWith('_') && !exclude.has(key))\n const fieldMode = entries.some(([, value]) => isI18nArray(value))\n\n const result: TranslatableField[] = []\n for (const [key, value] of entries) {\n if (fieldMode) {\n if (!isI18nArray(value)) continue\n const sample = value.find((m) => m && typeof m === 'object')?.value\n const kind = sampleKind(sample)\n if (kind) result.push({path: key, kind, localization: 'field', title: titleCase(key)})\n } else if (typeof value === 'string') {\n if (value.length > 0) result.push({path: key, kind: 'plain', localization: 'document', title: titleCase(key)})\n } else if (Array.isArray(value) && value.some(isPortableTextBlock)) {\n result.push({path: key, kind: 'portableText', localization: 'document', title: titleCase(key)})\n }\n }\n return result\n}\n\nfunction sampleKind(value: unknown): TranslatableField['kind'] | null {\n if (typeof value === 'string') return 'plain'\n if (Array.isArray(value) && value.some(isPortableTextBlock)) return 'portableText'\n return null\n}\n\nfunction isPortableTextBlock(value: unknown): boolean {\n return Boolean(value && typeof value === 'object' && (value as {_type?: string})._type === 'block')\n}\n\n/**\n * Reorders fields to match `orderedPaths` (e.g. a schema's field order). Fields\n * not listed keep their original relative order and go last. Stable.\n */\nexport function orderFieldsBy(\n fields: TranslatableField[],\n orderedPaths: string[] | undefined,\n): TranslatableField[] {\n if (!orderedPaths || orderedPaths.length === 0) return fields\n const rank = new Map(orderedPaths.map((path, i) => [path, i]))\n const at = (path: string) => (rank.has(path) ? (rank.get(path) as number) : Number.MAX_SAFE_INTEGER)\n return fields\n .map((field, i) => ({field, i}))\n .sort((a, b) => at(a.field.path) - at(b.field.path) || a.i - b.i)\n .map((x) => x.field)\n}\n","/**\n * Tiny path grammar for addressing translatable leaves nested inside a field's\n * value: `.field` for object properties, `[key]` for array members by `_key`,\n * `[n]` for array members by index. Used to expand array/object member values\n * into per-leaf LocJSON units and to overlay translations back onto a cloned\n * source value.\n *\n * e.g. `items[b1].value`, `tabs[2]`, `` (empty = the value itself).\n */\nexport type PathSeg = {field: string} | {key: string} | {index: number}\n\nexport function parsePath(path: string): PathSeg[] {\n const segs: PathSeg[] = []\n const re = /\\.?([^.[\\]]+)|\\[([^\\]]+)\\]/g\n let m: RegExpExecArray | null\n while ((m = re.exec(path)) !== null) {\n if (m[1] !== undefined) segs.push({field: m[1]})\n else if (/^\\d+$/.test(m[2])) segs.push({index: Number(m[2])})\n else segs.push({key: m[2]})\n }\n return segs\n}\n\n/** Append a segment to a base path string, producing a valid path. */\nexport function joinPath(base: string, seg: PathSeg): string {\n if ('field' in seg) return base ? `${base}.${seg.field}` : seg.field\n if ('index' in seg) return `${base}[${seg.index}]`\n return `${base}[${seg.key}]`\n}\n\nfunction child(container: unknown, seg: PathSeg): unknown {\n if (container == null || typeof container !== 'object') return undefined\n if ('field' in seg) return (container as Record<string, unknown>)[seg.field]\n if ('index' in seg) return Array.isArray(container) ? container[seg.index] : undefined\n return Array.isArray(container)\n ? container.find((m) => m && typeof m === 'object' && (m as Record<string, unknown>)._key === seg.key)\n : undefined\n}\n\n/** Read the value at a path from a root value (undefined if any segment misses). */\nexport function getAtPath(root: unknown, path: string): unknown {\n let cur = root\n for (const seg of parsePath(path)) {\n cur = child(cur, seg)\n if (cur === undefined) return undefined\n }\n return cur\n}\n\n/**\n * Return a deep clone of `root` with the leaf at `path` replaced by `value`.\n * An empty path replaces the whole value. Missing intermediate segments are a\n * no-op (the clone is returned unchanged), so a template that lacks a leaf can't\n * be corrupted.\n */\nexport function setAtPath<T>(root: T, path: string, value: unknown): T {\n const segs = parsePath(path)\n if (segs.length === 0) return value as T\n const clone = structuredClone(root)\n let cur: unknown = clone\n for (let i = 0; i < segs.length - 1; i++) {\n cur = child(cur, segs[i])\n if (cur == null || typeof cur !== 'object') return clone\n }\n const last = segs[segs.length - 1]\n if ('field' in last) (cur as Record<string, unknown>)[last.field] = value\n else if ('index' in last && Array.isArray(cur)) cur[last.index] = value\n else if ('key' in last && Array.isArray(cur)) {\n const idx = cur.findIndex((m) => m && typeof m === 'object' && (m as Record<string, unknown>)._key === last.key)\n if (idx >= 0) cur[idx] = value\n }\n return clone\n}\n","import {escapeHTML, toHTML} from '@portabletext/to-html'\nimport {htmlToBlocks, type DeserializerRule} from '@sanity/block-tools'\n\n/** A Portable Text block (loosely typed — shape varies by schema). */\nexport type PortableTextBlock = Record<string, unknown>\n\n/**\n * Custom (non-`block`) top-level Portable Text types that we *can* round-trip\n * through HTML, and so must NOT treat as unserializable. Each needs both a\n * `toHTML` component (below) and an `htmlToBlocks` rule (see {@link tableRule})\n * so the block survives export → translation → import intact.\n *\n * Currently just `@sanity/table`: `{_type:'table', rows:[{_type:'tableRow',\n * cells: string[]}]}` — plain-string cells, no header row, no col/row spans.\n */\nconst SERIALIZABLE_CUSTOM_TYPES = new Set(['table'])\n\n/** Renders a `@sanity/table` block to an HTML `<table>` for translation. */\nfunction tableToHtml(value: unknown): string {\n const rows = Array.isArray((value as {rows?: unknown})?.rows) ? (value as {rows: unknown[]}).rows : []\n const trs = rows\n .map((row) => {\n const cells = Array.isArray((row as {cells?: unknown})?.cells) ? (row as {cells: unknown[]}).cells : []\n const tds = cells.map((cell) => `<td>${escapeHTML(typeof cell === 'string' ? cell : '')}</td>`).join('')\n return `<tr>${tds}</tr>`\n })\n .join('')\n return `<table><tbody>${trs}</tbody></table>`\n}\n\n/**\n * An `htmlToBlocks` rule that turns a translated `<table>` back into a\n * `@sanity/table` block. Rows get deterministic index-based `_key`s (the whole\n * field value is replaced on import, so keys need only be unique within the\n * table); cells are plain strings, matching the schema.\n */\nconst tableRule: DeserializerRule = {\n deserialize(el, _next, createBlock) {\n if ((el as Element).tagName?.toLowerCase() !== 'table') return undefined\n const rows = Array.from((el as Element).querySelectorAll('tr')).map((tr, i) => ({\n _type: 'tableRow',\n _key: `row${i}`,\n cells: Array.from(tr.querySelectorAll('td, th')).map((cell) => cell.textContent ?? ''),\n }))\n return createBlock({_type: 'table', rows}) as never\n },\n}\n\n/** Parses an HTML string into a DOM Document. Provided by the environment. */\nexport type ParseHtml = (html: string) => Document\n\n/**\n * Renders Portable Text to an HTML string for translation.\n * Whole-field: the entire block array becomes one HTML document fragment.\n */\nexport function portableTextToHtml(blocks: PortableTextBlock[] | undefined | null): string {\n if (!blocks || blocks.length === 0) return ''\n return toHTML(blocks as never, {\n // Fallback only. Serialization skips any field containing an unserializable\n // block up front (see findUnserializableBlockTypes), so to-html should never\n // meet an unknown type here. If one slips through, render it as empty rather\n // than let @portabletext/to-html emit its default placeholder — a hidden\n // `<div style=\"display:none\">Unknown block type \"…\"</div>` whose English text\n // would otherwise be sent to Smartcat and translated into every locale.\n // (`onMissingComponent: false` only silences the warning; it does NOT stop the\n // placeholder — the `unknownType` override is what suppresses it.)\n onMissingComponent: false,\n components: {\n unknownType: () => '',\n // Serializable custom block types get real markup so their text is\n // translatable and can be parsed back (see SERIALIZABLE_CUSTOM_TYPES).\n types: {table: ({value}) => tableToHtml(value)},\n },\n })\n}\n\n/**\n * Distinct `_type`s in a Portable Text value that `@portabletext/to-html` cannot\n * serialize: any top-level member that isn't a standard `block` (and isn't in\n * {@link SERIALIZABLE_CUSTOM_TYPES}, e.g. `table`, which we render explicitly),\n * or any inline child that isn't a `span` (e.g. an inline object). Such nodes\n * render to nothing, so a field containing them can't survive the HTML\n * round-trip — the caller skips the whole field and warns rather than silently\n * drop or corrupt it. Marks/annotations are intentionally not included: their\n * text survives (only the annotation would be lost).\n */\nexport function findUnserializableBlockTypes(\n blocks: PortableTextBlock[] | undefined | null,\n): string[] {\n if (!Array.isArray(blocks)) return []\n const types = new Set<string>()\n for (const block of blocks) {\n if (!block || typeof block !== 'object') continue\n const type = (block as {_type?: unknown})._type\n if (type !== 'block') {\n if (typeof type === 'string' && !SERIALIZABLE_CUSTOM_TYPES.has(type)) types.add(type)\n continue\n }\n const children = (block as {children?: unknown}).children\n if (!Array.isArray(children)) continue\n for (const child of children) {\n const childType = (child as {_type?: unknown} | null)?._type\n if (typeof childType === 'string' && childType !== 'span') types.add(childType)\n }\n }\n return [...types].sort()\n}\n\n/**\n * Extracts the visible text of Portable Text — the concatenation of every span's\n * `text` across all blocks (recursing into `children`). Used to decide whether a\n * field carries anything translatable: structural-but-textless blocks render to\n * non-empty HTML (`<p></p>`, `<br/>`, `<ul><li></li></ul>`), so emptiness must be\n * judged from the source blocks, not from stripping tags/entities out of the HTML\n * (which would mishandle `&nbsp;` and void elements).\n */\nexport function portableTextToPlainText(blocks: PortableTextBlock[] | undefined | null): string {\n if (!Array.isArray(blocks)) return ''\n let text = ''\n const visit = (node: unknown): void => {\n if (Array.isArray(node)) {\n node.forEach(visit)\n } else if (node && typeof node === 'object') {\n const value = node as {text?: unknown; children?: unknown; rows?: unknown}\n if (typeof value.text === 'string') text += value.text\n if (Array.isArray(value.children)) visit(value.children)\n // `table` blocks carry text in `rows[].cells[]` (plain strings), not spans;\n // count it so a table-only field isn't judged empty and skipped on export.\n if (Array.isArray(value.rows)) {\n for (const row of value.rows) {\n const cells = (row as {cells?: unknown})?.cells\n if (Array.isArray(cells)) for (const cell of cells) if (typeof cell === 'string') text += cell\n }\n }\n }\n }\n visit(blocks)\n return text\n}\n\n/**\n * Parses translated HTML back into Portable Text using the field's compiled\n * block content type. `parseHtml` supplies a DOM parser:\n * - Studio: (html) => new DOMParser().parseFromString(html, 'text/html')\n * - Functions: (html) => new JSDOM(html).window.document\n */\nexport function htmlToPortableText(\n html: string,\n blockContentType: unknown,\n parseHtml: ParseHtml,\n): PortableTextBlock[] {\n if (!html.trim()) return []\n return htmlToBlocks(html, blockContentType as never, {\n parseHtml,\n rules: [tableRule],\n }) as unknown as PortableTextBlock[]\n}\n","import {\n findUnserializableBlockTypes,\n portableTextToHtml,\n portableTextToPlainText,\n type PortableTextBlock,\n} from './portableText'\nimport {enumerateLeaves} from './fields'\nimport {getAtPath} from './paths'\nimport type {LocJSON, LocUnit, TranslatableField} from './types'\n\nexport interface SerializeOptions {\n sourceLanguage: string\n /**\n * How internationalized-array members identify their locale: a field name\n * (e.g. `'language'`) or `'_key'` (v4.x and lower). Resolved by the caller (see\n * `resolveConfig`); no default is assumed here.\n */\n fieldLanguageKey: string\n /**\n * Called when a Portable Text field is skipped because it contains block types\n * that can't be serialized (see {@link findUnserializableBlockTypes}). The\n * whole field is left out so its content isn't dropped or corrupted on the\n * round-trip; the caller surfaces this to the user's log.\n */\n onSkippedField?: (info: {fieldPath: string; title?: string; types: string[]}) => void\n}\n\n/** A minimal view of a Sanity document needed for serialization. */\nexport interface SerializableDocument {\n _id: string\n _type: string\n _rev?: string\n [key: string]: unknown\n}\n\n/**\n * Serializes a Sanity document into a LocJSON file: one unit per translatable\n * field. Plain fields become plain units (segments split on newlines); Portable\n * Text fields become whole-field HTML units (Smartcat segments them internally).\n *\n * Fields with no value are skipped, so the file only contains real source text.\n */\nexport function serializeToLocjson(\n doc: SerializableDocument,\n fields: TranslatableField[],\n options: SerializeOptions,\n): LocJSON {\n const units: LocUnit[] = []\n\n for (const field of fields) {\n // For field-level (internationalized-array) fields the source content is the\n // source-language member's `value` (the field may be nested through objects).\n const source =\n field.localization === 'field'\n ? memberValue(getAtPath(doc, field.path), options.sourceLanguage, options.fieldLanguageKey)\n : getAtPath(doc, field.path)\n\n // A scalar field yields one leaf; array/object member values yield one leaf\n // per translatable string inside (addressed by valuePath).\n for (const leaf of enumerateLeaves(field, source)) {\n const value = leaf.valuePath ? getAtPath(source, leaf.valuePath) : source\n const comments = leaf.title ? [leaf.title] : undefined\n\n if (leaf.kind === 'plain') {\n // Skip when there's no translatable text. Trim only for the check — the\n // stored source keeps its original whitespace so the round-trip is exact.\n if (typeof value !== 'string' || value.trim().length === 0) continue\n units.push({\n key: leaf.fullKey,\n properties: {comments, 'x-smartcat-split': 'full', 'x-sanity': {fieldPath: leaf.fullKey}},\n // Split into line segments but keep the trailing newline on each, so\n // concatenating the segments (LocJSON's '' join) reconstructs the original.\n source: value.split(/(?<=\\n)/),\n target: [],\n })\n } else {\n const blocks = value as PortableTextBlock[] | undefined\n // A field with a block to-html can't serialize (e.g. a custom `table`)\n // is skipped whole: partial HTML would drop that block on the round-trip.\n const unserializable = findUnserializableBlockTypes(blocks)\n if (unserializable.length > 0) {\n options.onSkippedField?.({fieldPath: field.path, title: field.title, types: unserializable})\n continue\n }\n // `html` is non-empty for structural-but-textless blocks (e.g. `<p></p>`,\n // `<br/>`), so also require actual visible text before emitting a unit.\n const html = portableTextToHtml(blocks)\n if (!html || portableTextToPlainText(blocks).trim().length === 0) continue\n units.push({\n key: leaf.fullKey,\n properties: {comments, 'x-smartcat-format': 'html', 'x-smartcat-split': 'full', 'x-sanity': {fieldPath: leaf.fullKey}},\n source: [html],\n target: [],\n })\n }\n }\n }\n\n return {\n properties: {\n version: 1,\n 'x-sanity': {\n documentId: doc._id,\n documentType: doc._type,\n rev: doc._rev,\n sourceLanguage: options.sourceLanguage,\n },\n },\n units,\n }\n}\n\n/**\n * Reads the `value` of an internationalized-array field's member for a given\n * language. Members are identified strictly by `languageKey` — a field name\n * (`'language'`, v5+) or `'_key'` (v4.x and lower).\n */\nfunction memberValue(field: unknown, language: string, languageKey: string): unknown {\n if (!Array.isArray(field)) return undefined\n const member = field.find(\n (m) => m && typeof m === 'object' && (m as Record<string, unknown>)[languageKey] === language,\n )\n return (member as {value?: unknown} | undefined)?.value\n}\n","/**\n * Builds the Smartcat document filename for a Sanity document:\n * \"<type>/<sanitized title>-<id prefix>.locjson\"\n * e.g. \"page/About Us-3f14f092.locjson\".\n *\n * The type acts as a folder; the id prefix guarantees uniqueness. Identity on\n * import comes from the file's `x-sanity` metadata, not this name, so renames\n * are safe. When the exported content came from the document's draft, a `-draft`\n * marker is appended so it's visible in Smartcat (`…-3f14f092-draft.locjson`).\n */\nexport function buildLocjsonFilename(\n documentType: string,\n documentId: string,\n title?: string,\n options: {draft?: boolean} = {},\n): string {\n const idPrefix = stripDraft(documentId).slice(0, 8) || 'unknown'\n const safeTitle = sanitizeSegment(title || 'Untitled')\n const safeType = sanitizeSegment(documentType) || 'document'\n const draftMarker = options.draft ? '-draft' : ''\n return `${safeType}/${safeTitle}-${idPrefix}${draftMarker}.locjson`\n}\n\nfunction stripDraft(id: string): string {\n return id.replace(/^drafts\\./, '')\n}\n\n/** Removes characters unsafe for file paths while keeping it human-readable. */\nfunction sanitizeSegment(value: string): string {\n return value\n .replace(/[/\\\\?%*:|\"<>]/g, ' ') // path-unsafe characters\n .replace(/\\s+/g, ' ')\n .trim()\n}\n","import {htmlToPortableText, type ParseHtml} from './portableText'\nimport type {LocJSON} from './types'\n\nexport interface DeserializeOptions {\n /** DOM parser for HTML units (DOMParser in Studio, jsdom in Functions). */\n parseHtml: ParseHtml\n /** Returns the compiled block content type for a Portable Text field path. */\n getBlockContentType: (fieldPath: string) => unknown\n}\n\nexport interface DeserializedDocument {\n documentId?: string\n documentType?: string\n sourceLanguage?: string\n /** Translated field values, keyed by field path. */\n fields: Record<string, unknown>\n /**\n * Source (pre-translation) text per field path, as the raw joined string. Used\n * to decide whether a field is JSON (so its translation can be validated before\n * import). Always the string form, even for HTML units.\n */\n sources: Record<string, string>\n}\n\n/**\n * Parses a (translated) LocJSON file back into Sanity field values.\n *\n * Uses each unit's `target` (falling back to `source` when a unit hasn't been\n * translated yet, so the result is always coherent). HTML units are converted\n * back to Portable Text via the field's compiled block content type.\n */\nexport function deserializeFromLocjson(locjson: LocJSON, options: DeserializeOptions): DeserializedDocument {\n const fileMeta = locjson.properties?.['x-sanity']\n const fields: Record<string, unknown> = {}\n const sources: Record<string, string> = {}\n\n for (const unit of locjson.units ?? []) {\n const fieldPath = unit.properties?.['x-sanity']?.fieldPath ?? unit.key\n const text = (unit.target?.length ? unit.target : unit.source) ?? []\n\n // LocJSON splits a string into arbitrary chunks with no implied delimiter,\n // so the original is always reconstructed by concatenating the segments.\n const value = text.join('')\n sources[fieldPath] = (unit.source ?? []).join('')\n\n if (unit.properties?.['x-smartcat-format'] === 'html') {\n fields[fieldPath] = htmlToPortableText(value, options.getBlockContentType(fieldPath), options.parseHtml)\n } else {\n fields[fieldPath] = value\n }\n }\n\n return {\n documentId: fileMeta?.documentId,\n documentType: fileMeta?.documentType,\n sourceLanguage: fileMeta?.sourceLanguage,\n fields,\n sources,\n }\n}\n"],"names":["child"],"mappings":"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"}
package/dist/import.cjs CHANGED
@@ -8,7 +8,7 @@ const DEFAULT_BATCH_SIZE = 50, DEFAULT_MAX_INBOX_BYTES = 15e5, DEFAULT_CONCURREN
8
8
  _id,
9
9
  smartcatProjectId,
10
10
  smartcatLanguages[]{sanityId, smartcatLanguage},
11
- "items": items[]{ "doc": ${progress.itemDocDeref("{_id, title}")} },
11
+ "items": items[]{ "_id": ${progress.ITEM_ID}, "title": ${progress.itemDocDerefRespectingDraft(".title")} },
12
12
  progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}
13
13
  }`;
14
14
  function stripDraft(id) {
@@ -1 +1 @@
1
- {"version":3,"file":"import.cjs","sources":["../src/import/index.ts"],"sourcesContent":["import type {SmartcatDocument, SmartcatProject} from '../smartcat/types'\nimport {buildProgress, hasConfirmedContent, itemsFromProject, type DocProgress} from '../progress/core'\nimport {itemDocDeref} from '../lib/projectItems'\nimport type {SmartcatLanguageMapping} from '../lib/languageMap'\nimport type {LogLine} from '../lib/log'\n\n/** The Smartcat document's full path (folder + filename), extension stripped. */\nfunction smartcatDocPath(scDoc: SmartcatDocument | undefined): string {\n const raw = scDoc?.fullPath || scDoc?.name || scDoc?.filename || ''\n return raw.replace(/\\.[^./]+$/, '')\n}\n\n/** Minimal structural subset of @sanity/client used by the import download. */\nexport interface SanityLikeClient {\n fetch<T = unknown>(query: string, params?: Record<string, unknown>): Promise<T>\n patch(id: string): {\n set(attrs: Record<string, unknown>): {commit(): Promise<unknown>}\n }\n}\n\n/** Structural subset of SmartcatClient the import download needs. */\nexport interface SmartcatImportClient {\n getProject(projectId: string): Promise<SmartcatProject>\n exportDocument(documentId: string): Promise<string>\n /**\n * Batch export: one Smartcat task for many documents (ZIP result). Optional\n * so custom/legacy clients keep working — without it every document falls\n * back to a single {@link exportDocument} round-trip.\n */\n exportDocumentsBatch?(documentIds: string[]): Promise<{filename: string; content: string}[]>\n}\n\nexport interface RunImportDownloadOptions {\n sanity: SanityLikeClient\n smartcat: SmartcatImportClient\n /** `_id` of the `smartcat.translationProject` to import. */\n projectId: string\n now?: () => string\n /** Documents per Smartcat batch-export task. */\n batchSize?: number\n /**\n * Soft cap on the total LocJSON bytes stored in the project's `inbox` per\n * run. The inbox lives on the Sanity project document, which has a practical\n * size limit — targets beyond the cap are reported as `remaining` and picked\n * up by the next import run.\n */\n maxInboxBytes?: number\n /** How many batch-export tasks run against Smartcat concurrently. */\n concurrency?: number\n /**\n * Soft time budget for the download phase, in ms. Once exceeded, batches not\n * yet started are deferred as `remaining` and the run exits cleanly — so the\n * Function always finishes well before its hard platform timeout instead of\n * being killed mid-run (which would leave the project stuck in \"importing\").\n */\n timeBudgetMs?: number\n /** Clock override for tests (ms). */\n nowMs?: () => number\n /**\n * Receives every log line as it is emitted (in addition to the functionLog\n * stored on the project document) — the Function forwards these to console\n * so `sanity functions logs` shows the run without reading the document.\n */\n onLog?: (line: LogLine) => void\n}\n\nexport interface RunImportDownloadResult {\n /** Number of complete targets downloaded into the inbox. */\n downloaded: number\n /** Number of targets skipped because they are not yet fully translated. */\n skipped: number\n /** Targets ready to download but deferred to the next run (inbox size cap). */\n remaining: number\n /** Targets already imported in a previous run and therefore not re-downloaded. */\n alreadyImported: number\n}\n\nconst DEFAULT_BATCH_SIZE = 50\n/** Keeps the project document comfortably below Sanity's document size limits. */\nconst DEFAULT_MAX_INBOX_BYTES = 1_500_000\n/**\n * Parallel Smartcat export tasks. High enough to divide the download time ~4×,\n * low enough not to crowd the account's export-task queue.\n */\nconst DEFAULT_CONCURRENCY = 4\n/** Exit-early margin against the 15-minute Sanity Function timeout. */\nconst DEFAULT_TIME_BUDGET_MS = 10 * 60_000\n\nconst PROJECT_QUERY = `*[_id == $id][0]{\n _id,\n smartcatProjectId,\n smartcatLanguages[]{sanityId, smartcatLanguage},\n \"items\": items[]{ \"doc\": ${itemDocDeref('{_id, title}')} },\n progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}\n}`\n\ninterface ProjectData {\n _id: string\n smartcatProjectId?: string\n smartcatLanguages?: SmartcatLanguageMapping[]\n items?: {doc: {_id: string; title?: string} | null}[]\n progress?: DocProgress[]\n}\n\ninterface InboxItem {\n _key: string\n sourceDocId: string\n targetLanguage: string\n smartcatDocumentId: string\n locjson: string\n}\n\n/** A (document × language) target selected for download this run. */\ninterface ReadyTarget {\n key: string\n sourceDocId: string\n language: string\n smartcatDocumentId: string\n label: string\n}\n\nfunction stripDraft(id: string): string {\n return id.replace(/^drafts\\./, '')\n}\n\n/** The Sanity source-document id a LocJSON carries in its `x-sanity` metadata. */\nfunction sanityDocIdOf(locjson: string): string | undefined {\n try {\n const parsed = JSON.parse(locjson) as {properties?: {'x-sanity'?: {documentId?: string}}}\n return parsed.properties?.['x-sanity']?.documentId\n } catch {\n return undefined\n }\n}\n\ninterface BatchDownloadResult {\n files: {target: ReadyTarget; locjson: string}[]\n /** Targets fetched one-by-one because the batched call failed or its archive missed them. */\n fallbackCount: number\n /** The batched export call itself failed (the error line is already emitted). */\n batchFailed: boolean\n}\n\n/**\n * Downloads a batch of same-language targets. Uses one batched export task\n * (ZIP) when the client supports it, correlating each returned file back to\n * its target via the LocJSON's own `x-sanity.documentId`. Targets the archive\n * didn't cover — and the whole batch when the batched call fails — fall back\n * to per-document exports, so a batch problem degrades to the old (slow but\n * proven) path instead of failing the run. Every degradation is logged loudly:\n * a silent fallback looks exactly like \"batching deployed but nothing faster\".\n */\nasync function downloadBatch(\n smartcat: SmartcatImportClient,\n batch: ReadyTarget[],\n emit: (line: LogLine) => void,\n): Promise<BatchDownloadResult> {\n const contentByTarget = new Map<ReadyTarget, string>()\n const batchAttempted = Boolean(smartcat.exportDocumentsBatch && batch.length > 1)\n let batchFailed = false\n\n if (batchAttempted) {\n try {\n const files = await smartcat.exportDocumentsBatch!(batch.map((t) => t.smartcatDocumentId))\n const targetByDocId = new Map(batch.map((t) => [stripDraft(t.sourceDocId), t]))\n for (const file of files) {\n const docId = sanityDocIdOf(file.content)\n const target = docId ? targetByDocId.get(stripDraft(docId)) : undefined\n if (target && !contentByTarget.has(target)) contentByTarget.set(target, file.content)\n }\n if (contentByTarget.size < batch.length) {\n emit({\n level: 'info',\n message: `${batch.length - contentByTarget.size} of ${batch.length} file(s) missing from the batch archive — fetching them per-document`,\n })\n }\n } catch (err) {\n batchFailed = true\n emit({\n level: 'error',\n message: `Batch export of ${batch.length} document(s) failed — falling back to per-document export: ${err instanceof Error ? err.message : String(err)}`,\n })\n }\n }\n\n const files: {target: ReadyTarget; locjson: string}[] = []\n let fallbackCount = 0\n for (const target of batch) {\n let locjson = contentByTarget.get(target)\n if (locjson === undefined) {\n locjson = await smartcat.exportDocument(target.smartcatDocumentId)\n if (batchAttempted) fallbackCount++\n }\n files.push({target, locjson})\n }\n return {files, fallbackCount, batchFailed}\n}\n\n/**\n * Thin import step run by a Sanity Function: refreshes per-stage progress from\n * Smartcat, then downloads the ready targets into the project's `inbox` and\n * sets status to `downloaded`. The browser turns the inbox into locale\n * variants and decides final completion.\n *\n * Downloads happen in **batches** (one Smartcat export task per ~50 documents,\n * returned as a ZIP), so run time no longer scales with per-document polling —\n * large projects fit comfortably inside the Function timeout. The inbox is\n * size-capped per run; overflow targets are reported as `remaining` and the\n * next import run picks them up (already-imported targets are skipped).\n *\n * Targets with nothing confirmed are skipped, so no untranslated variants are\n * created. Does no content processing, so it depends only on the Smartcat client.\n */\nexport async function runImportDownload(\n options: RunImportDownloadOptions,\n): Promise<RunImportDownloadResult> {\n const {\n sanity,\n smartcat,\n projectId,\n now = () => new Date().toISOString(),\n batchSize = DEFAULT_BATCH_SIZE,\n maxInboxBytes = DEFAULT_MAX_INBOX_BYTES,\n concurrency = DEFAULT_CONCURRENCY,\n timeBudgetMs = DEFAULT_TIME_BUDGET_MS,\n nowMs = Date.now,\n onLog,\n } = options\n const log: LogLine[] = []\n const emit = (line: LogLine) => {\n log.push(line)\n onLog?.(line)\n }\n\n const project = await sanity.fetch<ProjectData | null>(PROJECT_QUERY, {id: projectId})\n if (!project) throw new Error(`Translation project ${projectId} not found`)\n\n try {\n if (!project.smartcatProjectId) {\n throw new Error('Project has no smartcatProjectId — export it first')\n }\n\n const timestamp = now()\n const scProject = await smartcat.getProject(project.smartcatProjectId)\n emit({level: 'info', message: 'Checking Smartcat for completed translations'})\n // Look up each target's Smartcat document by its id, for full-path logging.\n const scDocById = new Map((scProject.documents ?? []).filter((d) => d.id).map((d) => [d.id, d]))\n\n // Rebuild progress from Smartcat + the project's items, so the dashboard\n // reflects the latest state even for targets we don't import this round.\n const progress = buildProgress(\n itemsFromProject(project),\n scProject,\n project.progress,\n timestamp,\n project.smartcatLanguages,\n )\n\n // Collect every target that has confirmed content (at any stage); targets\n // with nothing confirmed are skipped. Targets already imported in an\n // earlier run are not re-downloaded, so capped runs make forward progress.\n const ready: ReadyTarget[] = []\n let skipped = 0\n let alreadyImported = 0\n for (const docProgress of progress) {\n for (const target of docProgress.targets) {\n const id = target.smartcatDocumentId\n // Prefer the Smartcat full path; fall back to the resolved title / source id.\n const label =\n (id && smartcatDocPath(scDocById.get(id))) || docProgress.title || docProgress.sourceDocId\n if (!id || !hasConfirmedContent(target.stages)) {\n if (id && !target.imported) {\n skipped++\n emit({level: 'skip', message: `${label} (Smartcat ID: ${id}) → ${target.language}: not ready, skipped`})\n }\n continue\n }\n if (target.imported) {\n alreadyImported++\n continue\n }\n ready.push({\n key: target._key,\n sourceDocId: docProgress.sourceDocId,\n language: target.language,\n smartcatDocumentId: id,\n label,\n })\n }\n }\n\n // Download in batches: one Smartcat export task per batch (ZIP result)\n // instead of a request+poll round-trip per document. Batches are grouped\n // by language so each source document appears at most once per archive.\n const inbox: InboxItem[] = []\n let inboxBytes = 0\n let remaining = 0\n const byLanguage = new Map<string, ReadyTarget[]>()\n for (const target of ready) {\n const group = byLanguage.get(target.language) ?? []\n group.push(target)\n byLanguage.set(target.language, group)\n }\n\n const batches: ReadyTarget[][] = []\n for (const group of byLanguage.values()) {\n for (let i = 0; i < group.length; i += batchSize) {\n batches.push(group.slice(i, i + batchSize))\n }\n }\n\n // Worker pool: up to `concurrency` batch-export tasks in flight at once —\n // Smartcat assembles the archives in parallel, dividing the download time.\n // Each worker re-checks the caps before claiming the next batch:\n // - inbox byte cap: the inbox lives on the project document, which must\n // stay below Sanity's document size limit;\n // - time budget: exit cleanly before the platform kills the Function, so a\n // slow Smartcat day degrades to \"continue in the next run\", never to a\n // project stuck in \"importing\".\n const effectiveConcurrency = Math.max(1, Math.min(concurrency, batches.length))\n if (batches.length > 0) {\n emit({\n level: 'info',\n message: `Downloading ${ready.length} target(s) in ${batches.length} batch(es) of up to ${batchSize}, ${effectiveConcurrency} in parallel`,\n })\n }\n const startedAtMs = nowMs()\n let deferredByBytes = 0\n let deferredByTime = 0\n let fallbackDownloads = 0\n let failedBatches = 0\n let nextBatch = 0\n const worker = async () => {\n while (nextBatch < batches.length) {\n const index = nextBatch++\n const batch = batches[index]\n if (inboxBytes >= maxInboxBytes) {\n deferredByBytes += batch.length\n continue\n }\n if (nowMs() - startedAtMs >= timeBudgetMs) {\n deferredByTime += batch.length\n continue\n }\n const batchStartMs = nowMs()\n const {files, fallbackCount, batchFailed} = await downloadBatch(smartcat, batch, emit)\n fallbackDownloads += fallbackCount\n if (batchFailed) failedBatches++\n for (const {target, locjson} of files) {\n inbox.push({\n _key: target.key,\n sourceDocId: target.sourceDocId,\n targetLanguage: target.language,\n smartcatDocumentId: target.smartcatDocumentId,\n locjson,\n })\n inboxBytes += locjson.length\n }\n emit({\n level: 'success',\n message:\n `Batch ${index + 1}/${batches.length} (${batch[0].language}): ${files.length} doc(s) in ` +\n `${((nowMs() - batchStartMs) / 1000).toFixed(1)}s` +\n `${fallbackCount ? ` — ${fallbackCount} via per-document fallback` : ''}`,\n })\n }\n }\n await Promise.all(Array.from({length: effectiveConcurrency}, worker))\n remaining = deferredByBytes + deferredByTime\n\n // One line that answers \"where did the time go and why was anything left\n // behind\" — the silent-degradation postmortems start here.\n if (ready.length > 0) {\n emit({\n level: failedBatches || fallbackDownloads ? 'error' : 'info',\n message:\n `Download phase: ${((nowMs() - startedAtMs) / 1000).toFixed(1)}s — ` +\n `${batches.length} batch(es), ${failedBatches} failed, ${fallbackDownloads} per-document fallback download(s); ` +\n `deferred ${deferredByBytes} (inbox size cap) + ${deferredByTime} (time budget)`,\n })\n }\n\n emit({\n level: 'info',\n message:\n `Downloaded ${inbox.length} target(s)` +\n `${skipped ? `, skipped ${skipped} not ready` : ''}` +\n `${alreadyImported ? `, ${alreadyImported} already imported` : ''}` +\n `${remaining ? `, ${remaining} deferred — run Import again to continue` : ''}`,\n })\n\n await sanity\n .patch(projectId)\n .set({\n inbox,\n progress,\n progressSyncedAt: timestamp,\n status: 'downloaded',\n // Deferred-target count for the dashboard: after applying this batch it\n // auto-continues the import instead of asking for another click.\n importRemaining: remaining,\n lastError: null,\n functionLog: JSON.stringify(log),\n })\n .commit()\n\n return {downloaded: inbox.length, skipped, remaining, alreadyImported}\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n emit({level: 'error', message})\n await sanity\n .patch(projectId)\n .set({status: 'error', lastError: message, lastImportAt: now(), functionLog: JSON.stringify(log)})\n .commit()\n .catch(() => {})\n throw err\n }\n}\n"],"names":["itemDocDeref","files","progress","buildProgress","itemsFromProject","hasConfirmedContent"],"mappings":"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"}
1
+ {"version":3,"file":"import.cjs","sources":["../src/import/index.ts"],"sourcesContent":["import type {SmartcatDocument, SmartcatProject} from '../smartcat/types'\nimport {buildProgress, hasConfirmedContent, itemsFromProject, type DocProgress} from '../progress/core'\nimport {ITEM_ID, itemDocDerefRespectingDraft} from '../lib/projectItems'\nimport type {SmartcatLanguageMapping} from '../lib/languageMap'\nimport type {LogLine} from '../lib/log'\n\n/** The Smartcat document's full path (folder + filename), extension stripped. */\nfunction smartcatDocPath(scDoc: SmartcatDocument | undefined): string {\n const raw = scDoc?.fullPath || scDoc?.name || scDoc?.filename || ''\n return raw.replace(/\\.[^./]+$/, '')\n}\n\n/** Minimal structural subset of @sanity/client used by the import download. */\nexport interface SanityLikeClient {\n fetch<T = unknown>(query: string, params?: Record<string, unknown>): Promise<T>\n patch(id: string): {\n set(attrs: Record<string, unknown>): {commit(): Promise<unknown>}\n }\n}\n\n/** Structural subset of SmartcatClient the import download needs. */\nexport interface SmartcatImportClient {\n getProject(projectId: string): Promise<SmartcatProject>\n exportDocument(documentId: string): Promise<string>\n /**\n * Batch export: one Smartcat task for many documents (ZIP result). Optional\n * so custom/legacy clients keep working — without it every document falls\n * back to a single {@link exportDocument} round-trip.\n */\n exportDocumentsBatch?(documentIds: string[]): Promise<{filename: string; content: string}[]>\n}\n\nexport interface RunImportDownloadOptions {\n sanity: SanityLikeClient\n smartcat: SmartcatImportClient\n /** `_id` of the `smartcat.translationProject` to import. */\n projectId: string\n now?: () => string\n /** Documents per Smartcat batch-export task. */\n batchSize?: number\n /**\n * Soft cap on the total LocJSON bytes stored in the project's `inbox` per\n * run. The inbox lives on the Sanity project document, which has a practical\n * size limit — targets beyond the cap are reported as `remaining` and picked\n * up by the next import run.\n */\n maxInboxBytes?: number\n /** How many batch-export tasks run against Smartcat concurrently. */\n concurrency?: number\n /**\n * Soft time budget for the download phase, in ms. Once exceeded, batches not\n * yet started are deferred as `remaining` and the run exits cleanly — so the\n * Function always finishes well before its hard platform timeout instead of\n * being killed mid-run (which would leave the project stuck in \"importing\").\n */\n timeBudgetMs?: number\n /** Clock override for tests (ms). */\n nowMs?: () => number\n /**\n * Receives every log line as it is emitted (in addition to the functionLog\n * stored on the project document) — the Function forwards these to console\n * so `sanity functions logs` shows the run without reading the document.\n */\n onLog?: (line: LogLine) => void\n}\n\nexport interface RunImportDownloadResult {\n /** Number of complete targets downloaded into the inbox. */\n downloaded: number\n /** Number of targets skipped because they are not yet fully translated. */\n skipped: number\n /** Targets ready to download but deferred to the next run (inbox size cap). */\n remaining: number\n /** Targets already imported in a previous run and therefore not re-downloaded. */\n alreadyImported: number\n}\n\nconst DEFAULT_BATCH_SIZE = 50\n/** Keeps the project document comfortably below Sanity's document size limits. */\nconst DEFAULT_MAX_INBOX_BYTES = 1_500_000\n/**\n * Parallel Smartcat export tasks. High enough to divide the download time ~4×,\n * low enough not to crowd the account's export-task queue.\n */\nconst DEFAULT_CONCURRENCY = 4\n/** Exit-early margin against the 15-minute Sanity Function timeout. */\nconst DEFAULT_TIME_BUDGET_MS = 10 * 60_000\n\nconst PROJECT_QUERY = `*[_id == $id][0]{\n _id,\n smartcatProjectId,\n smartcatLanguages[]{sanityId, smartcatLanguage},\n \"items\": items[]{ \"_id\": ${ITEM_ID}, \"title\": ${itemDocDerefRespectingDraft('.title')} },\n progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}\n}`\n\ninterface ProjectData {\n _id: string\n smartcatProjectId?: string\n smartcatLanguages?: SmartcatLanguageMapping[]\n items?: {_id?: string; title?: string}[]\n progress?: DocProgress[]\n}\n\ninterface InboxItem {\n _key: string\n sourceDocId: string\n targetLanguage: string\n smartcatDocumentId: string\n locjson: string\n}\n\n/** A (document × language) target selected for download this run. */\ninterface ReadyTarget {\n key: string\n sourceDocId: string\n language: string\n smartcatDocumentId: string\n label: string\n}\n\nfunction stripDraft(id: string): string {\n return id.replace(/^drafts\\./, '')\n}\n\n/** The Sanity source-document id a LocJSON carries in its `x-sanity` metadata. */\nfunction sanityDocIdOf(locjson: string): string | undefined {\n try {\n const parsed = JSON.parse(locjson) as {properties?: {'x-sanity'?: {documentId?: string}}}\n return parsed.properties?.['x-sanity']?.documentId\n } catch {\n return undefined\n }\n}\n\ninterface BatchDownloadResult {\n files: {target: ReadyTarget; locjson: string}[]\n /** Targets fetched one-by-one because the batched call failed or its archive missed them. */\n fallbackCount: number\n /** The batched export call itself failed (the error line is already emitted). */\n batchFailed: boolean\n}\n\n/**\n * Downloads a batch of same-language targets. Uses one batched export task\n * (ZIP) when the client supports it, correlating each returned file back to\n * its target via the LocJSON's own `x-sanity.documentId`. Targets the archive\n * didn't cover — and the whole batch when the batched call fails — fall back\n * to per-document exports, so a batch problem degrades to the old (slow but\n * proven) path instead of failing the run. Every degradation is logged loudly:\n * a silent fallback looks exactly like \"batching deployed but nothing faster\".\n */\nasync function downloadBatch(\n smartcat: SmartcatImportClient,\n batch: ReadyTarget[],\n emit: (line: LogLine) => void,\n): Promise<BatchDownloadResult> {\n const contentByTarget = new Map<ReadyTarget, string>()\n const batchAttempted = Boolean(smartcat.exportDocumentsBatch && batch.length > 1)\n let batchFailed = false\n\n if (batchAttempted) {\n try {\n const files = await smartcat.exportDocumentsBatch!(batch.map((t) => t.smartcatDocumentId))\n const targetByDocId = new Map(batch.map((t) => [stripDraft(t.sourceDocId), t]))\n for (const file of files) {\n const docId = sanityDocIdOf(file.content)\n const target = docId ? targetByDocId.get(stripDraft(docId)) : undefined\n if (target && !contentByTarget.has(target)) contentByTarget.set(target, file.content)\n }\n if (contentByTarget.size < batch.length) {\n emit({\n level: 'info',\n message: `${batch.length - contentByTarget.size} of ${batch.length} file(s) missing from the batch archive — fetching them per-document`,\n })\n }\n } catch (err) {\n batchFailed = true\n emit({\n level: 'error',\n message: `Batch export of ${batch.length} document(s) failed — falling back to per-document export: ${err instanceof Error ? err.message : String(err)}`,\n })\n }\n }\n\n const files: {target: ReadyTarget; locjson: string}[] = []\n let fallbackCount = 0\n for (const target of batch) {\n let locjson = contentByTarget.get(target)\n if (locjson === undefined) {\n locjson = await smartcat.exportDocument(target.smartcatDocumentId)\n if (batchAttempted) fallbackCount++\n }\n files.push({target, locjson})\n }\n return {files, fallbackCount, batchFailed}\n}\n\n/**\n * Thin import step run by a Sanity Function: refreshes per-stage progress from\n * Smartcat, then downloads the ready targets into the project's `inbox` and\n * sets status to `downloaded`. The browser turns the inbox into locale\n * variants and decides final completion.\n *\n * Downloads happen in **batches** (one Smartcat export task per ~50 documents,\n * returned as a ZIP), so run time no longer scales with per-document polling —\n * large projects fit comfortably inside the Function timeout. The inbox is\n * size-capped per run; overflow targets are reported as `remaining` and the\n * next import run picks them up (already-imported targets are skipped).\n *\n * Targets with nothing confirmed are skipped, so no untranslated variants are\n * created. Does no content processing, so it depends only on the Smartcat client.\n */\nexport async function runImportDownload(\n options: RunImportDownloadOptions,\n): Promise<RunImportDownloadResult> {\n const {\n sanity,\n smartcat,\n projectId,\n now = () => new Date().toISOString(),\n batchSize = DEFAULT_BATCH_SIZE,\n maxInboxBytes = DEFAULT_MAX_INBOX_BYTES,\n concurrency = DEFAULT_CONCURRENCY,\n timeBudgetMs = DEFAULT_TIME_BUDGET_MS,\n nowMs = Date.now,\n onLog,\n } = options\n const log: LogLine[] = []\n const emit = (line: LogLine) => {\n log.push(line)\n onLog?.(line)\n }\n\n const project = await sanity.fetch<ProjectData | null>(PROJECT_QUERY, {id: projectId})\n if (!project) throw new Error(`Translation project ${projectId} not found`)\n\n try {\n if (!project.smartcatProjectId) {\n throw new Error('Project has no smartcatProjectId — export it first')\n }\n\n const timestamp = now()\n const scProject = await smartcat.getProject(project.smartcatProjectId)\n emit({level: 'info', message: 'Checking Smartcat for completed translations'})\n // Look up each target's Smartcat document by its id, for full-path logging.\n const scDocById = new Map((scProject.documents ?? []).filter((d) => d.id).map((d) => [d.id, d]))\n\n // Rebuild progress from Smartcat + the project's items, so the dashboard\n // reflects the latest state even for targets we don't import this round.\n const progress = buildProgress(\n itemsFromProject(project),\n scProject,\n project.progress,\n timestamp,\n project.smartcatLanguages,\n )\n\n // Collect every target that has confirmed content (at any stage); targets\n // with nothing confirmed are skipped. Targets already imported in an\n // earlier run are not re-downloaded, so capped runs make forward progress.\n const ready: ReadyTarget[] = []\n let skipped = 0\n let alreadyImported = 0\n for (const docProgress of progress) {\n for (const target of docProgress.targets) {\n const id = target.smartcatDocumentId\n // Prefer the Smartcat full path; fall back to the resolved title / source id.\n const label =\n (id && smartcatDocPath(scDocById.get(id))) || docProgress.title || docProgress.sourceDocId\n if (!id || !hasConfirmedContent(target.stages)) {\n if (id && !target.imported) {\n skipped++\n emit({level: 'skip', message: `${label} (Smartcat ID: ${id}) → ${target.language}: not ready, skipped`})\n }\n continue\n }\n if (target.imported) {\n alreadyImported++\n continue\n }\n ready.push({\n key: target._key,\n sourceDocId: docProgress.sourceDocId,\n language: target.language,\n smartcatDocumentId: id,\n label,\n })\n }\n }\n\n // Download in batches: one Smartcat export task per batch (ZIP result)\n // instead of a request+poll round-trip per document. Batches are grouped\n // by language so each source document appears at most once per archive.\n const inbox: InboxItem[] = []\n let inboxBytes = 0\n let remaining = 0\n const byLanguage = new Map<string, ReadyTarget[]>()\n for (const target of ready) {\n const group = byLanguage.get(target.language) ?? []\n group.push(target)\n byLanguage.set(target.language, group)\n }\n\n const batches: ReadyTarget[][] = []\n for (const group of byLanguage.values()) {\n for (let i = 0; i < group.length; i += batchSize) {\n batches.push(group.slice(i, i + batchSize))\n }\n }\n\n // Worker pool: up to `concurrency` batch-export tasks in flight at once —\n // Smartcat assembles the archives in parallel, dividing the download time.\n // Each worker re-checks the caps before claiming the next batch:\n // - inbox byte cap: the inbox lives on the project document, which must\n // stay below Sanity's document size limit;\n // - time budget: exit cleanly before the platform kills the Function, so a\n // slow Smartcat day degrades to \"continue in the next run\", never to a\n // project stuck in \"importing\".\n const effectiveConcurrency = Math.max(1, Math.min(concurrency, batches.length))\n if (batches.length > 0) {\n emit({\n level: 'info',\n message: `Downloading ${ready.length} target(s) in ${batches.length} batch(es) of up to ${batchSize}, ${effectiveConcurrency} in parallel`,\n })\n }\n const startedAtMs = nowMs()\n let deferredByBytes = 0\n let deferredByTime = 0\n let fallbackDownloads = 0\n let failedBatches = 0\n let nextBatch = 0\n const worker = async () => {\n while (nextBatch < batches.length) {\n const index = nextBatch++\n const batch = batches[index]\n if (inboxBytes >= maxInboxBytes) {\n deferredByBytes += batch.length\n continue\n }\n if (nowMs() - startedAtMs >= timeBudgetMs) {\n deferredByTime += batch.length\n continue\n }\n const batchStartMs = nowMs()\n const {files, fallbackCount, batchFailed} = await downloadBatch(smartcat, batch, emit)\n fallbackDownloads += fallbackCount\n if (batchFailed) failedBatches++\n for (const {target, locjson} of files) {\n inbox.push({\n _key: target.key,\n sourceDocId: target.sourceDocId,\n targetLanguage: target.language,\n smartcatDocumentId: target.smartcatDocumentId,\n locjson,\n })\n inboxBytes += locjson.length\n }\n emit({\n level: 'success',\n message:\n `Batch ${index + 1}/${batches.length} (${batch[0].language}): ${files.length} doc(s) in ` +\n `${((nowMs() - batchStartMs) / 1000).toFixed(1)}s` +\n `${fallbackCount ? ` — ${fallbackCount} via per-document fallback` : ''}`,\n })\n }\n }\n await Promise.all(Array.from({length: effectiveConcurrency}, worker))\n remaining = deferredByBytes + deferredByTime\n\n // One line that answers \"where did the time go and why was anything left\n // behind\" — the silent-degradation postmortems start here.\n if (ready.length > 0) {\n emit({\n level: failedBatches || fallbackDownloads ? 'error' : 'info',\n message:\n `Download phase: ${((nowMs() - startedAtMs) / 1000).toFixed(1)}s — ` +\n `${batches.length} batch(es), ${failedBatches} failed, ${fallbackDownloads} per-document fallback download(s); ` +\n `deferred ${deferredByBytes} (inbox size cap) + ${deferredByTime} (time budget)`,\n })\n }\n\n emit({\n level: 'info',\n message:\n `Downloaded ${inbox.length} target(s)` +\n `${skipped ? `, skipped ${skipped} not ready` : ''}` +\n `${alreadyImported ? `, ${alreadyImported} already imported` : ''}` +\n `${remaining ? `, ${remaining} deferred — run Import again to continue` : ''}`,\n })\n\n await sanity\n .patch(projectId)\n .set({\n inbox,\n progress,\n progressSyncedAt: timestamp,\n status: 'downloaded',\n // Deferred-target count for the dashboard: after applying this batch it\n // auto-continues the import instead of asking for another click.\n importRemaining: remaining,\n lastError: null,\n functionLog: JSON.stringify(log),\n })\n .commit()\n\n return {downloaded: inbox.length, skipped, remaining, alreadyImported}\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n emit({level: 'error', message})\n await sanity\n .patch(projectId)\n .set({status: 'error', lastError: message, lastImportAt: now(), functionLog: JSON.stringify(log)})\n .commit()\n .catch(() => {})\n throw err\n }\n}\n"],"names":["ITEM_ID","itemDocDerefRespectingDraft","files","progress","buildProgress","itemsFromProject","hasConfirmedContent"],"mappings":"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"}
package/dist/import.js CHANGED
@@ -1,4 +1,4 @@
1
- import { buildProgress, itemsFromProject, hasConfirmedContent, itemDocDeref } from "./_chunks-es/index.js";
1
+ import { ITEM_ID, buildProgress, itemsFromProject, hasConfirmedContent, itemDocDerefRespectingDraft } from "./_chunks-es/index.js";
2
2
  function smartcatDocPath(scDoc) {
3
3
  return (scDoc?.fullPath || scDoc?.name || scDoc?.filename || "").replace(/\.[^./]+$/, "");
4
4
  }
@@ -6,7 +6,7 @@ const DEFAULT_BATCH_SIZE = 50, DEFAULT_MAX_INBOX_BYTES = 15e5, DEFAULT_CONCURREN
6
6
  _id,
7
7
  smartcatProjectId,
8
8
  smartcatLanguages[]{sanityId, smartcatLanguage},
9
- "items": items[]{ "doc": ${itemDocDeref("{_id, title}")} },
9
+ "items": items[]{ "_id": ${ITEM_ID}, "title": ${itemDocDerefRespectingDraft(".title")} },
10
10
  progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}
11
11
  }`;
12
12
  function stripDraft(id) {