@smartcat/sanity-plugin 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
- import { defineType, defineField, defineArrayMember, useClient, prepareForPreview, useSchema, useRelativeTime, useValuePreview, useWorkspace, useFormValue, definePlugin } from "sanity";
1
+ import { defineType, defineField, defineArrayMember, useClient, prepareForPreview, useSchema, usePerspective, useRelativeTime, useValuePreview, useWorkspace, useFormValue, definePlugin } from "sanity";
2
2
  import { TranslateIcon, CogIcon, AddIcon, LaunchIcon, CheckmarkIcon, CopyIcon, SyncIcon, ArrowLeftIcon, TrashIcon, PublishIcon, DownloadIcon, CloseIcon, RemoveCircleIcon, JsonIcon } from "@sanity/icons";
3
3
  import { TRANSLATION_PROJECT_TYPE, SETTINGS_TYPE, API_VERSION, SETTINGS_DOC_ID, requireSourceLanguage } from "./_chunks-es/constants.js";
4
4
  import { jsxs, jsx, Fragment } from "react/jsx-runtime";
5
5
  import { useState, useRef, useEffect, useCallback, useMemo, useLayoutEffect, Fragment as Fragment$1 } from "react";
6
- import { Select, useToast, Flex, Spinner, Stack, Label, TextInput, Card, Text, Switch, Button, Badge, Heading, Dialog, Box, Inline, useTheme_v2, useTheme, Checkbox, Container } from "@sanity/ui";
6
+ import { Select, useToast, Flex, Spinner, Stack, Label, TextInput, Card, Text, Badge, Switch, Button, Heading, Dialog, Box, Inline, useTheme_v2, useTheme, Checkbox, Container } from "@sanity/ui";
7
7
  import { uuid } from "@sanity/uuid";
8
8
  import { TEMPLATE_PREFIX, STANDARD_WORKFLOW_OPTIONS, isWorkflowValid } from "./_chunks-es/workflow.js";
9
- import { ITEM_ID, ITEM_ID_FROM_SUBQUERY, itemDocDeref, summarizeCompletion, buildLanguageMappings, findDuplicateSmartcatLanguages, targetPercent } from "./_chunks-es/index.js";
10
- import { getTranslatableFields, deserializeFromLocjson, serializeToLocjson, buildLocjsonFilename, localizationMode, getAtPath, enumerateLeaves, setAtPath, isInternationalizedArrayType, innerValueType } from "./_chunks-es/index2.js";
9
+ import { ITEM_ID, ITEM_ID_FROM_SUBQUERY, itemDocDeref, summarizeCompletion, buildLanguageMappings, findDuplicateSmartcatLanguages, itemDocDerefRespectingDraft, targetPercent } from "./_chunks-es/index.js";
10
+ import { getTranslatableFields, deserializeFromLocjson, serializeToLocjson, getAtPath, buildLocjsonFilename, localizationMode, enumerateLeaves, setAtPath, isInternationalizedArrayType, innerValueType } from "./_chunks-es/index2.js";
11
11
  import { useIntentLink, useRouter, useRouterState, route } from "sanity/router";
12
12
  import styled, { keyframes, css } from "styled-components";
13
13
  const STATUS_OPTIONS = [
@@ -89,7 +89,11 @@ function createTranslationProjectType() {
89
89
  title: "Project item",
90
90
  fields: [
91
91
  defineField({ name: "docId", title: "Document ID", type: "string" }),
92
- defineField({ name: "docType", title: "Document type", type: "string" })
92
+ defineField({ name: "docType", title: "Document type", type: "string" }),
93
+ // Set when the item was added from the published perspective, so the
94
+ // export translates the published document. Absent/false (incl. all
95
+ // legacy items) prefers the draft — see itemDocDerefRespectingDraft.
96
+ defineField({ name: "sourceIsPublished", title: "Source is published", type: "boolean" })
93
97
  ],
94
98
  preview: { select: { title: "docId", subtitle: "docType" } }
95
99
  })
@@ -435,7 +439,7 @@ const PROJECTS_QUERY$1 = `*[_type == $type] | order(name asc){
435
439
  function createAddToProjectAction(config) {
436
440
  const sourceLanguage = config.sourceLanguage;
437
441
  return (props) => {
438
- const { id, draft, published, onComplete } = props, client = useClient({ apiVersion: API_VERSION }), schema = useSchema(), toast = useToast(), [open, setOpen] = useState(!1), [projects, setProjects] = useState(null), [selection, setSelection] = useState(CREATE_NEW), [newName, setNewName] = useState(""), [busy, setBusy] = useState(!1), [includeLinked, setIncludeLinked] = useState(!0), { templates, loaded, requestRefresh } = useTemplates(), [workflow, setWorkflow] = useWorkflowSelection(open, templates, loaded), publishedId2 = id.replace(/^drafts\./, ""), doc = draft ?? published, docTitle = doc && resolveDocumentTitle(schema, doc) || UNTITLED;
442
+ const { id, draft, published, onComplete } = props, client = useClient({ apiVersion: API_VERSION }), schema = useSchema(), toast = useToast(), { selectedPerspectiveName } = usePerspective(), sourceIsPublished = selectedPerspectiveName === "published" || !draft, [open, setOpen] = useState(!1), [projects, setProjects] = useState(null), [selection, setSelection] = useState(CREATE_NEW), [newName, setNewName] = useState(""), [busy, setBusy] = useState(!1), [includeLinked, setIncludeLinked] = useState(!0), { templates, loaded, requestRefresh } = useTemplates(), [workflow, setWorkflow] = useWorkflowSelection(open, templates, loaded), publishedId2 = id.replace(/^drafts\./, ""), doc = draft ?? published, docTitle = doc && resolveDocumentTitle(schema, doc) || UNTITLED;
439
443
  useEffect(() => {
440
444
  if (!open) return;
441
445
  requestRefresh();
@@ -457,7 +461,8 @@ function createAddToProjectAction(config) {
457
461
  _type: "smartcat.projectItem",
458
462
  _key: uuid(),
459
463
  docId,
460
- docType
464
+ docType,
465
+ sourceIsPublished
461
466
  }), collected = /* @__PURE__ */ new Map([[publishedId2, doc?._type]]);
462
467
  if (includeLinked) {
463
468
  const linked = await gatherLinkedDocuments({ client, rootId: publishedId2, isRoot: (type) => config.rootTypes.includes(type), isTranslatable: (type) => getTranslatableFields(schema.get(type), { exclude: [config.documentI18nLanguageField] }).length > 0 });
@@ -499,7 +504,7 @@ function createAddToProjectAction(config) {
499
504
  } finally {
500
505
  setBusy(!1);
501
506
  }
502
- }, [selection, newName, workflow, publishedId2, doc, includeLinked, schema, client, projects, close]), content = useMemo(() => projects === null ? /* @__PURE__ */ jsx(Flex, { align: "center", justify: "center", padding: 4, children: /* @__PURE__ */ jsx(Spinner, { muted: !0 }) }) : /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
507
+ }, [selection, newName, workflow, publishedId2, doc, includeLinked, sourceIsPublished, schema, client, projects, close]), content = useMemo(() => projects === null ? /* @__PURE__ */ jsx(Flex, { align: "center", justify: "center", padding: 4, children: /* @__PURE__ */ jsx(Spinner, { muted: !0 }) }) : /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
503
508
  /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
504
509
  /* @__PURE__ */ jsx(Label, { size: 1, children: "Project" }),
505
510
  /* @__PURE__ */ jsxs(
@@ -550,7 +555,20 @@ function createAddToProjectAction(config) {
550
555
  ] }),
551
556
  /* @__PURE__ */ jsx(Card, { padding: 3, radius: 2, tone: "transparent", border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
552
557
  /* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
553
- "Adding ",
558
+ "Adding",
559
+ " ",
560
+ /* @__PURE__ */ jsx(
561
+ Badge,
562
+ {
563
+ tone: sourceIsPublished ? "positive" : "caution",
564
+ fontSize: 1,
565
+ paddingX: 2,
566
+ style: { verticalAlign: "middle" },
567
+ children: sourceIsPublished ? "Published" : "Draft"
568
+ }
569
+ ),
570
+ " ",
571
+ "version of ",
554
572
  /* @__PURE__ */ jsx("strong", { children: docTitle }),
555
573
  " to the selected project."
556
574
  ] }),
@@ -583,7 +601,7 @@ function createAddToProjectAction(config) {
583
601
  }
584
602
  )
585
603
  ] })
586
- ] }), [projects, selection, newName, workflow, templates, busy, includeLinked, docTitle, close, handleConfirm]);
604
+ ] }), [projects, selection, newName, workflow, templates, busy, includeLinked, docTitle, sourceIsPublished, close, handleConfirm]);
587
605
  return {
588
606
  label: "Add to translation project",
589
607
  icon: TranslateIcon,
@@ -619,6 +637,7 @@ const PROJECTS_QUERY = `*[_type == "${TRANSLATION_PROJECT_TYPE}"] | order(_creat
619
637
  items[]{
620
638
  _key,
621
639
  "docId": ${ITEM_ID},
640
+ sourceIsPublished,
622
641
  "doc": ${itemDocDeref("{_id, _type}")},
623
642
  "translations": *[_type == "translation.metadata" && references(${ITEM_ID_FROM_SUBQUERY})][0]
624
643
  .translations[]{language, "id": value._ref}
@@ -794,6 +813,16 @@ function valuePreview(value) {
794
813
  function errorMessage(err) {
795
814
  return err instanceof Error ? err.message : String(err);
796
815
  }
816
+ function memberLocalesWithContent(members, languageKey) {
817
+ if (!Array.isArray(members)) return [];
818
+ const locales = [];
819
+ for (const member of members) {
820
+ if (!member || typeof member != "object") continue;
821
+ const locale = member[languageKey], value = member.value, hasContent = typeof value == "string" ? value.trim().length > 0 : Array.isArray(value) ? value.length > 0 : value != null;
822
+ typeof locale == "string" && hasContent && locales.push(locale);
823
+ }
824
+ return locales;
825
+ }
797
826
  function schemaFieldNames(schema, type) {
798
827
  return (schema.get(type)?.fields ?? []).map((f) => f?.name).filter((n) => typeof n == "string" && !n.startsWith("_"));
799
828
  }
@@ -801,7 +830,14 @@ function blockContentTypeFor(schema, type, fieldPath) {
801
830
  const fieldType = schema.get(type)?.fields?.find((f) => f.name === fieldPath)?.type;
802
831
  return isInternationalizedArrayType(fieldType) ? innerValueType(fieldType) : fieldType;
803
832
  }
804
- const ITEMS_QUERY = `*[_id == $id][0]{ sourceLanguage, items[]{ "doc": ${itemDocDeref()} } }`;
833
+ const ITEMS_QUERY = `*[_id == $id][0]{
834
+ sourceLanguage,
835
+ items[]{
836
+ "docId": ${ITEM_ID},
837
+ "docType": docType,
838
+ "doc": ${itemDocDerefRespectingDraft()}
839
+ }
840
+ }`;
805
841
  async function prepareExport({
806
842
  client,
807
843
  schema,
@@ -809,6 +845,7 @@ async function prepareExport({
809
845
  targetLanguages,
810
846
  sourceLanguage,
811
847
  languages,
848
+ translatableTypes,
812
849
  exclude,
813
850
  fieldLanguageKey,
814
851
  deletions = [],
@@ -822,15 +859,22 @@ async function prepareExport({
822
859
  const detail = dups.map((d) => `${d.sanityIds.join(", ")} \u2192 ${d.smartcatLanguage}`).join("; ");
823
860
  throw new Error(`Multiple target languages map to the same Smartcat language: ${detail}`);
824
861
  }
825
- const deletedDocIds = new Set(deletions.map((d) => d.sourceDocId)), docs = (project?.items ?? []).map((i) => i?.doc).filter((d) => !!d).filter((d) => !deletedDocIds.has(d._id)), fieldsByType = /* @__PURE__ */ new Map(), fieldsFor = (type) => {
862
+ const deletedDocIds = new Set(deletions.map((d) => d.sourceDocId)), items = (project?.items ?? []).filter(
863
+ (item) => !(item.docId && deletedDocIds.has(item.docId))
864
+ ), unresolved = items.filter((item) => !item.doc), docs = items.map((item) => item.doc).filter((d) => !!d), fieldsByType = /* @__PURE__ */ new Map(), fieldsFor = (type) => {
826
865
  let fields = fieldsByType.get(type);
827
866
  return fields || (fields = getTranslatableFields(schema.get(type), { exclude }), fieldsByType.set(type, fields)), fields;
828
- };
829
- log({ level: "info", message: `Export \u2014 ${docs.length} document(s) \u2192 ${targetLanguages.join(", ") || "(no targets)"}` });
867
+ }, configuredLanguages = languages.map((l) => l.id).join(", ") || "(none)", configuredTypes = translatableTypes === void 0 ? "all types" : translatableTypes.join(", ") || "(none)";
868
+ log({ level: "info", message: `Config \u2014 source language: ${sourceLanguage}` }), log({ level: "info", indent: !0, message: `languages: ${configuredLanguages}` }), log({ level: "info", indent: !0, message: `translatable types: ${configuredTypes}` }), log({ level: "info", message: `Project \u2014 source: ${source} \u2192 targets: ${targetLanguages.join(", ") || "(no targets)"}` }), log({ level: "info", message: `Export \u2014 ${docs.length} document(s)` });
869
+ for (const item of unresolved)
870
+ log({
871
+ level: "error",
872
+ message: `${item.docId ?? "unknown document"} (${item.docType ?? "unknown type"}): no document found \u2014 it may be unpublished or deleted; skipped`
873
+ });
830
874
  const outbox = [];
831
- for (const doc of docs) {
832
- const name = resolveDocumentTitle(schema, doc);
833
- log({ level: "info", message: `${name || doc._id} \xB7 ${doc._type}` });
875
+ for (const rawDoc of docs) {
876
+ const isDraft = rawDoc._id.startsWith("drafts."), doc = isDraft ? { ...rawDoc, _id: publishedId(rawDoc._id) } : rawDoc, name = resolveDocumentTitle(schema, doc);
877
+ log({ level: "info", message: `${name || doc._id} \xB7 ${doc._type}${isDraft ? " (draft)" : ""}` });
834
878
  try {
835
879
  const fields = fieldsFor(doc._type), skippedBlockTypes = /* @__PURE__ */ new Map(), locjson = serializeToLocjson(doc, fields, {
836
880
  sourceLanguage: source,
@@ -839,11 +883,18 @@ async function prepareExport({
839
883
  }), unitByKey = new Map(locjson.units.map((u) => [u.key, u]));
840
884
  for (const field of fields) {
841
885
  const unit = unitByKey.get(field.path), skippedTypes = skippedBlockTypes.get(field.path);
842
- log(unit ? { level: "success", indent: !0, message: `${field.title || field.path}: serialized (${field.kind})`, value: unit.source.join("") } : skippedTypes ? {
843
- level: "error",
844
- indent: !0,
845
- message: `${field.title || field.path}: not translated \u2014 contains block type(s) that can't be translated: ${skippedTypes.join(", ")}. The whole field was left untranslated to avoid losing this content.`
846
- } : { level: "skip", indent: !0, message: `${field.title || field.path}: skipped \u2014 no source content` });
886
+ if (unit)
887
+ log({ level: "success", indent: !0, message: `${field.title || field.path}: serialized (${field.kind})`, value: unit.source.join("") });
888
+ else if (skippedTypes)
889
+ log({
890
+ level: "error",
891
+ indent: !0,
892
+ message: `${field.title || field.path}: not translated \u2014 contains block type(s) that can't be translated: ${skippedTypes.join(", ")}. The whole field was left untranslated to avoid losing this content.`
893
+ });
894
+ else {
895
+ const otherLocales = field.localization === "field" ? memberLocalesWithContent(getAtPath(doc, field.path), fieldLanguageKey).filter((l) => l !== source) : [], reason = otherLocales.length ? `no '${source}' content (I see only: ${otherLocales.join(", ")})` : "no source content";
896
+ log({ level: "skip", indent: !0, message: `${field.title || field.path}: skipped \u2014 ${reason}` });
897
+ }
847
898
  }
848
899
  const selected = new Set(fields.map((f) => f.path)), excluded = new Set(exclude ?? []);
849
900
  for (const fieldName of schemaFieldNames(schema, doc._type))
@@ -860,7 +911,7 @@ async function prepareExport({
860
911
  _key: uuid(),
861
912
  sourceDocId: doc._id,
862
913
  title: name,
863
- filename: buildLocjsonFilename(doc._type, doc._id, name),
914
+ filename: buildLocjsonFilename(doc._type, doc._id, name, { draft: isDraft }),
864
915
  locjson: JSON.stringify(locjson)
865
916
  });
866
917
  } catch (err) {
@@ -1418,20 +1469,23 @@ const CodeArea = styled.textarea`
1418
1469
  tab-size: 2;
1419
1470
  `;
1420
1471
  function ViewLocjsonButton(props) {
1421
- const { docId, type, client, schema, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField } = props, [open, setOpen] = useState(!1), [content, setContent] = useState(null), [filename, setFilename] = useState(null), [error, setError] = useState(null), build = useCallback(async () => {
1472
+ const { docId, type, sourceIsPublished, client, schema, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField } = props, [open, setOpen] = useState(!1), [content, setContent] = useState(null), [filename, setFilename] = useState(null), [error, setError] = useState(null), build = useCallback(async () => {
1422
1473
  setOpen(!0), setContent(null), setFilename(null), setError(null);
1423
1474
  try {
1424
- const doc = await client.fetch("*[_id == $id][0]", { id: docId });
1425
- if (!doc) throw new Error(`Document not found: ${docId}`);
1426
- const fields = getTranslatableFields(schema.get(type), { exclude: [documentI18nLanguageField] }), locjson = serializeToLocjson(doc, fields, {
1475
+ const raw = await client.fetch(
1476
+ sourceIsPublished ? "*[_id == $id][0]" : 'coalesce(*[_id == "drafts." + $id][0], *[_id == $id][0])',
1477
+ { id: docId }
1478
+ );
1479
+ if (!raw) throw new Error(`Document not found: ${docId}`);
1480
+ const isDraft = typeof raw._id == "string" && raw._id.startsWith("drafts."), doc = isDraft ? { ...raw, _id: docId } : raw, fields = getTranslatableFields(schema.get(type), { exclude: [documentI18nLanguageField] }), locjson = serializeToLocjson(doc, fields, {
1427
1481
  sourceLanguage,
1428
1482
  fieldLanguageKey: fieldI18nLanguageField
1429
- }), smartcatName = buildLocjsonFilename(type, docId, resolveDocumentTitle(schema, doc));
1483
+ }), smartcatName = buildLocjsonFilename(type, docId, resolveDocumentTitle(schema, doc), { draft: isDraft });
1430
1484
  setFilename(smartcatName.split("/").pop() || smartcatName), setContent(JSON.stringify(locjson, null, 2));
1431
1485
  } catch (err) {
1432
1486
  setError(err instanceof Error ? err.message : String(err));
1433
1487
  }
1434
- }, [client, schema, docId, type, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField]), download = useCallback(() => {
1488
+ }, [client, schema, docId, type, sourceIsPublished, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField]), download = useCallback(() => {
1435
1489
  if (content == null || !filename) return;
1436
1490
  const url = URL.createObjectURL(new Blob([content], { type: "application/json" })), anchor = document.createElement("a");
1437
1491
  anchor.href = url, anchor.download = filename, anchor.click(), URL.revokeObjectURL(url);
@@ -1490,6 +1544,7 @@ function ProjectView({
1490
1544
  onBack,
1491
1545
  languages,
1492
1546
  sourceLanguage,
1547
+ translatableTypes,
1493
1548
  documentI18nLanguageField,
1494
1549
  fieldI18nLanguageField
1495
1550
  }) {
@@ -1635,6 +1690,7 @@ function ProjectView({
1635
1690
  targetLanguages: targets,
1636
1691
  sourceLanguage,
1637
1692
  languages,
1693
+ translatableTypes,
1638
1694
  exclude: [documentI18nLanguageField],
1639
1695
  fieldLanguageKey: fieldI18nLanguageField,
1640
1696
  deletions,
@@ -1846,13 +1902,23 @@ Smartcat language code: '${l.smartcatLanguage || l.id}'`,
1846
1902
  children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
1847
1903
  /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
1848
1904
  /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
1905
+ /* @__PURE__ */ jsx(
1906
+ Badge,
1907
+ {
1908
+ tone: item.sourceIsPublished ? "positive" : "caution",
1909
+ paddingX: 2,
1910
+ title: item.sourceIsPublished ? "The published version is the translation source" : "The draft version is the translation source",
1911
+ children: item.sourceIsPublished ? "Published" : "Draft"
1912
+ }
1913
+ ),
1849
1914
  /* @__PURE__ */ jsx(Text, { weight: "medium", children: /* @__PURE__ */ jsx("span", { style: isMarked ? { textDecoration: "line-through" } : void 0, children: /* @__PURE__ */ jsx(DocTitle, { doc: item.doc }) }) }),
1850
- item.doc?._type && /* @__PURE__ */ jsx(Badge, { tone: "primary", children: item.doc._type }),
1915
+ item.doc?._type && /* @__PURE__ */ jsx(Badge, { tone: "primary", paddingX: 2, children: item.doc._type }),
1851
1916
  item.doc?._type && modeByType.has(item.doc._type) && /* @__PURE__ */ jsx(
1852
1917
  Badge,
1853
1918
  {
1854
1919
  tone: "default",
1855
1920
  fontSize: 0,
1921
+ paddingX: 2,
1856
1922
  title: modeByType.get(item.doc._type) === "field" ? "This item is translated in field mode; translations will be stored as language variants of each field" : "This item is translated in document mode; translations will be stored as linked documents",
1857
1923
  children: modeByType.get(item.doc._type) === "field" ? "fields" : "document"
1858
1924
  }
@@ -1863,6 +1929,7 @@ Smartcat language code: '${l.smartcatLanguage || l.id}'`,
1863
1929
  {
1864
1930
  docId: item.doc._id,
1865
1931
  type: item.doc._type,
1932
+ sourceIsPublished: item.sourceIsPublished,
1866
1933
  client,
1867
1934
  schema,
1868
1935
  sourceLanguage,
@@ -1938,7 +2005,7 @@ Smartcat language code: '${l.smartcatLanguage || l.id}'`,
1938
2005
  ] });
1939
2006
  }
1940
2007
  function createDashboardComponent(config) {
1941
- const { sourceLanguage, languages, documentI18nLanguageField, fieldI18nLanguageField } = config;
2008
+ const { sourceLanguage, languages, translatableTypes, documentI18nLanguageField, fieldI18nLanguageField } = config;
1942
2009
  return function() {
1943
2010
  const router = useRouter(), projectId = useRouterState(
1944
2011
  useCallback((state) => state.projectId ?? null, [])
@@ -1950,6 +2017,7 @@ function createDashboardComponent(config) {
1950
2017
  onBack: goBack,
1951
2018
  languages,
1952
2019
  sourceLanguage,
2020
+ translatableTypes,
1953
2021
  documentI18nLanguageField,
1954
2022
  fieldI18nLanguageField
1955
2023
  }
@@ -2069,6 +2137,7 @@ function resolveConfig(config) {
2069
2137
  const translatableTypes = config.translatableTypes;
2070
2138
  return {
2071
2139
  isTranslatableType: translatableTypes ? (type) => translatableTypes.includes(type) : (type) => !PLUGIN_TYPES.has(type),
2140
+ translatableTypes,
2072
2141
  // `rootTypes` inherits the *explicitly-configured* translatableTypes (not the
2073
2142
  // resolved "everything") so an unconfigured Studio gets no crawl boundary
2074
2143
  // rather than every type being a root (which would stop the crawl at once).