diff --git a/.agents/skills/migrate-styled-components-to-vanilla-extract/SKILL.md b/.agents/skills/migrate-styled-components-to-vanilla-extract/SKILL.md new file mode 100644 index 0000000000..3068b8c3fd --- /dev/null +++ b/.agents/skills/migrate-styled-components-to-vanilla-extract/SKILL.md @@ -0,0 +1,289 @@ +--- +name: migrate-styled-components-to-vanilla-extract +description: Step-by-step procedure for migrating a workspace off styled-components to vanilla-extract (zero-runtime CSS). Use when converting styled() components to .css.ts files, removing a styled-components dependency, adding a ./bundle.css export, or when asked to migrate styling to vanilla-extract. Pair with the sanity-plugin-best-practices styling reference for the underlying patterns. +metadata: + author: Sanity.io + version: '1.0.0' +--- + +# Migrate styled-components to vanilla-extract + +The repeatable procedure for converting a workspace's styling from `styled-components` (the Studio's +legacy styling library) to [vanilla-extract](https://vanilla-extract.style). + +This skill covers the **workflow**: what to change, in what order, and how to verify it. The styling +**patterns** themselves (dynamic theming, variants, keyframes, the `&&` specificity trick, +encapsulation) live in the `sanity-plugin-best-practices` skill's +[styling reference](../sanity-plugin-best-practices/references/styling.md) — read it first and defer +to it for pattern details. For monorepo-specific policy (one plugin per PR, never during a transfer, +which plugins are reference implementations), see `AGENTS.md` → +`Migrating plugin styling off styled-components`. + +## Ground rules + +- **Identical visual output is the goal.** This is a refactor, not a redesign — every rule, theme + token, and specificity outcome must be preserved. Verify the result against the original rendering + before opening the PR. + +## Step 1: Inventory + +Find every styled-components usage in the workspace and classify it before touching code: + +```bash +rg "styled-components" /src +``` + +| Usage | Migration target | +| --------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Static `styled(Primitive)` / `styled.div` | `style()` in a colocated `.css.ts` + thin wrapper component (or plain `className`) | +| Theme reads (`({theme}) => theme.sanity...`) | `createVar()` + `assignInlineVars()` + `useTheme_v2()` | +| Prop-driven variants (`$isInvalid`, `css` branches) | `styleVariants()` or conditional class composition | +| `keyframes` animations | vanilla-extract `keyframes()` | +| Descendant selectors (`& img`, third-party classes) | class on the child directly, or `globalStyle()` scoped under a local wrapper class | +| `createGlobalStyle` | `globalStyle()` (scope it — never leak outside the workspace) | +| `*.styles.tsx` modules | replaced by `.css.ts` (+ a component layer where call sites need it) | +| Computed inline `style={{}}` objects | static parts into `style()`, changing values via `createVar()` (Shape C below) | + +Each row's pattern is documented with examples in the +[styling reference](../sanity-plugin-best-practices/references/styling.md). + +## Step 2: Migrate the code + +Work component by component. Create a `.css.ts` next to each component and move its rules over. Two +real shapes cover most cases: + +**Shape A — keep the component layer.** When the styled element is used like a component (composed, +given props), replace it with a `style()` rule plus a thin wrapper that keeps the same name and API, +so call sites don't change: + +```ts +// FloatingCard.css.ts +import {style} from '@vanilla-extract/css' + +export const floatingCard = style({ + position: 'fixed', + bottom: 0, + left: 0, + zIndex: 1000, +}) +``` + +```tsx +// FloatingCard.tsx — before: const StyledFloatingCard = styled(Card)`position: fixed; ...` +import {Card} from '@sanity/ui' +import {clsx} from 'clsx/lite' +import type {ComponentProps} from 'react' + +import {floatingCard} from './FloatingCard.css' + +function StyledFloatingCard({className, ...props}: ComponentProps) { + return +} +``` + +Type the wrapper with `ComponentProps` (or `ComponentProps<'div'>`) and never use +`forwardRef` — `ref` is a regular prop on React 19. **Merge, don't clobber:** a fixed +`className={floatingCard}` after `{...props}` silently drops a `className` a caller passes in. Pull +`className` out of props and merge it with `clsx(floatingCard, className)` — prefer +`import {clsx} from 'clsx/lite'` when only joining strings (the usual case); reach for full `clsx` +only if you need the object/array API. A `` `${floatingCard} ${className ?? ''}` `` template literal +also works. See +[Keep the component layer](../sanity-plugin-best-practices/references/styling.md#keep-the-component-layer-encapsulation). + +**Shape B — flatten single-use wrappers.** When the styled element was an internal, single-use +`styled.div` with no meaningful API, put the class directly on the element. Descendant selectors +like `& img { ... }` usually migrate best by styling the child directly when you render it yourself: + +```tsx +// Before: where MapDiffImage = styled.div`& img {...}` + +``` + +**Shape C — dynamic values through CSS variables.** When a value varies per instance, with +props/state, or reads the theme, keep the static parts in `style()` and bridge only the changing +values through `createVar()` + `assignInlineVars()` (from `@vanilla-extract/dynamic`). This also +migrates computed inline `style={{}}` objects — hoist the static properties into the `.css.ts` and +keep only the variables inline: + +```ts +// Checkboard.css.ts +import {createVar, style} from '@vanilla-extract/css' + +export const borderRadiusVar = createVar() +export const backgroundImageVar = createVar() + +export const checkboard = style({ + borderRadius: borderRadiusVar, + position: 'absolute', + inset: 0, + background: backgroundImageVar, +}) +``` + +```tsx +// Checkboard.tsx — before:
+import {assignInlineVars} from '@vanilla-extract/dynamic' + +import {backgroundImageVar, borderRadiusVar, checkboard} from './Checkboard.css' + +function Checkboard({borderRadius, background}: {borderRadius?: string; background?: string}) { + return ( +
+ ) +} +``` + +`assignInlineVars` omits `undefined` values, so one class serves every instance. Theme tokens flow +the same way: read them with `useTheme_v2()` in a small wrapper and assign them to variables. + +For everything else — variants (`styleVariants`), keyframes, theming third-party widgets' +classes, and overriding a `@sanity/ui` primitive's own styles (the `selectors: {'&&': {...}}` +trick) — follow the corresponding section of the +[styling reference](../sanity-plugin-best-practices/references/styling.md). Where styled-components +silently won specificity battles via CSSOM insertion order, you may need `&&` to match the original +rendering. + +Delete the `*.styles.tsx` modules (and their `styled-components` imports) as they empty out. + +## Step 3: Switch the build config + +In the workspace's `tsdown.config.ts`, drop `styledComponents: true` and add `vanillaExtract: true`: + +```ts +import {defineConfig} from '@sanity/tsdown-config' +import type {UserConfig} from 'tsdown' + +export default defineConfig({ + reactCompiler: true, + vanillaExtract: true, +}) satisfies Promise +``` + +The integration extracts all `.css.ts` rules into `dist/bundle.css` at build time, auto-imports it +from `dist/index.js`, and writes/syncs the `./bundle.css` entry in `package.json` `exports` and +`publishConfig.exports` on every build — nothing to hand-edit there. After the first build the +export block looks like: + +```jsonc +"./bundle.css": { + "types": "./dist/bundle-css.d.ts", + "browser": "./dist/bundle.css", + "style": "./dist/bundle.css", + "node": "./dist/bundle-css.js", + "default": "./dist/bundle-css.js" +} +``` + +## Step 4: Update dependencies + +In the workspace's `package.json`: + +- **Add** dev dependencies `"@sanity/vanilla-extract-vite-plugin": "catalog:"` and + `"@vanilla-extract/css": "catalog:"`. Both are build-time only — never runtime `dependencies`. + (If the workspace uses `assignInlineVars`, add `"@vanilla-extract/dynamic": "catalog:"` under + `dependencies` — that one is a small runtime helper.) +- **Remove** the `styled-components` entry from `peerDependencies`, and any + `babel-plugin-styled-components` devDependency. +- **Peer alignment:** when the workspace depends on `@sanity/ui` (which peers on styled-components), + removing the `"styled-components": "catalog:"` devDependency can make pnpm resolve a separate + styled-components copy, forking the workspace's `sanity` peer variant away from the rest of the + monorepo and breaking type-aware lint. Dropping it can be fine, but you must verify: after + `pnpm install`, the workspace's `sanity` / `@sanity/ui` resolution strings in `pnpm-lock.yaml` must + match other workspaces that depend on `@sanity/ui`. If they fork, keep the `catalog:` + devDependency. + +No `knip.jsonc` or catalog changes are needed — `@vanilla-extract/css` is globally ignored and all +the catalog entries already exist. + +## Step 5: Register the Vite plugin for Vitest + +The package-exports test resolves the workspace `exports` map, whose `.` entry points at +`./src/index.ts` — so it imports real `.css.ts` source and Vitest must compile it. Add the plugin to +the workspace's `vitest.config.ts`: + +```ts +import {vanillaExtractPlugin} from '@sanity/vanilla-extract-vite-plugin' +import {defineConfig} from 'vitest/config' + +export default defineConfig({ + plugins: [vanillaExtractPlugin()], + // ...existing test config +}) +``` + +Use `@sanity/vanilla-extract-vite-plugin` (faster drop-in for the upstream +`@vanilla-extract/vite-plugin`). If a host app or studio already registers the Vite plugin +globally, you don't need to touch that file for the workspace under migration. + +> **Optional, jsdom-only:** if the workspace has `jsdom`/`happy-dom` suites that don't assert on real +> CSS (layout, `getComputedStyle`), you can skip runtime style injection with +> `setupFiles: ['@vanilla-extract/css/disableRuntimeStyles']`. This does **not** replace the Vite +> plugin, is irrelevant under the default `node` environment, and is opted into per workspace — never +> monorepo-wide. See +> [Disabling runtime styles in tests](../sanity-plugin-best-practices/references/styling.md#disabling-runtime-styles-in-tests) +> in the styling reference. + +## Step 6: Build and update the exports snapshot + +```bash +pnpm install +pnpm build --filter= +``` + +Check the output: `dist/bundle.css` must contain every migrated rule, and `dist/index.js` must +import it. Then update the package-exports inline snapshot, which gains a `"./bundle.css"` entry: + +```bash +pnpm test -u --project +``` + +## Step 7: Verify + +Run the full pre-PR suite (all must pass): + +```bash +pnpm format +pnpm lint +pnpm knip +pnpm build +pnpm test run +``` + +Then verify **visual fidelity** manually: exercise the workspace's UI and compare against the +pre-migration rendering — theme tokens, spacing, stacking, hover/sticky/fixed behavior. If an +override no longer takes effect, reach for the `&&` trick rather than `!important`. + +Optionally lock the migration in: once the workspace no longer imports `styled-components`, a +`no-restricted-imports` ban in a workspace-level lint override keeps it from creeping back. + +## Step 8: Add a changeset + +One patch changeset for the migrated workspace: + +```markdown +--- +'': patch +--- + +Migrate styling from styled-components to vanilla-extract (zero-runtime CSS) +``` + +## Checklist + +- [ ] Every `styled-components` import in `src/` removed; `.styles.tsx` modules deleted +- [ ] `.css.ts` files colocated with their components; component layer preserved where call sites + need it +- [ ] `tsdown.config.ts`: `styledComponents` removed, `vanillaExtract: true` added +- [ ] `package.json`: vanilla-extract devDeps added; `styled-components` peer removed; `sanity` / + `@sanity/ui` peer variants verified aligned in `pnpm-lock.yaml` +- [ ] `vitest.config.ts` registers `vanillaExtractPlugin()` +- [ ] `dist/bundle.css` emitted with all rules; package-exports snapshot updated +- [ ] `pnpm format` / `pnpm lint` / `pnpm knip` / `pnpm build` / `pnpm test run` all pass +- [ ] Visual fidelity verified against the pre-migration rendering +- [ ] Patch changeset added diff --git a/.agents/skills/plugin-test-coverage/SKILL.md b/.agents/skills/plugin-test-coverage/SKILL.md new file mode 100644 index 0000000000..cabc726b04 --- /dev/null +++ b/.agents/skills/plugin-test-coverage/SKILL.md @@ -0,0 +1,97 @@ +--- +name: plugin-test-coverage +description: Guides agents through expanding Vitest and Playwright e2e coverage for a monorepo plugin. Use when adding or improving plugin tests, wiring e2e-studio, or following the internationalized-array / document-internationalization coverage playbook. +--- + +# Plugin Test Coverage + +Use this skill when expanding **Vitest** and/or **Playwright e2e** coverage for a plugin under `plugins/`. + +Reference implementations: + +- `@sanity/document-internationalization` — Vitest + e2e under `e2e/tests/document-internationalization/` +- `sanity-plugin-internationalized-array` — Vitest + e2e under `e2e/tests/internationalized-array/` + +Also read [`e2e/README.md`](../../../e2e/README.md) before writing Playwright specs. + +## When to use + +- Filling unit/integration gaps for a published plugin +- Wiring a plugin into `dev/e2e-studio` and adding Playwright specs +- Porting the doc-i18n / internationalized-array coverage process to another plugin + +## Workflow + +1. **Inventory use cases** from README + source (authoring loops, config knobs, integrations). +2. **Map each use case** to Vitest (pure logic, components with mocks) vs Playwright (studio UX that needs a real form). +3. **Fill Vitest gaps** co-located under `plugins//src/` — plugin assembly, context/providers, utils, components. +4. **Wire e2e-studio** — `definePlugin` example file, register in **both** chromium/firefox workspaces. +5. **Add helpers + specs** — `e2e/helpers//`, `e2e/tests//`. +6. **Changesets** — separate patch changeset per published package that gained `data-testid`s or runtime changes. Private e2e/studio files need none. +7. **Verify** — `pnpm format && pnpm lint && pnpm knip && pnpm build && pnpm test` (and targeted e2e when secrets allow). +8. **PR** — draft, `🤖 bot` label, inventory + e2e test table in the description. + +Skip one-off tooling (migrations, banners) unless they are part of the main authoring loop. + +## Vitest patterns + +- Co-locate `*.test.ts(x)` next to source; use jsdom via the package `vitest.config.ts`. +- Shared mocks/fixtures in `src/test/helpers.ts` and `src/test/component-helpers.tsx` (`ThemeWrapper` for `@sanity/ui`). +- Plugin assembly tests: call the plugin factory, assert schema type names, document layout, form input wrappers, nested plugins. +- Context/provider tests: mock `useClient` / `useWorkspace` / pane hooks; use `Suspense` + `act` for async `React.use` language resolution; call any module-level `clear()` between tests. +- Timeouts: `test('name', {timeout: 30_000}, async () => { … })` — options object as second arg. +- Keep the package-exports snapshot test; update with `pnpm test -u` only when exports intentionally change. +- React Compiler is on — do not add unnecessary `useMemo` / `useCallback`. + +Run a single package: + +```bash +pnpm --filter test run +``` + +## E2e structure + +``` +e2e/ +├── tests/ +│ ├── smoke.spec.ts # studio-wide only +│ └── / +│ └── .spec.ts +└── helpers/ + └── / + └── .ts +``` + +Do not dump plugin specs at the top level of `tests/`. + +## E2e-studio wiring + +1. Add `dev/e2e-studio/src/.ts` with `definePlugin` that registers schema + the plugin under test. +2. Import and add it to **both** workspaces in `dev/e2e-studio/sanity.config.ts`. +3. Ensure the workspace `package.json` already depends on the plugin (`workspace:*`). +4. Update the “Currently wired” list in `e2e/README.md`. + +## Hard-won Playwright lessons + +- Project `baseURL` must end with a **trailing slash** (`…/chromium/`, `…/firefox/`). +- Navigate with **relative** intents: `intent/edit/id=…;type=…`. Never host-absolute `/intent/…` (drops workspace basePath → “Workspace not found”). +- Auth: Playwright `storageState` seeds `__studio_auth_token_`; preflight `/users/me`. Never use `SANITY_DEPLOY_TOKEN` for e2e session auth. +- Local pitfall: stale server on `:3333` + `reuseExistingServer` → signed-out studio. Kill and restart. +- Prefer accessible role/name locators; add `data-testid` + a **patch** changeset only when selectors are flaky or ambiguous (e.g. duplicate add-button grids). +- Seed documents via the Content Lake API; always `try/finally` cleanup. +- Video: `SANITY_E2E_VIDEO=on` when debugging. +- Document existence matters: some plugins only auto-seed after `_rev` exists — seed empty persisted docs, don’t rely on brand-new unsaved drafts. + +## PR checklist + +- [ ] Separate changeset per published package touched +- [ ] Draft PR with `🤖 bot` label +- [ ] Use-case inventory + e2e test table in the description +- [ ] `pnpm format` / `lint` / `knip` / `build` / `test` green +- [ ] CI e2e (chromium + firefox) green when studio wiring changed + +## Pointers + +- [`e2e/README.md`](../../../e2e/README.md) +- [`AGENTS.md`](../../../AGENTS.md) — CI commands, changesets, Node version notes +- Doc-i18n + internationalized-array as living examples diff --git a/.agents/skills/plugin-transfer/SKILL.md b/.agents/skills/plugin-transfer/SKILL.md index c8b77fbfa9..3d47cc01db 100644 --- a/.agents/skills/plugin-transfer/SKILL.md +++ b/.agents/skills/plugin-transfer/SKILL.md @@ -15,30 +15,212 @@ Always start with: pnpm generate "copy plugin" ``` -This is the canonical transfer flow and scaffolds monorepo-compatible files, test-studio wiring, and migration TODOs. +This is the canonical transfer flow and scaffolds monorepo-compatible files and test-studio wiring. + +**Do not keep migration TODOs in the repo.** If the generator creates `README.todo.md`, delete it after moving its contents into the transfer PR description. Maintainers can update PR checklists directly on GitHub without a code change. ## Required vs Unnecessary Config Keep and maintain these monorepo config files in the transferred plugin: - `package.json` -- `package.config.ts` +- `tsdown.config.ts` - `tsconfig.json` -- `tsconfig.build.json` - `vitest.config.ts` Do not copy standalone-repo-only setup such as custom root CI/build/lint/test configs that are already handled by this monorepo. +## Clean Up the Transferred README + +The original `README.md` is preserved, but old standalone-repo content is almost always stale in the monorepo. Remove or rewrite the following before opening the PR: + +- **Old release/development sections.** Delete sections that describe the original repo's release or dev tooling, e.g. `## Develop & test` (typically references `@sanity/plugin-kit`) and `### Release new version` (references the original repo's GitHub Actions / semantic-release). The monorepo handles building, testing, and releasing centrally, so these instructions are wrong here. +- **Links to old/forked versions.** Remove pointers like "for the v2 version, see this other repo" that link to pre-transfer forks or legacy repositories. +- **Specific Sanity Studio major versions.** Do not reference the current latest Studio major (e.g. "Sanity Studio v6") since it ages quickly, and do not mention long-gone majors like v3. Reword phrasing such as "migrated to Sanity Studio V3" / "only the v3 version is maintained" to a version-agnostic statement (e.g. "maintained by Sanity.io"). Only mention a version when genuinely necessary—at most as `v2 - legacy` to disambiguate a legacy line—and usually omit it entirely. + +Keep the substance that is still accurate: intro/description, screenshots, acknowledgements, install, usage, configuration, and license sections. + ## Required Transfer Checks -1. Keep the original plugin `README.md` in the new plugin workspace. -2. Add and verify the generated test-studio example under `dev/test-studio/src//index.tsx`. -3. Confirm the plugin is wired in `dev/test-studio/sanity.config.ts`. -4. Do **not** update `.github/CODEOWNERS` during transfer unless explicitly requested. -5. Add a changeset with a **major** bump for the transferred plugin. -6. Update the root `README.md` plugins table with the transferred plugin. +1. Keep the original plugin `README.md` in the new plugin workspace, but clean it up (see [Clean Up the Transferred README](#clean-up-the-transferred-readme)). +2. Restore `LICENSE` from the original repository when it credits authors beyond Sanity.io alone (the copy-plugin generator deletes it during cleanup). If kept, update the copyright year(s) to the current year. +3. Add and verify the generated test-studio example under `dev/test-studio/src//index.tsx`. +4. Confirm the plugin is wired in `dev/test-studio/sanity.config.ts`. +5. Do **not** update `.github/CODEOWNERS` during transfer unless explicitly requested. +6. Add a changeset with a **major** bump for the transferred plugin (see [Changesets](#changesets)). +7. Update the root `README.md` plugins table with the transferred plugin. +8. Add pending transfer TODOs to the transfer PR description only (see [PR description checklist](#pr-description-checklist)—not in a README or other repo file). +9. Run the full pre-PR verification suite (see [Before Submitting a PR](#before-submitting-a-pr)). + +## Before Submitting a PR + +Run these commands in order. **All must pass** or CI will fail: + +```bash +# 1. Format code +pnpm format + +# 2. Check for unused exports, dependencies, and catalog entries +pnpm knip + +# 3. Run linters (includes TypeScript type checking) +pnpm lint + +# 4. Build all packages +pnpm build + +# 5. Run tests +pnpm test run +``` + +### Knip + +The copy-plugin generator adds a workspace entry to `knip.jsonc`. After transfer, fix any knip issues in the plugin: + +- Remove unused exports (e.g. helpers only used internally should not be exported). +- Remove dead code flagged as unused. + +Catalog warnings for `dev/*` workspaces (e.g. `@sanity/vision` used only by `dev/test-studio`) are expected—the root `knip.jsonc` sets `"catalog": "warn"` for those. + +### Lint + +Transferred plugins may carry legacy patterns that fail monorepo lint rules. Fix what you can; for remaining issues in legacy `src/` or `test/` code, add targeted `.oxlintrc.json` overrides or `ignorePatterns` rather than disabling rules repo-wide. + +Common legacy fixes: + +- Replace `createRequire` / `require()` with ESM `import` (add `"resolveJsonModule": true` to the plugin `tsconfig.json` for JSON imports). +- Use `import.meta.url` with `fileURLToPath` instead of `__dirname` in tests. +- Remove stale `eslint-disable` comments that oxlint reports as unused. + +#### Duplicate `sanity` peer variants break type-aware lint + +Type-aware lint can fail (sometimes intermittently, especially on cold installs) with errors like `Type 'import(".../.pnpm/sanity@X_/...").D' is not assignable to type 'import(".../.pnpm/sanity@X_/...").D'` in `dev/test-studio/src/**` examples. This happens when the transferred plugin resolves `sanity` to a different pnpm peer-variant than the other plugins, creating an extra duplicate copy of sanity's type definitions. + +To keep the plugin on the shared `sanity` variant, declare these in the plugin `devDependencies`: + +- `"@types/node": "catalog:"` +- `"styled-components": "catalog:"` (when the plugin depends on `@sanity/ui`, which peers on styled-components; without the declaration pnpm may auto-install a separate copy of the peer) + +Verify alignment by checking that the plugin importer's `sanity` version string in `pnpm-lock.yaml` matches other plugins (e.g. `plugins/@sanity/sfcc`). + +> **Do not migrate styling during a transfer.** When the plugin already uses `styled-components`, leave it in place for the initial port — the goal is a faithful, low-risk move. `styled-components` is still migrated to vanilla-extract (the styling target for every plugin), but in a **separate follow-up PR**, done with care to preserve visual fidelity and avoid regressions. The `styled-components: catalog:` alignment above applies until then. For the follow-up, use the `migrate-styled-components-to-vanilla-extract` skill; the `sanity-plugin-best-practices` styling reference (`Migrating off styled-components`) covers the patterns. + +### Tests + +Vitest runs against built `dist/` output (`pretest` builds packages automatically). Fix path resolution and module import issues in legacy test files. The plugin's own `test/` suite (if present) runs via the root vitest config when included in the plugin workspace. + +## Changesets + +Every transferred plugin needs a **major** changeset. Compare the transferred plugin's `package.json` (peer dependencies, engines, exports, and build config) against the last published version on npm. Do not copy a template blindly—only list breaking changes that actually apply. + +### Credit every contributor + +Because the transfer PR is opened by someone else (often the `🤖 bot`), the generated release would otherwise thank the wrong person. Add an `author:` directive so the changelog credits the people who actually built the plugin. [Multiple `author:` lines are supported](https://github.com/changesets/changesets/blob/c2db1dd5d2da6c6eb514d86bbe05cbb7227b067f/packages/changelog-github/src/index.test.ts#L229-L243), so list **everyone** who worked on the plugin being ported — not just the latest author — so they all get their thanks in the release notes. + +- Use the contributors' **GitHub usernames**, always with a leading `@` (e.g. `author: @stipsan`). +- Put each `author:` line on its own line, before the summary text. The lines are stripped from the rendered changelog. +- Gather contributors from the original repo's commit history, `package.json` `author`/`contributors`, and the README acknowledgements. + +```markdown +--- +'package-name': major +--- + +author: @stipsan +author: @rexxars + +Port PACKAGE-NAME to the Sanity plugins monorepo +``` + +This produces a release line thanking each contributor: + +> Thanks [@stipsan](https://github.com/stipsan), [@rexxars](https://github.com/rexxars)! - Port PACKAGE-NAME to the Sanity plugins monorepo + +See [AGENTS.md → Crediting Original Authors](../../../AGENTS.md) for the full rationale. + +### Format + +Use this format (add the `author:` lines from [Credit every contributor](#credit-every-contributor) above the summary): + +```markdown +--- +'package-name': major +--- + +author: @stipsan +author: @rexxars + +Port PACKAGE-NAME to the Sanity plugins monorepo + +This major release includes several breaking changes as part of the migration to the monorepo: + +- **React Compiler enabled**: ... +- **ESM-only**: CommonJS support has been removed. The package now ships only ESM +- **React 19.2+ required**: ... +- **Sanity Studio v5+ required**: ... +- **Node.js 20.19+ required**: ... +``` + +Include additional bullets only when they apply to the plugin—for example: + +- **styled-components 6.1+ required** (UI plugins that use styled-components) +- **react-dom 19.2+ required** (when newly added as a peer dependency) +- **Dropped Sanity v3/v4 support** (when the previous peer range allowed older Studio versions) + +Example for `sanity-naive-html-serializer`: + +```markdown +--- +'sanity-naive-html-serializer': major +--- + +author: @stipsan +author: @rexxars + +Port sanity-naive-html-serializer to the Sanity plugins monorepo + +This major release includes several breaking changes as part of the migration to the monorepo: + +- **React Compiler enabled**: The package is now built with React Compiler targeting React 19 +- **ESM-only**: CommonJS support has been removed. The package now ships only ESM +- **React 19.2+ required**: Minimum React version is now 19.2 (previously ^18.3 || ^19) +- **react-dom 19.2+ required**: `react-dom` is now a required peer dependency +- **Sanity Studio v5+ required**: Minimum Sanity version is now v5 (Sanity v3 and v4 are no longer supported) +- **Node.js 20.19+ required**: Minimum Node.js version is now 20.19 (previously >=18) +``` + +## PR description checklist + +Put all pending transfer work in the **PR description** as unchecked checkboxes. Do not create `README.todo.md` or similar todo files in the plugin workspace—the maintainer should be able to check items off on GitHub without opening a PR to edit repo files. + +Include these sections in every transfer PR: + +### Transfer verification + +- [ ] Trusted publishing configured: `npm trust github --file=release.yml --repository=sanity-io/plugins` +- [ ] `package.json` dependencies/peerDependencies/exports verified against original repo +- [ ] `LICENSE` restored with updated copyright year when the original credits authors beyond Sanity.io alone +- [ ] Test studio example wired and manually verified (`pnpm dev`) +- [ ] `pnpm format`, `pnpm knip`, `pnpm lint`, `pnpm build`, `pnpm test run` all pass +- [ ] Major changeset added with validated breaking changes + +### Maintainer follow-up + +Agents cannot complete these steps themselves. Ask the maintainer to handle them: + +- [ ] Update the original repo README (``) and replace it with: `# [This plugin has moved]()` +- [ ] Transfer pending issues from the original repo to this monorepo and label them as `` +- [ ] Archive the original repo: `/settings` + +Example for `sanity-naive-html-serializer`: + +- [ ] Update the original repo README (https://github.com/sanity-io/sanity-naive-html-serializer/blob/main/README.md) and replace it with: `# [This plugin has moved](https://github.com/sanity-io/plugins/tree/main/plugins/sanity-naive-html-serializer)` +- [ ] Transfer pending issues from the original repo to this monorepo and label them as `naive-html-serializer` +- [ ] Archive the original repo: https://github.com/sanity-io/sanity-naive-html-serializer/settings ## Anything Else To Consider +- **Do not migrate styling during the transfer.** Keep an existing `styled-components` plugin on `styled-components` for the initial port; defer any vanilla-extract migration to a follow-up PR using the `migrate-styled-components-to-vanilla-extract` skill (see [the styling note above](#duplicate-sanity-peer-variants-break-type-aware-lint)). - Review copied dependencies and peer dependencies carefully. -- Run `pnpm build`, `pnpm test`, and `pnpm dev` to verify migration quality. +- Run the [Before Submitting a PR](#before-submitting-a-pr) verification suite—not just `pnpm build` and `pnpm dev`. +- Use `pnpm dev` to manually verify the test-studio example after the automated checks pass. diff --git a/.agents/skills/pnpm/references/features-overrides.md b/.agents/skills/pnpm/references/features-overrides.md index 5b6bd9b2f3..0722e7631c 100644 --- a/.agents/skills/pnpm/references/features-overrides.md +++ b/.agents/skills/pnpm/references/features-overrides.md @@ -80,13 +80,13 @@ Override cookie only when it's a dependency of express. ```yaml overrides: # Replace underscore with lodash - "underscore": "npm:lodash@^4.17.21" + 'underscore': 'npm:lodash@^4.17.21' # Use local file - "some-pkg": "file:./local-pkg" + 'some-pkg': 'file:./local-pkg' # Use git - "some-pkg": "github:user/repo#commit" + 'some-pkg': 'github:user/repo#commit' ``` ### Remove a dependency diff --git a/.agents/skills/sanity-plugin-best-practices/SKILL.md b/.agents/skills/sanity-plugin-best-practices/SKILL.md new file mode 100644 index 0000000000..11f593bbe6 --- /dev/null +++ b/.agents/skills/sanity-plugin-best-practices/SKILL.md @@ -0,0 +1,97 @@ +--- +name: sanity-plugin-best-practices +description: Anti-patterns and best practices for building Sanity Studio plugins in this monorepo. Use when writing, reviewing, or refactoring plugin code under plugins/ — especially for styling/CSS, component performance, and runtime cost. Triggers on vanilla-extract, raw + ... +
+ ) +} +``` + +**Incorrect (`dangerouslySetInnerHTML` for CSS — injection-prone, no dedup):** + +```tsx +function Callout({css}: {css: string}) { + return + + + ) +} + +// Object for Autocomplete's `options` prop +interface OptionsItem { + value: string + [key: string]: unknown +} + +// Autocomplete options validation +function validOptions(arr: unknown): arr is OptionsItem[] { + return ( + Array.isArray(arr) && + arr.every( + (item: unknown) => + typeof item === 'object' && + item !== null && + 'value' in item && + typeof item.value === 'string', + ) + ) +} + +/** + * Props for the {@link AsyncList} input component: the standard Sanity string + * input props plus the async-list `options`. + * + * @public + */ +export interface AsyncListInputProps extends StringInputProps { + options: AsyncListInputOptions +} + +/** + * The async-list input component. It is a regular React component that takes a + * single `props` argument, so it is safe under the Rules of Hooks and gets + * optimized by the React Compiler. + * + * For the `components.input` slot, prefer {@link createAsyncListInput}, which + * binds the options for you. + * + * TODO: + * - Cache fetchData call w/o arguments + * + * @public + */ +export function AsyncList(props: AsyncListInputProps): JSX.Element { + const {options} = props + const namespace = + options.secrets?.namespace ?? + (options.schemaType ? `async-list-${options.schemaType}` : 'async-list') + + // Warn (in dev) when secrets are configured but there is nothing stable to + // derive a namespace from. Without an explicit `secrets.namespace` (or a + // `schemaType` from the plugin), multiple component-usage fields would share + // the same default namespace and collide. + const secretsKeys = options.secrets?.keys + const secretsNamespace = options.secrets?.namespace + const schemaType = options.schemaType + useEffect(() => { + if (process.env['NODE_ENV'] === 'production') return + if (secretsKeys && !secretsNamespace && !schemaType) { + console.warn( + 'sanity-plugin-async-list: `secrets` is configured without `schemaType` or `secrets.namespace`. ' + + 'Set an explicit `secrets.namespace` to avoid collisions between fields.', + ) + } + }, [secretsKeys, secretsNamespace, schemaType]) + + const {secrets} = useSecrets | undefined>(namespace) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [showSettings, setShowSettings] = useState(false) + const [prevQuery, setPrevQuery] = useState(null) + const client = useClient(options.clientOptions ?? {apiVersion: '2024-12-12'}) + + const fetchData = useCallback( + async (query?: string) => { + try { + // Reset previous error state + setError(null) + setLoading(true) + + // Call user provided data loader + const loaderData = await options.loader({secrets, query, client}) + + // Validate and set data + if (validOptions(loaderData)) { + setData(loaderData) + } else { + console.error( + 'sanity-plugin-async-list data error - data must match options from @sanity/ui Autocomplete https://www.sanity.io/ui/docs/component/autocomplete', + loaderData, + ) + setError(new Error('Error with list data. Check console for more info.')) + } + } catch (e) { + const errorMessage = + e instanceof Error ? e.message : 'An unknown error occurred while fetching data' + + console.error('sanity-plugin-async-list fetch error:', errorMessage) + setError(new Error('Error fetching list, check console for more info')) + } finally { + setLoading(false) + } + }, + [options, secrets, client], + ) + + useEffect(() => { + // Don't fetch if we expect API keys but secrets don't exist + if (options?.secrets?.keys && !secrets) return + // fetch the initial data, but only if the field doesn't have a value + if (!props.value && !data) { + // Kicking off the initial load intentionally sets loading/error state on + // mount so the field shows a spinner while the loader resolves. + // oxlint-disable-next-line react/react-compiler + void fetchData() + } + }, [fetchData, data, secrets, options.secrets, props.value]) + + // Set field value in content lake. Plain function: with React Compiler enabled + // this is memoized automatically based on the `props.onChange` it captures. + const handleChange = (value?: string) => props.onChange(value ? set(value) : unset()) + // Handle searching in 'search' mode + const handleQueryChange = useCallback( + (query: string | null) => { + if ( + query === '' || // User hit backspace or + (!query && !props.value && prevQuery) // they cleared out an existing value or query + ) { + if (!data) { + void fetchData() + } + } + if (query) { + void fetchData(query) + } + setPrevQuery(query) + }, + [fetchData, data, props.value, prevQuery], + ) + + // Keep a stable debounced wrapper that always calls the latest handler. + // Depending on `handleQueryChange` in useMemo would recreate (and cancel) the + // debounce after every search finishes (`data`/`prevQuery` change), dropping + // keystrokes typed while a request was in flight. + const handleQueryChangeRef = useRef(handleQueryChange) + useEffect(() => { + handleQueryChangeRef.current = handleQueryChange + }, [handleQueryChange]) + + // oxlint-disable react/react-compiler -- stable debounce instance; latest handler via ref + const debouncedHandler = useMemo( + () => debounce((value: string | null) => handleQueryChangeRef.current(value), 300), + [], + ) + // oxlint-enable react/react-compiler + + // Cancel only on unmount — not when the underlying handler identity changes. + useEffect(() => () => debouncedHandler.cancel(), [debouncedHandler]) + + // Render error state as a readonly string field + if (error) { + const readOnlyProps = { + ...props, + elementProps: {...props.elementProps, readOnly: true}, + } + + return ( + + {readOnlyProps.renderDefault(readOnlyProps)} + + {error.message} + + + ) + } + if (props.readOnly) { + return {props.renderDefault(props)} + } + return ( + + true : undefined) + } + icon={loading ? LoadingIcon : (options.autocompleteProps?.icon ?? SearchIcon)} + openButton={options.autocompleteProps?.openButton ?? true} + onChange={handleChange} + options={data ?? []} + value={props.value} + onQueryChange={ + options.loaderType === 'search' + ? debouncedHandler + : options.autocompleteProps?.onQueryChange + } + /> + + {showSettings && options.secrets?.keys && ( + setShowSettings(false)} + /> + )} + {options.secrets && ( + +
) -}) +} export {SfccDocumentStatus} diff --git a/plugins/@sanity/sfcc/src/components/SfccOfflineBanner.tsx b/plugins/@sanity/sfcc/src/components/SfccOfflineBanner.tsx index 7a756eb904..1403159e28 100644 --- a/plugins/@sanity/sfcc/src/components/SfccOfflineBanner.tsx +++ b/plugins/@sanity/sfcc/src/components/SfccOfflineBanner.tsx @@ -1,4 +1,4 @@ -import {WarningOutlineIcon} from '@sanity/icons' +import {WarningOutlineIcon} from '@sanity/icons/WarningOutline' import {Card, Flex, Text} from '@sanity/ui' import {useFormValue} from 'sanity' diff --git a/plugins/@sanity/sfcc/src/documentActions/sfccDelete.tsx b/plugins/@sanity/sfcc/src/documentActions/sfccDelete.tsx index 33af31938a..19bceb5456 100644 --- a/plugins/@sanity/sfcc/src/documentActions/sfccDelete.tsx +++ b/plugins/@sanity/sfcc/src/documentActions/sfccDelete.tsx @@ -1,4 +1,4 @@ -import {TrashIcon} from '@sanity/icons' +import {TrashIcon} from '@sanity/icons/Trash' import {Stack, Text, useToast} from '@sanity/ui' import {useState} from 'react' import {type DocumentActionComponent, useClient} from 'sanity' diff --git a/plugins/@sanity/sfcc/src/index.test.ts b/plugins/@sanity/sfcc/src/index.test.ts index 522799de22..ea6ac32286 100644 --- a/plugins/@sanity/sfcc/src/index.test.ts +++ b/plugins/@sanity/sfcc/src/index.test.ts @@ -16,7 +16,7 @@ test('package exports', {timeout: 30_000}, async () => { expect(manifest.exports).toMatchInlineSnapshot(` { ".": { - "SfccDocumentStatus": "object", + "SfccDocumentStatus": "function", "SfccOfflineBanner": "function", "categoryStructure": "function", "productStructure": "function", diff --git a/plugins/@sanity/sfcc/src/structure/categoryStructure.ts b/plugins/@sanity/sfcc/src/structure/categoryStructure.ts index 30fd05a250..d2df0d3565 100644 --- a/plugins/@sanity/sfcc/src/structure/categoryStructure.ts +++ b/plugins/@sanity/sfcc/src/structure/categoryStructure.ts @@ -1,7 +1,5 @@ -import type {ListItemBuilder} from 'sanity/structure' - import {defineStructure} from './index' -export const categoryStructure = defineStructure((S) => +export const categoryStructure = defineStructure((S) => S.listItem().title('Categories').schemaType('category').child(S.documentTypeList('category')), ) diff --git a/plugins/@sanity/sfcc/src/structure/productStructure.ts b/plugins/@sanity/sfcc/src/structure/productStructure.ts index 66fcc76673..a45303a01a 100644 --- a/plugins/@sanity/sfcc/src/structure/productStructure.ts +++ b/plugins/@sanity/sfcc/src/structure/productStructure.ts @@ -1,9 +1,7 @@ -import type {ListItemBuilder} from 'sanity/structure' - import {API_VERSION} from '../constants' import {defineStructure} from './index' -export const productStructure = defineStructure((S, context) => { +export const productStructure = defineStructure((S, context) => { const client = context.getClient({apiVersion: API_VERSION}) return S.listItem() diff --git a/plugins/@sanity/sfcc/tsconfig.build.json b/plugins/@sanity/sfcc/tsconfig.build.json deleted file mode 100644 index a3da93d3eb..0000000000 --- a/plugins/@sanity/sfcc/tsconfig.build.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@repo/tsconfig/build.json", - "include": ["src/**/*.ts", "src/**/*.tsx"], - "exclude": ["dist", "node_modules"], - "compilerOptions": { - "isolatedDeclarations": false - } -} diff --git a/plugins/@sanity/sfcc/tsconfig.json b/plugins/@sanity/sfcc/tsconfig.json index 82b6db3792..f55220a341 100644 --- a/plugins/@sanity/sfcc/tsconfig.json +++ b/plugins/@sanity/sfcc/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@repo/tsconfig/check.json", + "extends": ["@sanity/tsconfig/strictest"], "include": ["**/*.ts", "**/*.tsx"], "exclude": ["dist", "node_modules"] } diff --git a/plugins/@sanity/sfcc/tsdown.config.ts b/plugins/@sanity/sfcc/tsdown.config.ts new file mode 100644 index 0000000000..757ef5c8f1 --- /dev/null +++ b/plugins/@sanity/sfcc/tsdown.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from '@sanity/tsdown-config' +import type {UserConfig} from 'tsdown' + +export default defineConfig({ + styledComponents: true, + reactCompiler: true, +}) satisfies Promise diff --git a/plugins/@sanity/studio-secrets/CHANGELOG.md b/plugins/@sanity/studio-secrets/CHANGELOG.md index c5877d08ac..3767381cf2 100644 --- a/plugins/@sanity/studio-secrets/CHANGELOG.md +++ b/plugins/@sanity/studio-secrets/CHANGELOG.md @@ -1,5 +1,71 @@ # @sanity/studio-secrets +## 4.0.15 + +### Patch Changes + +- [#1792](https://github.com/sanity-io/plugins/pull/1792) [`c61bb44`](https://github.com/sanity-io/plugins/commit/c61bb4444b326668e03a4d83a9853bec7a638d15) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency react-rx to ^4.2.5 + +## 4.0.14 + +### Patch Changes + +- [#1702](https://github.com/sanity-io/plugins/pull/1702) [`2a3a7ea`](https://github.com/sanity-io/plugins/commit/2a3a7eab8616981991e4a0b345ebe866a5fec8df) Thanks [@stipsan](https://github.com/stipsan)! - Update `@sanity/ui` dependency to ^3.4.3. + +## 4.0.13 + +### Patch Changes + +- [#1684](https://github.com/sanity-io/plugins/pull/1684) [`4ea0d1f`](https://github.com/sanity-io/plugins/commit/4ea0d1fd2eeb05b80f38e11aa17ca29390115999) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Update `@sanity/ui` dependency to the latest catalog version. + +## 4.0.12 + +### Patch Changes + +- [#1622](https://github.com/sanity-io/plugins/pull/1622) [`6fe3c11`](https://github.com/sanity-io/plugins/commit/6fe3c11e32b8187a19fbdc333e4a8b159fe5a616) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.9 + +## 4.0.11 + +### Patch Changes + +- [#1596](https://github.com/sanity-io/plugins/pull/1596) [`f06fd76`](https://github.com/sanity-io/plugins/commit/f06fd767531740a09a5755f41fa1d3d42da202ae) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.8 + +## 4.0.10 + +### Patch Changes + +- [#1571](https://github.com/sanity-io/plugins/pull/1571) [`52975b2`](https://github.com/sanity-io/plugins/commit/52975b2f0d4ea5086c800b2ce16190b862284a95) Thanks [@stipsan](https://github.com/stipsan)! - fix(deps): update tsdown to ^0.22.7 and @sanity/tsdown-config to ^0.14.0 + +## 4.0.9 + +### Patch Changes + +- [#1519](https://github.com/sanity-io/plugins/pull/1519) [`a11d511`](https://github.com/sanity-io/plugins/commit/a11d511b371b332adc08197711583951eb294166) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.5 + +## 4.0.8 + +### Patch Changes + +- [#1493](https://github.com/sanity-io/plugins/pull/1493) [`1a6465d`](https://github.com/sanity-io/plugins/commit/1a6465d2548e8fe8b034f58b89a905a6ad74bd3a) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency react-rx to ^4.2.3 + +## 4.0.7 + +### Patch Changes + +- [#1491](https://github.com/sanity-io/plugins/pull/1491) [`2361892`](https://github.com/sanity-io/plugins/commit/236189294b6408c9bced43765e53cf26a11a0e66) Thanks [@stipsan](https://github.com/stipsan)! - Build with `tsdown` instead of `@sanity/pkg-utils`. Internal build-tooling change only, with no intended changes to the public API or runtime behavior. + +## 4.0.6 + +### Patch Changes + +- [#1460](https://github.com/sanity-io/plugins/pull/1460) [`f50f060`](https://github.com/sanity-io/plugins/commit/f50f0605968e5cec4f23f5f3455abe5c8ddda23c) Thanks [@stipsan](https://github.com/stipsan)! - Regenerate TypeScript declaration output: `isolatedDeclarations` is no longer used and declarations are now generated with tsgo (`@typescript/native-preview`). Internal build-tooling change only, with no runtime behavior or public API changes. + +## 4.0.5 + +### Patch Changes + +- [#980](https://github.com/sanity-io/plugins/pull/980) [`98d148e`](https://github.com/sanity-io/plugins/commit/98d148e00ef679b422e1effe7fc53dfce9cb046c) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Update `@sanity/pkg-utils` to pick up a DTS generation bug fix. + ## 4.0.4 ### Patch Changes @@ -31,11 +97,13 @@ - [#568](https://github.com/sanity-io/plugins/pull/568) [`f49588a`](https://github.com/sanity-io/plugins/commit/f49588a397a5c9c655272efc6085d697f44d7083) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Port @sanity/studio-secrets plugin to the plugins monorepo **Breaking Changes:** + - Require React 19 and Sanity Studio v5 - Drop CJS output, ESM only - Enable React Compiler **Code Modernization:** + - Fixed TypeScript linting issues for strict type checking - Fixed floating promises with proper void operator usage - Replaced deprecated `React.FormEvent` with `ChangeEvent` diff --git a/plugins/@sanity/studio-secrets/package.config.ts b/plugins/@sanity/studio-secrets/package.config.ts deleted file mode 100644 index 43da34cfa9..0000000000 --- a/plugins/@sanity/studio-secrets/package.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import config from '@repo/package.config' -import {defineConfig} from '@sanity/pkg-utils' - -export default defineConfig({ - ...config, - babel: {reactCompiler: true}, - reactCompilerOptions: {target: '19'}, -}) diff --git a/plugins/@sanity/studio-secrets/package.json b/plugins/@sanity/studio-secrets/package.json index e11d06db84..4c56310536 100644 --- a/plugins/@sanity/studio-secrets/package.json +++ b/plugins/@sanity/studio-secrets/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/studio-secrets", - "version": "4.0.4", + "version": "4.0.15", "description": "React hooks and UI for reading and managing secrets in a Sanity Studio. This is a good pattern for keeping configuration secret. Instead of using environment variables which would be bundled with the Studio source (it is an SPA), we store secret information in a separate, access-controlled dataset that is only queried at runtime.", "keywords": [ "sanity", @@ -23,11 +23,7 @@ "type": "module", "types": "./dist/index.d.ts", "exports": { - ".": { - "source": "./src/index.ts", - "development": "./src/index.ts", - "default": "./dist/index.js" - }, + ".": "./src/index.ts", "./package.json": "./package.json" }, "publishConfig": { @@ -37,34 +33,34 @@ } }, "scripts": { - "build": "pkg build --strict --check --clean", + "build": "tsdown", "prepack": "turbo run build" }, "dependencies": { "@sanity/ui": "catalog:", - "react-rx": "^4.2.2", + "react-rx": "catalog:", "rxjs": "catalog:" }, "devDependencies": { - "@repo/package.config": "workspace:*", - "@repo/tsconfig": "workspace:*", - "@sanity/pkg-utils": "catalog:", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", + "@sanity/tsconfig": "catalog:", + "@sanity/tsdown-config": "catalog:", + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "babel-plugin-react-compiler": "catalog:", - "jsdom": "^29.0.1", + "jsdom": "catalog:", "react": "catalog:", "react-dom": "catalog:", "sanity": "catalog:", - "styled-components": "catalog:" + "styled-components": "catalog:", + "tsdown": "catalog:" }, "peerDependencies": { - "react": "^19.2", - "react-dom": "^19.2", - "sanity": "^5 || ^6.0.0-0" + "react": "catalog:peer", + "react-dom": "catalog:peer", + "sanity": "catalog:peer" }, "engines": { "node": ">=20.19 <22 || >=22.12" diff --git a/plugins/@sanity/studio-secrets/src/Settings.tsx b/plugins/@sanity/studio-secrets/src/Settings.tsx index 2c660cde48..cfa87ba0a8 100644 --- a/plugins/@sanity/studio-secrets/src/Settings.tsx +++ b/plugins/@sanity/studio-secrets/src/Settings.tsx @@ -39,7 +39,7 @@ export const SettingsView = ({ // See: https://github.com/facebook/react/issues/34743 useEffect(() => { if (secrets) { - // oxlint-disable-next-line react-hooks-js/set-state-in-effect + // oxlint-disable-next-line react/react-compiler setNewSecrets(secrets) } }, [secrets]) diff --git a/plugins/@sanity/studio-secrets/tsconfig.build.json b/plugins/@sanity/studio-secrets/tsconfig.build.json deleted file mode 100644 index a3da93d3eb..0000000000 --- a/plugins/@sanity/studio-secrets/tsconfig.build.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@repo/tsconfig/build.json", - "include": ["src/**/*.ts", "src/**/*.tsx"], - "exclude": ["dist", "node_modules"], - "compilerOptions": { - "isolatedDeclarations": false - } -} diff --git a/plugins/@sanity/studio-secrets/tsconfig.json b/plugins/@sanity/studio-secrets/tsconfig.json index 82b6db3792..f55220a341 100644 --- a/plugins/@sanity/studio-secrets/tsconfig.json +++ b/plugins/@sanity/studio-secrets/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@repo/tsconfig/check.json", + "extends": ["@sanity/tsconfig/strictest"], "include": ["**/*.ts", "**/*.tsx"], "exclude": ["dist", "node_modules"] } diff --git a/plugins/@sanity/studio-secrets/tsdown.config.ts b/plugins/@sanity/studio-secrets/tsdown.config.ts new file mode 100644 index 0000000000..31e58147f3 --- /dev/null +++ b/plugins/@sanity/studio-secrets/tsdown.config.ts @@ -0,0 +1,6 @@ +import {defineConfig} from '@sanity/tsdown-config' +import type {UserConfig} from 'tsdown' + +export default defineConfig({ + reactCompiler: true, +}) satisfies Promise diff --git a/plugins/@sanity/table/CHANGELOG.md b/plugins/@sanity/table/CHANGELOG.md new file mode 100644 index 0000000000..d7b0861890 --- /dev/null +++ b/plugins/@sanity/table/CHANGELOG.md @@ -0,0 +1,160 @@ +# @sanity/table + +## 3.1.12 + +### Patch Changes + +- [#1702](https://github.com/sanity-io/plugins/pull/1702) [`2a3a7ea`](https://github.com/sanity-io/plugins/commit/2a3a7eab8616981991e4a0b345ebe866a5fec8df) Thanks [@stipsan](https://github.com/stipsan)! - Update `@sanity/ui` dependency to ^3.4.3. + +## 3.1.11 + +### Patch Changes + +- [#1684](https://github.com/sanity-io/plugins/pull/1684) [`4ea0d1f`](https://github.com/sanity-io/plugins/commit/4ea0d1fd2eeb05b80f38e11aa17ca29390115999) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Update `@sanity/icons` dependency to the latest catalog version. + +- [#1684](https://github.com/sanity-io/plugins/pull/1684) [`4ea0d1f`](https://github.com/sanity-io/plugins/commit/4ea0d1fd2eeb05b80f38e11aa17ca29390115999) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Update `@sanity/ui` dependency to the latest catalog version. + +## 3.1.10 + +### Patch Changes + +- [#1622](https://github.com/sanity-io/plugins/pull/1622) [`6fe3c11`](https://github.com/sanity-io/plugins/commit/6fe3c11e32b8187a19fbdc333e4a8b159fe5a616) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.9 + +## 3.1.9 + +### Patch Changes + +- [#1596](https://github.com/sanity-io/plugins/pull/1596) [`f06fd76`](https://github.com/sanity-io/plugins/commit/f06fd767531740a09a5755f41fa1d3d42da202ae) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.8 + +## 3.1.8 + +### Patch Changes + +- [#1571](https://github.com/sanity-io/plugins/pull/1571) [`52975b2`](https://github.com/sanity-io/plugins/commit/52975b2f0d4ea5086c800b2ce16190b862284a95) Thanks [@stipsan](https://github.com/stipsan)! - fix(deps): update tsdown to ^0.22.7 and @sanity/tsdown-config to ^0.14.0 + +## 3.1.7 + +### Patch Changes + +- [#1519](https://github.com/sanity-io/plugins/pull/1519) [`a11d511`](https://github.com/sanity-io/plugins/commit/a11d511b371b332adc08197711583951eb294166) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.5 + +## 3.1.6 + +### Patch Changes + +- [#1491](https://github.com/sanity-io/plugins/pull/1491) [`2361892`](https://github.com/sanity-io/plugins/commit/236189294b6408c9bced43765e53cf26a11a0e66) Thanks [@stipsan](https://github.com/stipsan)! - Build with `tsdown` instead of `@sanity/pkg-utils`. Internal build-tooling change only, with no intended changes to the public API or runtime behavior. + +## 3.1.5 + +### Patch Changes + +- [#1460](https://github.com/sanity-io/plugins/pull/1460) [`f50f060`](https://github.com/sanity-io/plugins/commit/f50f0605968e5cec4f23f5f3455abe5c8ddda23c) Thanks [@stipsan](https://github.com/stipsan)! - Regenerate TypeScript declaration output: `isolatedDeclarations` is no longer used and declarations are now generated with tsgo (`@typescript/native-preview`). Internal build-tooling change only, with no runtime behavior or public API changes. + +## 3.1.4 + +### Patch Changes + +- [#1471](https://github.com/sanity-io/plugins/pull/1471) [`52487d2`](https://github.com/sanity-io/plugins/commit/52487d208f11fe2a4ccb523fab9386f3fbdd5880) Thanks [@stipsan](https://github.com/stipsan)! - Update `@sanity/icons` to v4 and adopt its per-icon import paths for smaller bundles and faster treeshaking + +## 3.1.3 + +### Patch Changes + +- [#1304](https://github.com/sanity-io/plugins/pull/1304) [`5d2195a`](https://github.com/sanity-io/plugins/commit/5d2195a8b56b1907391a6bfb9cff9ca5448bc9dc) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): Update dependency @sanity/uuid to ^3.0.3 + +## 3.1.2 + +### Patch Changes + +- [#1054](https://github.com/sanity-io/plugins/pull/1054) [`534d7b9`](https://github.com/sanity-io/plugins/commit/534d7b9b75a2aa62eed47a272c67615f5ab931a6) Thanks [@stipsan](https://github.com/stipsan)! - Fix table preview cell truncation (use the `ellipsis` text-overflow), derive the menu/dialog DOM ids from the input id so multiple table inputs (e.g. arrays of tables) don't collide on duplicate ids, and validate the add row/column count by disabling Confirm and showing a validity message for empty or out-of-range values + +## 3.1.1 + +### Patch Changes + +- [#1069](https://github.com/sanity-io/plugins/pull/1069) [`65ecb1b`](https://github.com/sanity-io/plugins/commit/65ecb1b3f724776d7ca7032697e1649dbcf34a28) Thanks [@mehmetyildizdev](https://github.com/mehmetyildizdev)! - Keep the Table menu "Add …" dialog input controlled, silencing the React controlled/uncontrolled input warning + +## 3.1.0 + +### Minor Changes + +- [#1068](https://github.com/sanity-io/plugins/pull/1068) [`1bf92b0`](https://github.com/sanity-io/plugins/commit/1bf92b0dff4cc1192fdfb8bf415246ac1d623a05) Thanks [@matthewwyndham](https://github.com/matthewwyndham)! - Add quick "Row" and "Column" buttons to the table input toolbar so a single row or column can be added without opening the menu + +## 3.0.0 + +### Major Changes + +- [#973](https://github.com/sanity-io/plugins/pull/973) [`5fc4a71`](https://github.com/sanity-io/plugins/commit/5fc4a719915b8df9df597f6a10fd69ba3f3642f5) Thanks [@stipsan](https://github.com/stipsan)! - Port @sanity/table to the Sanity plugins monorepo + + This major release includes several breaking changes as part of the migration to the monorepo: + + - **React Compiler enabled**: The package is now built with React Compiler targeting React 19 + - **ESM-only**: CommonJS support has been removed. The package now ships only ESM + - **React 19.2+ required**: Minimum React version is now 19.2 (previously ^18 || ^19) + - **react-dom 19.2+ required**: `react-dom` is now a required peer dependency + - **Sanity Studio v5+ required**: Minimum Sanity version is now v5 (Sanity v3 and v4 are no longer supported) + - **Node.js 20.19+ required**: Minimum Node.js version is now 20.19 (previously >=18) + +## [2.0.1](https://github.com/sanity-io/table/compare/v2.0.0...v2.0.1) (2025-12-29) + +### Bug Fixes + +- **deps:** allow studio v5 in peer deps ranges ([#56](https://github.com/sanity-io/table/issues/56)) ([79d97da](https://github.com/sanity-io/table/commit/79d97daaa26a64583afde58ee8818af2e7eeefb2)) + +## [2.0.0](https://github.com/sanity-io/table/compare/v1.1.4...v2.0.0) (2025-09-15) + +### ⚠ BREAKING CHANGES + +- **deps:** update @sanity/ui to 3.x (#54) + +### Features + +- **deps:** update @sanity/ui to 3.x ([#54](https://github.com/sanity-io/table/issues/54)) ([8bb3e25](https://github.com/sanity-io/table/commit/8bb3e25b01da99d2e9e6b18dff47ee5240d08c11)) + +## [1.1.4](https://github.com/sanity-io/table/compare/v1.1.3...v1.1.4) (2025-07-10) + +### Bug Fixes + +- **deps:** allow studio v4 in peer dep ranges + update main.yml ([#53](https://github.com/sanity-io/table/issues/53)) ([877bbc5](https://github.com/sanity-io/table/commit/877bbc54cb1e0a010f6d554175f4c7194f5946e7)) + +## [1.1.3](https://github.com/sanity-io/table/compare/v1.1.2...v1.1.3) (2024-12-18) + +### Bug Fixes + +- make react 19 compatible ([#49](https://github.com/sanity-io/table/issues/49)) ([a2e5eff](https://github.com/sanity-io/table/commit/a2e5effbab9fff9feb598685f163b8a1cdb25d24)) + +## [1.1.2](https://github.com/sanity-io/table/compare/v1.1.1...v1.1.2) (2024-01-17) + +### Bug Fixes + +- correct config type ([c911c23](https://github.com/sanity-io/table/commit/c911c23cdafb6c6ec659ab4a081c8d76db4536c5)) + +## [1.1.1](https://github.com/sanity-io/table/compare/v1.1.0...v1.1.1) (2024-01-17) + +### Bug Fixes + +- pass row type to table component ([35d5457](https://github.com/sanity-io/table/commit/35d545728ad97419ee7cc7b0bb674bc5e8844a85)) + +## [1.1.0](https://github.com/sanity-io/table/compare/v1.0.1...v1.1.0) (2024-01-12) + +### Features + +- add configurable row type ([4d19b67](https://github.com/sanity-io/table/commit/4d19b67197a8507aeb6125020020d7467286c7bb)) + +## [1.0.1](https://github.com/sanity-io/table/compare/v1.0.0...v1.0.1) (2022-11-25) + +### Bug Fixes + +- **deps:** sanity ^3.0.0 (works with rc.3) ([ba5c124](https://github.com/sanity-io/table/commit/ba5c124daa0dafe66b2755e861ecb91ec3c1a705)) +- preview props ([6ab72e7](https://github.com/sanity-io/table/commit/6ab72e76c400d9c1a5c6e073df79bb34e25b1990)) + +## 1.0.0 (2022-11-20) + +### ⚠ BREAKING CHANGES + +- Big thanks to the original contributors for their work! + @rdunk @kMathisBullinger and @davydog187 + +### Features + +- initial Sanity Studio v3 release ([639df1e](https://github.com/sanity-io/table/commit/639df1ee074d6e7b46291c66f49382ee20da62d3)) diff --git a/plugins/@sanity/table/LICENSE b/plugins/@sanity/table/LICENSE new file mode 100644 index 0000000000..a49e2de854 --- /dev/null +++ b/plugins/@sanity/table/LICENSE @@ -0,0 +1,24 @@ +MIT License + +Copyright (c) 2026 ʞunp ʇɹǝdnɹ +Copyright (c) 2026 Mathis Bullinger +Copyright (c) 2026 Dave Lucia +Copyright (c) 2026 Sanity.io + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/@sanity/table/README.md b/plugins/@sanity/table/README.md new file mode 100644 index 0000000000..0862ac6cdf --- /dev/null +++ b/plugins/@sanity/table/README.md @@ -0,0 +1,92 @@ +# Sanity Table Plugin + +This is a (triple) fork of the Sanity Plugin Table, now maintained by Sanity.io. + +![example](https://user-images.githubusercontent.com/8467307/48703530-e369be00-ebeb-11e8-8299-14812461aee8.gif) + +## Acknowledgements + +Big thanks to the original contributors for their work! + +- Original version: [rdunk/sanity-plugin-table](https://github.com/rdunk/sanity-plugin-table). +- Further improvements in fork [MathisBullinger/sanity-plugin-another-table](https://github.com/MathisBullinger/sanity-plugin-another-table). +- Initial Studio port: [bitfo/sanity-plugin-table](https://github.com/bitfo/sanity-plugin-table) + +## Disclaimer + +Sometimes a table is just what you need. +However, before using the Table plugin, consider if there are other ways to model your data that are: + +- easier to edit and validate +- easier to query + +Approaching your schemas in a more structured manner can often pay dividends down the line. + +## Install + +Install using npm + +```bash +$ npm i --save @sanity/table +``` + +## Usage + +Add the plugin to your project configuration. Then use the type in your schemas + +```js +// sanity.config.ts + +import {defineConfig} from 'sanity' + +import {table} from '@sanity/table' + +export default defineConfig({ + name: 'default', + title: 'My Cool Project', + projectId: 'my-project-id', + dataset: 'production', + plugins: [ + // Include the table plugin + table(), + ], + schema: { + types: [ + { + name: 'product', + title: 'Product', + type: 'document', + fields: [ + { + // Include the table as a field + // Giving it a semantic title + name: 'sizeChart', + title: 'Size Chart', + type: 'table', + }, + ], + }, + ], + }, +}) +``` + +## Configuration + +You can optionally configure the `_type` used for the row object in the table schema by passing a `rowType` when adding the plugin. For most users this is unnecessary, but it can be useful if you are migrating from a legacy table plugin. + +```js +export default defineConfig({ + // ... + plugins: [ + table({ + rowType: 'my-custom-row-type', + }), + ], + // ... +}) +``` + +## License + +[MIT](LICENSE) © ʞunp ʇɹǝdnɹ, Mathis Bullinger, Dave Lucia and Sanity.io diff --git a/plugins/@sanity/table/package.json b/plugins/@sanity/table/package.json new file mode 100644 index 0000000000..97ec2a9e1b --- /dev/null +++ b/plugins/@sanity/table/package.json @@ -0,0 +1,65 @@ +{ + "name": "@sanity/table", + "version": "3.1.12", + "description": "Table schema type and input component for Sanity Studio", + "keywords": [ + "sanity", + "sanity-plugin" + ], + "homepage": "https://github.com/sanity-io/plugins/tree/main/plugins/@sanity/table#readme", + "bugs": { + "url": "https://github.com/sanity-io/plugins/issues" + }, + "license": "MIT", + "author": "Sanity.io ", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/sanity-io/plugins.git", + "directory": "plugins/@sanity/table" + }, + "files": [ + "dist" + ], + "type": "module", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "build": "tsdown", + "prepack": "turbo run build" + }, + "dependencies": { + "@sanity/icons": "catalog:", + "@sanity/ui": "catalog:", + "@sanity/uuid": "catalog:" + }, + "devDependencies": { + "@sanity/tsconfig": "catalog:", + "@sanity/tsdown-config": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "babel-plugin-react-compiler": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "sanity": "catalog:", + "styled-components": "catalog:", + "tsdown": "catalog:" + }, + "peerDependencies": { + "react": "catalog:peer", + "react-dom": "catalog:peer", + "sanity": "catalog:peer" + }, + "engines": { + "node": ">=20.19 <22 || >=22.12" + } +} diff --git a/plugins/@sanity/table/src/components/TableComponent.tsx b/plugins/@sanity/table/src/components/TableComponent.tsx new file mode 100644 index 0000000000..d1a7ac4bb0 --- /dev/null +++ b/plugins/@sanity/table/src/components/TableComponent.tsx @@ -0,0 +1,266 @@ +import {AddIcon} from '@sanity/icons/Add' +import {Box, Button, Card, Dialog, Flex, Inline, Text} from '@sanity/ui' +import {uuid} from '@sanity/uuid' +import {type ChangeEvent, useState} from 'react' +import {type ObjectInputProps, set, unset} from 'sanity' + +import {TableInput} from './TableInput' +import {TableMenu} from './TableMenu' + +const deepClone: (data: T) => T = + globalThis.structuredClone ?? ((data) => JSON.parse(JSON.stringify(data))) + +export interface TableValue { + _type?: 'table' + rows?: TableRow[] +} + +export type TableProps = ObjectInputProps + +export type TableRow = { + _type: string + _key: string + cells: string[] +} + +// TODO refactor deepClone stuff to use proper patches +// TODO use callback all the things + +export const TableComponent = (props: TableProps & {rowType?: string}) => { + const {id, rowType = 'tableRow', value, onChange} = props + const [dialog, setDialog] = useState<{ + type: string + callback: () => void + } | null>(null) + + const updateValue = (v?: Omit) => { + return onChange(set(v)) + } + + const resetValue = () => { + return onChange(unset()) + } + + const createTable = () => { + const newValue: Omit = { + rows: [ + { + _type: rowType, + _key: uuid(), + cells: ['', ''], + }, + { + _type: rowType, + _key: uuid(), + cells: ['', ''], + }, + ], + } + return updateValue({...value, ...newValue}) + } + + const confirmRemoveTable = () => { + setDialog({type: 'table', callback: removeTable}) + } + + const removeTable = () => { + resetValue() + setDialog(null) + } + + const addRows = (count = 1) => { + if (!value?.rows) { + return + } + const newRows = deepClone(value.rows) + // Calculate the column count from the first row + const columnCount = value.rows[0]?.cells.length ?? 0 + for (let i = 0; i < count; i++) { + // Add as many cells as we have columns + newRows.push({ + _type: rowType, + _key: uuid(), + cells: Array(columnCount).fill(''), + }) + } + return updateValue({...value, rows: newRows}) + } + + const addRowAt = (index = 0) => { + if (!value?.rows) { + return + } + const newRows = deepClone(value.rows) + // Calculate the column count from the first row + const columnCount = value.rows[0]?.cells.length ?? 0 + + newRows.splice(index, 0, { + _type: rowType, + _key: uuid(), + cells: Array(columnCount).fill(''), + }) + + return updateValue({...value, rows: newRows}) + } + + const removeRow = (index: number) => { + if (!value?.rows) { + return + } + const newRows = deepClone(value.rows) + newRows.splice(index, 1) + updateValue({...value, rows: newRows}) + setDialog(null) + } + + const confirmRemoveRow = (index: number) => { + if (!value?.rows) { + return + } + if (value.rows.length <= 1) return confirmRemoveTable() + return setDialog({type: 'row', callback: () => removeRow(index)}) + } + + const confirmRemoveColumn = (index: number) => { + if (!value?.rows) { + return + } + if ((value.rows[0]?.cells.length ?? 0) <= 1) return confirmRemoveTable() + return setDialog({type: 'column', callback: () => removeColumn(index)}) + } + + const addColumns = (count: number) => { + if (!value?.rows) { + return + } + const newRows = deepClone(value.rows) + // Add a cell to each of the rows + newRows.forEach((row) => { + for (let j = 0; j < count; j++) { + row.cells.push('') + } + }) + return updateValue({...value, rows: newRows}) + } + + const addColumnAt = (index: number) => { + if (!value?.rows) { + return + } + const newRows = deepClone(value.rows) + + newRows.forEach((row) => { + row.cells.splice(index, 0, '') + }) + + return updateValue({...value, rows: newRows}) + } + + const removeColumn = (index: number) => { + if (!value?.rows) { + return + } + const newRows = deepClone(value.rows) + newRows.forEach((row) => { + row.cells.splice(index, 1) + }) + updateValue({...value, rows: newRows}) + setDialog(null) + } + + const updateCell = (e: ChangeEvent, rowIndex: number, cellIndex: number) => { + if (!value?.rows) { + return + } + const newRows = deepClone(value.rows) + const row = newRows[rowIndex] + if (!row) { + return + } + row.cells[cellIndex] = e.currentTarget.value + return updateValue({...value, rows: newRows}) + } + + return ( +
+ {dialog && ( + setDialog(null)} + zOffset={1000} + > + + Are you sure you want to remove this {dialog.type}? + + + + )} + + + {value?.rows?.length ? ( + +
+ ) +} + +export function createTableComponent(rowType: string) { + return function Table(props: TableProps) { + return + } +} diff --git a/plugins/@sanity/table/src/components/TableIcon.tsx b/plugins/@sanity/table/src/components/TableIcon.tsx new file mode 100644 index 0000000000..ee25b4a6dd --- /dev/null +++ b/plugins/@sanity/table/src/components/TableIcon.tsx @@ -0,0 +1,14 @@ +export function TableIcon() { + return ( + + + + ) +} diff --git a/plugins/@sanity/table/src/components/TableInput.tsx b/plugins/@sanity/table/src/components/TableInput.tsx new file mode 100644 index 0000000000..09b5f9f4ae --- /dev/null +++ b/plugins/@sanity/table/src/components/TableInput.tsx @@ -0,0 +1,65 @@ +import {RemoveIcon} from '@sanity/icons/Remove' +import {Box, Button, TextInput} from '@sanity/ui' +import type {ChangeEvent} from 'react' + +import type {TableRow} from './TableComponent' + +interface TableInputProps { + rows: TableRow[] + updateCell: (e: ChangeEvent, rowIndex: number, cellIndex: number) => void + removeRow: (index: number) => void + removeColumn: (index: number) => void +} + +export const TableInput = (props: TableInputProps) => { + const {rows, updateCell, removeRow, removeColumn} = props + + return ( + + + {rows.map((row, rowIndex) => ( + + {row.cells.map((cell, cellIndex) => ( + // Cells are plain strings; the cell position is the only stable identity + // eslint-disable-next-line react/no-array-index-key + + ))} + + + ))} + + {(rows[0]?.cells || []).map((_, i) => ( + // Cells are plain strings; the column position is the only stable identity + // eslint-disable-next-line react/no-array-index-key + + ))} + + +
+ updateCell(e, rowIndex, cellIndex)} + /> + + +
+ +
+ ) +} diff --git a/plugins/@sanity/table/src/components/TableMenu.tsx b/plugins/@sanity/table/src/components/TableMenu.tsx new file mode 100644 index 0000000000..9bc6e3bb4e --- /dev/null +++ b/plugins/@sanity/table/src/components/TableMenu.tsx @@ -0,0 +1,153 @@ +import {AddIcon} from '@sanity/icons/Add' +import {ControlsIcon} from '@sanity/icons/Controls' +import {WarningOutlineIcon} from '@sanity/icons/WarningOutline' +import { + Box, + Button, + Card, + Dialog, + Inline, + Menu, + MenuButton, + MenuDivider, + MenuItem, + type Placement, + TextInput, +} from '@sanity/ui' +import {type ChangeEventHandler, useState} from 'react' + +interface TableMenuProps { + id: string + addColumns: (count: number) => void + addColumnAt: (index: number) => void + addRows: (count: number) => void + addRowAt: (index: number) => void + remove: () => void + placement: Placement +} + +export const TableMenu = (props: TableMenuProps): React.JSX.Element => { + const {id, remove: handleRemove} = props + const [dialog, setDialog] = useState<{ + type: string + callback: (count: number) => void + } | null>(null) + + // Keep `count` always a string so the TextInput is controlled for the component lifetime. + const [count, setCount] = useState('') + + const updateCount: ChangeEventHandler = (e) => { + setCount(e.currentTarget.value) + } + + const addRows = () => { + setDialog({type: 'rows', callback: (c) => props.addRows(c)}) + setCount('') // ensure input starts controlled when dialog opens + } + + const addRowAt = () => { + setDialog({type: 'rows', callback: (index) => props.addRowAt(index)}) + setCount('') + } + + const addColumns = () => { + setDialog({ + type: 'columns', + callback: (c) => props.addColumns(c), + }) + setCount('') + } + + const addColumnsAt = () => { + setDialog({type: 'columns', callback: (index) => props.addColumnAt(index)}) + setCount('') + } + + // The dialog is reused for "add N rows/columns" (a count) and "add at index" + // (a position where 0 is valid), so allow 0 but reject NaN/negative/too-large. + const parsedCount = Number.parseInt(count, 10) + const isValidCount = Number.isInteger(parsedCount) && parsedCount >= 0 && parsedCount < 100 + + const onConfirm = () => { + if (!isValidCount) { + return + } + setDialog(null) + dialog?.callback(parsedCount) + setCount('') + } + + return ( + <> + {dialog && ( + { + setDialog(null) + setCount('') + }} + zOffset={1000} + > + + + + + + )} + } + id={`${id}-menu-button`} + menu={ + + + + + + + + + } + popover={{placement: props.placement}} + /> + + ) +} diff --git a/plugins/@sanity/table/src/components/TablePreview.tsx b/plugins/@sanity/table/src/components/TablePreview.tsx new file mode 100644 index 0000000000..6c2ccfc253 --- /dev/null +++ b/plugins/@sanity/table/src/components/TablePreview.tsx @@ -0,0 +1,52 @@ +import {Box, Card, Grid, Inline, Label, Text} from '@sanity/ui' +import type {PreviewProps} from 'sanity' + +import type {TableRow} from './TableComponent' +import {TableIcon} from './TableIcon' + +interface TablePreviewProps extends PreviewProps { + rows?: TableRow[] +} + +const Table = ({rows}: {rows: TableRow[]}) => { + const numCols = rows[0]?.cells.length ?? 0 + + return ( + + {rows.map((row) => + row.cells.map((cell, i) => ( + // Cells are plain strings; the cell position is the only stable identity + // eslint-disable-next-line react/no-array-index-key + + {cell} + + )), + )} + + ) +} + +export const TablePreview = (props: TablePreviewProps) => { + const {schemaType, rows = [], title} = props + const previewTitle = schemaType?.title ?? (typeof title === 'string' ? title : 'Title missing') + + return ( + <> + + + + + + + {previewTitle} + + + + + {rows.length === 0 ? : } + + + ) +} diff --git a/plugins/@sanity/table/src/index.test.ts b/plugins/@sanity/table/src/index.test.ts new file mode 100644 index 0000000000..ec9a5876f3 --- /dev/null +++ b/plugins/@sanity/table/src/index.test.ts @@ -0,0 +1,21 @@ +import {fileURLToPath} from 'node:url' + +import {expect, test} from 'vitest' +import {getPackageExportsManifest} from 'vitest-package-exports' + +test('package exports', {timeout: 30_000}, async () => { + const manifest = await getPackageExportsManifest({ + importMode: 'dist', + cwd: fileURLToPath(import.meta.url), + }) + + expect(manifest.exports).toMatchInlineSnapshot(` + { + ".": { + "TableComponent": "function", + "TablePreview": "function", + "table": "function", + }, + } + `) +}) diff --git a/plugins/@sanity/table/src/index.ts b/plugins/@sanity/table/src/index.ts new file mode 100644 index 0000000000..5aca6e31a2 --- /dev/null +++ b/plugins/@sanity/table/src/index.ts @@ -0,0 +1,64 @@ +import {definePlugin, defineType} from 'sanity' + +import {createTableComponent, TableComponent} from './components/TableComponent' +import {TablePreview} from './components/TablePreview' +export type {TableProps, TableRow, TableValue} from './components/TableComponent' + +export {TableComponent, TablePreview} + +export interface TableConfig { + rowType?: string +} + +export const table = definePlugin((config) => { + const tableRowSchema = defineType({ + title: 'Table Row', + name: config?.rowType || 'tableRow', + type: 'object', + fields: [ + { + name: 'cells', + type: 'array', + of: [{type: 'string'}], + }, + ], + }) + + const tableSchema = defineType({ + title: 'Table', + name: 'table', + type: 'object', + fields: [ + { + name: 'rows', + type: 'array', + of: [ + { + type: tableRowSchema.name, + }, + ], + }, + ], + components: { + input: createTableComponent(tableRowSchema.name), + preview: TablePreview, + }, + preview: { + select: { + rows: 'rows', + title: 'title', + }, + prepare: ({title, rows = []}) => ({ + title, + rows, + }), + }, + }) + + return { + name: 'table', + schema: { + types: [tableRowSchema, tableSchema], + }, + } +}) diff --git a/plugins/@sanity/table/tsconfig.json b/plugins/@sanity/table/tsconfig.json new file mode 100644 index 0000000000..f55220a341 --- /dev/null +++ b/plugins/@sanity/table/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": ["@sanity/tsconfig/strictest"], + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["dist", "node_modules"] +} diff --git a/plugins/@sanity/table/tsdown.config.ts b/plugins/@sanity/table/tsdown.config.ts new file mode 100644 index 0000000000..31e58147f3 --- /dev/null +++ b/plugins/@sanity/table/tsdown.config.ts @@ -0,0 +1,6 @@ +import {defineConfig} from '@sanity/tsdown-config' +import type {UserConfig} from 'tsdown' + +export default defineConfig({ + reactCompiler: true, +}) satisfies Promise diff --git a/plugins/@sanity/table/vitest.config.ts b/plugins/@sanity/table/vitest.config.ts new file mode 100644 index 0000000000..8eda73152e --- /dev/null +++ b/plugins/@sanity/table/vitest.config.ts @@ -0,0 +1,11 @@ +import {defineConfig} from 'vitest/config' + +export default defineConfig({ + test: { + server: { + deps: { + inline: ['vitest-package-exports'], + }, + }, + }, +}) diff --git a/plugins/@sanity/vercel-protection-bypass/CHANGELOG.md b/plugins/@sanity/vercel-protection-bypass/CHANGELOG.md index 067cd3ec1c..2dd81d895a 100644 --- a/plugins/@sanity/vercel-protection-bypass/CHANGELOG.md +++ b/plugins/@sanity/vercel-protection-bypass/CHANGELOG.md @@ -1,5 +1,73 @@ # @sanity/vercel-protection-bypass +## 5.0.19 + +### Patch Changes + +- [#1702](https://github.com/sanity-io/plugins/pull/1702) [`2a3a7ea`](https://github.com/sanity-io/plugins/commit/2a3a7eab8616981991e4a0b345ebe866a5fec8df) Thanks [@stipsan](https://github.com/stipsan)! - Update `@sanity/ui` dependency to ^3.4.3. + +## 5.0.18 + +### Patch Changes + +- [#1684](https://github.com/sanity-io/plugins/pull/1684) [`4ea0d1f`](https://github.com/sanity-io/plugins/commit/4ea0d1fd2eeb05b80f38e11aa17ca29390115999) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Update `@sanity/icons` dependency to the latest catalog version. + +- [#1684](https://github.com/sanity-io/plugins/pull/1684) [`4ea0d1f`](https://github.com/sanity-io/plugins/commit/4ea0d1fd2eeb05b80f38e11aa17ca29390115999) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Update `@sanity/ui` dependency to the latest catalog version. + +## 5.0.17 + +### Patch Changes + +- [#1622](https://github.com/sanity-io/plugins/pull/1622) [`6fe3c11`](https://github.com/sanity-io/plugins/commit/6fe3c11e32b8187a19fbdc333e4a8b159fe5a616) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.9 + +## 5.0.16 + +### Patch Changes + +- [#1596](https://github.com/sanity-io/plugins/pull/1596) [`f06fd76`](https://github.com/sanity-io/plugins/commit/f06fd767531740a09a5755f41fa1d3d42da202ae) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.8 + +## 5.0.15 + +### Patch Changes + +- [#1571](https://github.com/sanity-io/plugins/pull/1571) [`52975b2`](https://github.com/sanity-io/plugins/commit/52975b2f0d4ea5086c800b2ce16190b862284a95) Thanks [@stipsan](https://github.com/stipsan)! - fix(deps): update tsdown to ^0.22.7 and @sanity/tsdown-config to ^0.14.0 + +## 5.0.14 + +### Patch Changes + +- [#1519](https://github.com/sanity-io/plugins/pull/1519) [`a11d511`](https://github.com/sanity-io/plugins/commit/a11d511b371b332adc08197711583951eb294166) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.5 + +## 5.0.13 + +### Patch Changes + +- [#1491](https://github.com/sanity-io/plugins/pull/1491) [`2361892`](https://github.com/sanity-io/plugins/commit/236189294b6408c9bced43765e53cf26a11a0e66) Thanks [@stipsan](https://github.com/stipsan)! - Build with `tsdown` instead of `@sanity/pkg-utils`. Internal build-tooling change only, with no intended changes to the public API or runtime behavior. + +## 5.0.12 + +### Patch Changes + +- [#1460](https://github.com/sanity-io/plugins/pull/1460) [`f50f060`](https://github.com/sanity-io/plugins/commit/f50f0605968e5cec4f23f5f3455abe5c8ddda23c) Thanks [@stipsan](https://github.com/stipsan)! - Regenerate TypeScript declaration output: `isolatedDeclarations` is no longer used and declarations are now generated with tsgo (`@typescript/native-preview`). Internal build-tooling change only, with no runtime behavior or public API changes. + +## 5.0.11 + +### Patch Changes + +- [#1471](https://github.com/sanity-io/plugins/pull/1471) [`52487d2`](https://github.com/sanity-io/plugins/commit/52487d208f11fe2a4ccb523fab9386f3fbdd5880) Thanks [@stipsan](https://github.com/stipsan)! - Update `@sanity/icons` to v4 and adopt its per-icon import paths for smaller bundles and faster treeshaking + +## 5.0.10 + +### Patch Changes + +- [#994](https://github.com/sanity-io/plugins/pull/994) [`3694754`](https://github.com/sanity-io/plugins/commit/3694754a3abc1b435356a87e03c322efd3125266) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): Update dependency @sanity/preview-url-secret to ^4.0.7 + +## 5.0.9 + +### Patch Changes + +- [#980](https://github.com/sanity-io/plugins/pull/980) [`98d148e`](https://github.com/sanity-io/plugins/commit/98d148e00ef679b422e1effe7fc53dfce9cb046c) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Update `@sanity/pkg-utils` to pick up a DTS generation bug fix. + ## 5.0.8 ### Patch Changes diff --git a/plugins/@sanity/vercel-protection-bypass/package.config.ts b/plugins/@sanity/vercel-protection-bypass/package.config.ts deleted file mode 100644 index 43da34cfa9..0000000000 --- a/plugins/@sanity/vercel-protection-bypass/package.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import config from '@repo/package.config' -import {defineConfig} from '@sanity/pkg-utils' - -export default defineConfig({ - ...config, - babel: {reactCompiler: true}, - reactCompilerOptions: {target: '19'}, -}) diff --git a/plugins/@sanity/vercel-protection-bypass/package.json b/plugins/@sanity/vercel-protection-bypass/package.json index 557f64304f..398e6e0d92 100644 --- a/plugins/@sanity/vercel-protection-bypass/package.json +++ b/plugins/@sanity/vercel-protection-bypass/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/vercel-protection-bypass", - "version": "5.0.8", + "version": "5.0.19", "keywords": [ "sanity", "sanity-plugin", @@ -24,11 +24,7 @@ "type": "module", "types": "./dist/index.d.ts", "exports": { - ".": { - "development": "./src/index.ts", - "source": "./src/index.ts", - "default": "./dist/index.js" - }, + ".": "./src/index.ts", "./package.json": "./package.json" }, "publishConfig": { @@ -38,29 +34,29 @@ } }, "scripts": { - "build": "pkg build --strict --check --clean", + "build": "tsdown", "prepack": "turbo run build" }, "dependencies": { - "@sanity/icons": "^3.7.4", - "@sanity/preview-url-secret": "^4.0.4", + "@sanity/icons": "catalog:", + "@sanity/preview-url-secret": "catalog:", "@sanity/ui": "catalog:" }, "devDependencies": { - "@repo/package.config": "workspace:*", - "@repo/tsconfig": "workspace:*", "@sanity/client": "catalog:", - "@sanity/pkg-utils": "catalog:", + "@sanity/tsconfig": "catalog:", + "@sanity/tsdown-config": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "babel-plugin-react-compiler": "catalog:", "react": "catalog:", "sanity": "catalog:", - "styled-components": "catalog:" + "styled-components": "catalog:", + "tsdown": "catalog:" }, "peerDependencies": { - "react": "^19.2", - "sanity": "^5 || ^6.0.0-0" + "react": "catalog:peer", + "sanity": "catalog:peer" }, "engines": { "node": ">=20.19 <22 || >=22.12" diff --git a/plugins/@sanity/vercel-protection-bypass/src/VercelProtectionBypassTool.tsx b/plugins/@sanity/vercel-protection-bypass/src/VercelProtectionBypassTool.tsx index dac7c9bb96..1e34cafa75 100644 --- a/plugins/@sanity/vercel-protection-bypass/src/VercelProtectionBypassTool.tsx +++ b/plugins/@sanity/vercel-protection-bypass/src/VercelProtectionBypassTool.tsx @@ -1,5 +1,6 @@ import type {SyncTag, LiveEvent} from '@sanity/client' -import {AddIcon, TrashIcon} from '@sanity/icons' +import {AddIcon} from '@sanity/icons/Add' +import {TrashIcon} from '@sanity/icons/Trash' import { apiVersion, vercelProtectionBypassSchemaId as _id, diff --git a/plugins/@sanity/vercel-protection-bypass/src/index.ts b/plugins/@sanity/vercel-protection-bypass/src/index.ts index 075f6e8017..9bb705cc46 100644 --- a/plugins/@sanity/vercel-protection-bypass/src/index.ts +++ b/plugins/@sanity/vercel-protection-bypass/src/index.ts @@ -1,4 +1,6 @@ -import {CheckmarkCircleIcon, CloseCircleIcon, LockIcon} from '@sanity/icons' +import {CheckmarkCircleIcon} from '@sanity/icons/CheckmarkCircle' +import {CloseCircleIcon} from '@sanity/icons/CloseCircle' +import {LockIcon} from '@sanity/icons/Lock' import { vercelProtectionBypassSchemaId as _id, vercelProtectionBypassSchemaType as type, diff --git a/plugins/@sanity/vercel-protection-bypass/tsconfig.build.json b/plugins/@sanity/vercel-protection-bypass/tsconfig.build.json deleted file mode 100644 index ad94748dcb..0000000000 --- a/plugins/@sanity/vercel-protection-bypass/tsconfig.build.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "@repo/tsconfig/build.json", - "include": ["src/**/*.ts", "src/**/*.tsx"], - "exclude": ["dist", "node_modules"] -} diff --git a/plugins/@sanity/vercel-protection-bypass/tsconfig.json b/plugins/@sanity/vercel-protection-bypass/tsconfig.json index 82b6db3792..f55220a341 100644 --- a/plugins/@sanity/vercel-protection-bypass/tsconfig.json +++ b/plugins/@sanity/vercel-protection-bypass/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@repo/tsconfig/check.json", + "extends": ["@sanity/tsconfig/strictest"], "include": ["**/*.ts", "**/*.tsx"], "exclude": ["dist", "node_modules"] } diff --git a/plugins/@sanity/vercel-protection-bypass/tsdown.config.ts b/plugins/@sanity/vercel-protection-bypass/tsdown.config.ts new file mode 100644 index 0000000000..31e58147f3 --- /dev/null +++ b/plugins/@sanity/vercel-protection-bypass/tsdown.config.ts @@ -0,0 +1,6 @@ +import {defineConfig} from '@sanity/tsdown-config' +import type {UserConfig} from 'tsdown' + +export default defineConfig({ + reactCompiler: true, +}) satisfies Promise diff --git a/plugins/sanity-naive-html-serializer/CHANGELOG.md b/plugins/sanity-naive-html-serializer/CHANGELOG.md new file mode 100644 index 0000000000..0cef763d39 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/CHANGELOG.md @@ -0,0 +1,142 @@ +# sanity-naive-html-serializer + +## 5.1.12 + +### Patch Changes + +- [#1622](https://github.com/sanity-io/plugins/pull/1622) [`6fe3c11`](https://github.com/sanity-io/plugins/commit/6fe3c11e32b8187a19fbdc333e4a8b159fe5a616) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.9 + +## 5.1.11 + +### Patch Changes + +- [#1596](https://github.com/sanity-io/plugins/pull/1596) [`f06fd76`](https://github.com/sanity-io/plugins/commit/f06fd767531740a09a5755f41fa1d3d42da202ae) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.8 + +## 5.1.10 + +### Patch Changes + +- [#1571](https://github.com/sanity-io/plugins/pull/1571) [`52975b2`](https://github.com/sanity-io/plugins/commit/52975b2f0d4ea5086c800b2ce16190b862284a95) Thanks [@stipsan](https://github.com/stipsan)! - fix(deps): update tsdown to ^0.22.7 and @sanity/tsdown-config to ^0.14.0 + +## 5.1.9 + +### Patch Changes + +- [#1519](https://github.com/sanity-io/plugins/pull/1519) [`a11d511`](https://github.com/sanity-io/plugins/commit/a11d511b371b332adc08197711583951eb294166) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.5 + +## 5.1.8 + +### Patch Changes + +- [#1476](https://github.com/sanity-io/plugins/pull/1476) [`b8bc962`](https://github.com/sanity-io/plugins/commit/b8bc96275b26a3d219a55cd22e3d29b27e331e11) Thanks [@stipsan](https://github.com/stipsan)! - Remove redundant type assertions in block deserialization (internal refactor, no API change) + +## 5.1.7 + +### Patch Changes + +- [#1491](https://github.com/sanity-io/plugins/pull/1491) [`2361892`](https://github.com/sanity-io/plugins/commit/236189294b6408c9bced43765e53cf26a11a0e66) Thanks [@stipsan](https://github.com/stipsan)! - Build with `tsdown` instead of `@sanity/pkg-utils`. Internal build-tooling change only, with no intended changes to the public API or runtime behavior. + +## 5.1.6 + +### Patch Changes + +- [#1460](https://github.com/sanity-io/plugins/pull/1460) [`f50f060`](https://github.com/sanity-io/plugins/commit/f50f0605968e5cec4f23f5f3455abe5c8ddda23c) Thanks [@stipsan](https://github.com/stipsan)! - Regenerate TypeScript declaration output: `isolatedDeclarations` is no longer used and declarations are now generated with tsgo (`@typescript/native-preview`). Internal build-tooling change only, with no runtime behavior or public API changes. + +## 5.1.5 + +### Patch Changes + +- [`953cbf5`](https://github.com/sanity-io/plugins/commit/953cbf5c06d7c8a5191ee8534fa0d871b8f7cf0c) Thanks [@stipsan](https://github.com/stipsan)! - Use type-only imports for type references to satisfy `verbatimModuleSyntax` + +## 5.1.4 + +### Patch Changes + +- [#1299](https://github.com/sanity-io/plugins/pull/1299) [`eaa6280`](https://github.com/sanity-io/plugins/commit/eaa6280d729f6e3b4436e7b2fc2556b4580e4afe) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): Update dependency @portabletext/block-tools to ^5.1.5 + +- [#1345](https://github.com/sanity-io/plugins/pull/1345) [`c6e8859`](https://github.com/sanity-io/plugins/commit/c6e88593379d8890246f212fb12916f3b99f78d5) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): Update dependency @portabletext/block-tools to ^5.1.6 + +## 5.1.3 + +### Patch Changes + +- [#1002](https://github.com/sanity-io/plugins/pull/1002) [`a57034d`](https://github.com/sanity-io/plugins/commit/a57034d68067548b6366f0f6b2478e1ab4e79875) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): Update dependency @portabletext/to-html to v4 + +- [#998](https://github.com/sanity-io/plugins/pull/998) [`f63f575`](https://github.com/sanity-io/plugins/commit/f63f5755a25584af6ac41b7f2ef466eb8318584a) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): Update dependency @portabletext/block-tools to v5 + +- [#999](https://github.com/sanity-io/plugins/pull/999) [`143b038`](https://github.com/sanity-io/plugins/commit/143b0384c48e01d7417676f35a23b23c212a8dce) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): Update dependency @portabletext/to-html to v5 + +## 5.1.2 + +### Patch Changes + +- [#980](https://github.com/sanity-io/plugins/pull/980) [`98d148e`](https://github.com/sanity-io/plugins/commit/98d148e00ef679b422e1effe7fc53dfce9cb046c) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Update `@sanity/pkg-utils` to pick up a DTS generation bug fix. + +## 5.1.1 + +### Patch Changes + +- [#964](https://github.com/sanity-io/plugins/pull/964) [`4226408`](https://github.com/sanity-io/plugins/commit/4226408594d2717cf2503866f5d5216991701d38) Thanks [@stipsan](https://github.com/stipsan)! - Update `@sanity/mutator`, `@sanity/schema`, and `@sanity/util` dependencies to v6, in line with Sanity Studio v6 + +## 5.1.0 + +### Minor Changes + +- [#957](https://github.com/sanity-io/plugins/pull/957) [`310b5fe`](https://github.com/sanity-io/plugins/commit/310b5fe3070eafe89f3cafea48568a3577a3a118) Thanks [@pedrobonamin](https://github.com/pedrobonamin)! - Support both internationalized array data formats. The serializer, deserializer, and merger now read the language from either the legacy `_key` or the new `language` field (`sanity-plugin-internationalized-array` v5), and write merged translations back in whichever format the source document already uses. + +## 5.0.0 + +### Major Changes + +- [#931](https://github.com/sanity-io/plugins/pull/931) [`5a9204f`](https://github.com/sanity-io/plugins/commit/5a9204fcf31f00eb5c96c1368ab21cef088cf8f4) Thanks [@pedrobonamin](https://github.com/pedrobonamin)! - Port sanity-naive-html-serializer to the Sanity plugins monorepo + + This major release includes several breaking changes as part of the migration to the monorepo: + + - **React Compiler enabled**: The package is now built with React Compiler targeting React 19 + - **ESM-only**: CommonJS support has been removed. The package now ships only ESM + - **React 19.2+ required**: Minimum React version is now 19.2 (previously ^18.3 || ^19) + - **react-dom 19.2+ required**: `react-dom` is now a required peer dependency + - **Sanity Studio v5+ required**: Minimum Sanity version is now v5 (Sanity v3 and v4 are no longer supported) + - **Node.js 20.19+ required**: Minimum Node.js version is now 20.19 (previously >=18) + +## [4.1.0](https://github.com/sanity-io/sanity-naive-html-serializer/compare/v4.0.2...v4.1.0) (2026-02-08) + +### Features + +- i18n array serialization ([#62](https://github.com/sanity-io/sanity-naive-html-serializer/issues/62)) ([174ded7](https://github.com/sanity-io/sanity-naive-html-serializer/commit/174ded7a31948758aaa1897014941c1373970388)) + +## [4.0.2](https://github.com/sanity-io/sanity-naive-html-serializer/compare/v4.0.1...v4.0.2) (2025-12-29) + +### Bug Fixes + +- **deps:** allow studio v5 in peer deps ranges ([#92](https://github.com/sanity-io/sanity-naive-html-serializer/issues/92)) ([7e4b4c0](https://github.com/sanity-io/sanity-naive-html-serializer/commit/7e4b4c03565cc33d0ed8d8241b33ad07de97daab)) +- **deps:** Update dependency @portabletext/block-tools to v4 ([#89](https://github.com/sanity-io/sanity-naive-html-serializer/issues/89)) ([a277cdd](https://github.com/sanity-io/sanity-naive-html-serializer/commit/a277cddbb6a7b6c1c8d3adb9b91b6990af72d8d3)) + +## [4.0.1](https://github.com/sanity-io/sanity-naive-html-serializer/compare/v4.0.0...v4.0.1) (2025-11-12) + +### Bug Fixes + +- **deps:** update sanity monorepo to v4 ([#88](https://github.com/sanity-io/sanity-naive-html-serializer/issues/88)) ([e756db1](https://github.com/sanity-io/sanity-naive-html-serializer/commit/e756db1cb3483a1facada76f84434ef280495b25)) + +## [4.0.0](https://github.com/sanity-io/sanity-naive-html-serializer/compare/v3.2.0...v4.0.0) (2025-11-03) + +### ⚠ BREAKING CHANGES + +- Requires Node.js >=18. Updated from @sanity/block-tools to @portabletext/block-tools. + +### Miscellaneous Chores + +- upgrade to plugin-kit v3 and pkg-utils v8 ([#84](https://github.com/sanity-io/sanity-naive-html-serializer/issues/84)) ([5f1c214](https://github.com/sanity-io/sanity-naive-html-serializer/commit/5f1c214b89873301e8bb0f90878335e508dd47e0)) + +## [3.2.0](https://github.com/sanity-io/sanity-naive-html-serializer/compare/v3.1.9...v3.2.0) (2025-07-10) + +### Features + +- **deps:** bump all non-major ([1440591](https://github.com/sanity-io/sanity-naive-html-serializer/commit/14405912cc8adf353bc9a1f1c0e49057e2d4b7f2)) + +## [3.1.9](https://github.com/sanity-io/sanity-naive-html-serializer/compare/v3.1.8...v3.1.9) (2025-07-10) + +### Bug Fixes + +- **deps:** allow studio v4 peer dep ranges ([5b9936a](https://github.com/sanity-io/sanity-naive-html-serializer/commit/5b9936a7b43645b4e98ec6e11155799a4e1d85bb)) +- **deps:** update React dependency to v19 ([#78](https://github.com/sanity-io/sanity-naive-html-serializer/issues/78)) ([d3fd193](https://github.com/sanity-io/sanity-naive-html-serializer/commit/d3fd1939c7d3ccccabf321415e936816f5bbefad)) diff --git a/plugins/sanity-naive-html-serializer/README.md b/plugins/sanity-naive-html-serializer/README.md new file mode 100644 index 0000000000..14ca0e4bd1 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/README.md @@ -0,0 +1,103 @@ +# Naive HTML serialization from Sanity documents + +## Table of Contents + +- [What this package solves](#what-this-package-solves) +- [What this package does](#what-this-package-does) +- [Quick start](#quick-start) +- [Internationalized array formats](#internationalized-array-formats) +- [v2-to-v3-changes](#v2-to-v3-changes) + +## What this package solves + +This is not a plugin, and probably does not need to be installed independently. Instead, it is a dependency for our translation tooling. If you're using any of our `TranslationsTab` plugins and need to solve a serialization issue, you can skip to the [custom serialization guide](https://github.com/sanity-io/sanity-naive-html-serializer/blob/main/docs/serialization-guide.md). + +## What this package does + +This is the source for tooling for naively turning documents and rich text fields into HTML, deserializing them, combining them with source documents, and patching them back. Ideally, this should take in objects that are in portable text, text arrays, or objects with text fields without knowing their specific names or types, and be able to patch them back without additional work on the part of the developer. + +This builds heavily on [@portabletext/to-html](https://github.com/portabletext/to-html) and Sanity's [block-tools](https://github.com/sanity-io/sanity/tree/next/packages/@sanity/block-tools), and it's highly recommended you familiarize yourself with these if you plan on customizing. + +## Quick start + +Remember, you probably don't want this package on its own! For those that do: + +From the same directory as your studio: + +```sh +npm install --save sanity-naive-html-serializer +``` + +or + +```sh +yarn add sanity-naive-html-serializer +``` + +Now, you can import something from the serializer and use it in your code: + +```javascript +import { + BaseDocumentSerializer, + BaseDocumentDeserializer, + BaseDocumentMerger, +} from 'sanity-naive-html-serializer' +``` + +## Internationalized array formats + +When serializing and merging with the `internationalizedArray` translation level, both data formats of [sanity-plugin-internationalized-array](https://github.com/sanity-io/plugins/tree/main/plugins/sanity-plugin-internationalized-array) are supported: + +- **v4 and below**: the language lives in the item's `_key`, e.g. `{"_key": "en", "_type": "internationalizedArrayStringValue", "value": "hello"}` +- **v5 and above**: the language lives in a dedicated `language` field and `_key` is a random string, e.g. `{"_key": "abc123", "_type": "internationalizedArrayStringValue", "language": "en", "value": "hello"}` + +Serialized files are identical for both formats (items are identified by their language code), and `BaseDocumentMerger.internationalizedArrayMerge` writes patches in whichever format the target document already uses, preserving existing item keys when replacing. + +## v2-to-v3-changes + +You likely will not need to make changes to your usage of this package. The biggest change to your codebase will be feeding in the schema to `BaseDocumentSerializer`. `BaseDocumentSerializer` should be the only affected interface. + +### In v2 + +```javascript +import schemas from 'part:@sanity/base/schema' + +const serializer = BaseDocumentSerializer(schemas) +const serialized = serializer.serializeDocument(doc, 'document') +``` + +### In v3 + +If you're in a valid React context: + +```javascript +import useSchema from 'sanity' + +const MyComponent = (doc) => { + const schemas = useSchema() + const serializer = BaseDocumentSerializer(schemas) + const serialized = serializer.serializeDocument(doc, 'document') +} +``` + +If you're not in a component, you'll likely have access to the schema from the `context` param passed through most configuration functions. For example: + +```javascript +const defaultDocumentNode: DefaultDocumentNodeResolver = (S, {schema}) => { + return S.document().views([ + S.view.form(), + S.view + .component(SerializeView) + .options({ + serializeFunc: (doc: SanityDocument) => { + BaseDocumentSerializer(schema).serializeDocument(doc, 'document') + }, + }) + .title('Serialize'), + ]) +} +``` + +## License + +[MIT](LICENSE) © Sanity.io diff --git a/plugins/sanity-naive-html-serializer/docs/serialization-guide.md b/plugins/sanity-naive-html-serializer/docs/serialization-guide.md new file mode 100644 index 0000000000..b19faa0c75 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/docs/serialization-guide.md @@ -0,0 +1,164 @@ +If you're using any of our `TranslationsTab` plugins, the scenarios below are some you might encounter on your journey! + +### Scenario: Some fields or objects in my document are serializing /deserializing strangely. + +First: this is often caused by not declaring types at the top level of your schema. Serialization introspects your schema files and can get a much better sense of what to do when objects are not "anonymous" (this is similar to how our GraphQL functions work -- more info on "strict" schemas [here](https://www.sanity.io/docs/graphql#33ec7103289a)) You can save yourself some development time by trying this first. + +If that's still not doing the trick, you can add on to the serializer to ensure you have complete say over how an object gets serialized and deserialized. + +First, write your serialization rules: + +```javascript +import { h } from '@sanity/block-content-to-html' +import { customSerializers } from 'whichever-sanity-plugin-translation-service-you-use' + +const myCustomSerializerTypes = { + ...customSerializers.types, + myType: (props) => { + const innerElements = //do things with the props + //className and id is VERY important!! don't forget them!! + return h('div', { className: props.node._type, id: props.node._key }, innerElements) + } +} + +const myCustomSerializers = customSerializers +myCustomSerializers.types = myCustomSerializerTypes + +const myCustomDeserializer = { + types: { + myType: (htmlElement) => { + //parse it back out! + } + } +} + +``` + +If your object is inline, then you may need to use the deserialization rules in Sanity's [block-tools](https://github.com/sanity-io/sanity/tree/next/packages/@sanity/block-tools). So you might declare something like this: + +```javascript +const myBlockDeserializationRules = [ + { + deserialize(el, next, block) { + if (el.className.toLowerCase() != myType.toLowerCase()) { + return undefined + } + + //do stuff with the HTML string + return { + _type: 'myType', + //all my other fields + } + } +] +``` + +Now, to bring it all together: + +```javascript +import { + TranslationsTab, + defaultDocumentLevelConfig, + BaseDocumentSerializer, + BaseDocumentDeserializer, + documentLevelPatch, + defaultStopTypes, +} from 'whichever-sanity-plugin-translation-service-you-use' + +const myCustomConfig = { + ...defaultDocumentLevelConfig, + exportForTranslation: (id) => + BaseDocumentSerializer.serializeDocument( + id, + 'document', + 'en', + defaultStopTypes, + myCustomSerializers, + ), + importTranslation: (id, localeId, document) => { + return BaseDocumentDeserializer.deserializeDocument( + id, + document, + myCustomDeserializer, + myBlockDeserializationRules, + ).then((deserialized) => documentLevelPatch(deserialized, id, localeId)) + }, +} +``` + +Then, in your document structure, just feed the config into your `TranslationsTab`. + +```javascript +S.view.component(TranslationsTab).title('My Translation Service').options(myCustomConfig) +``` + +
+
+ +### Scenario: I want to have more granular control over how my documents get patched back to my dataset. + +If all the serialization is working to your liking, but you have a different setup for how your document works, you can overwrite that patching logic. + +```javascript +import { TranslationsTab, defaultDocumentLevelConfig, BaseDocumentDeserializer } from 'whichever-sanity-plugin-translation-service-you-use' + +const myCustomConfig = { + ...defaultDocumentLevelConfig, + importTranslation: (id, localeId, document) => { + return BaseDocumentDeserializer.deserializeDocument(id,document).then( + deserialized => + //you should have an object of translated values here. Do things with them! + ) + } +} +``` + +
+
+ +### Scenario: I want to ensure certain fields never get sent to my translators. + +The serializer actually introspects your schema files. You can set `localize: false` on a schema and that field should not be sent off. Example: + +```javascript + fields: [{ + name: 'categories', + type: 'array', + localize: false, + ... + }] +``` + +
+
+ +### Scenario: I want to ensure certain types of objects never get serialized or sent to my translators. + +This plugin ships with a specification called `stopTypes`. By default it ignores fields that don't have useful linguistic information -- dates, numbers, etc. You can add to it easily. + +```javascript +import { + TranslationsTab, + defaultDocumentLevelConfig, + defaultStopTypes, + BaseDocumentSerializer, +} from 'sanity-plugin-transifex' + +const myCustomStopTypes = [...defaultStopTypes, 'listItem'] + +const myCustomConfig = { + ...defaultDocumentLevelConfig, + exportForTranslation: (id) => + BaseDocumentSerializer.serializeDocument(id, 'document', 'en', myCustomStopTypes), +} +``` + +As above, feed the config into your `TranslationsTab`. + +```javascript +S.view.component(TranslationsTab).title('My Translation Service').options(myCustomConfig) +``` + +There's a number of further possibilities here. Pretty much every interface provided can be partially or fully overwritten. Do write an issue if something seems to never work how you expect, or if you'd like a more elegant way of doing things. + +Primary use case is for translation plugins, but likely has applications elsewhere! diff --git a/plugins/sanity-naive-html-serializer/package.json b/plugins/sanity-naive-html-serializer/package.json new file mode 100644 index 0000000000..d60984df83 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/package.json @@ -0,0 +1,69 @@ +{ + "name": "sanity-naive-html-serializer", + "version": "5.1.12", + "description": "This is the source for tooling for naively turning documents and rich text fields into HTML, deserializing them, combining them with source documents, and patching them back. Ideally, this should take in objects that are in portable text, text arrays, or objects with text fields without knowing their specific names or types, and be able to patch them back without additional work on the part of the developer.", + "keywords": [ + "sanity", + "sanity-plugin" + ], + "homepage": "https://github.com/sanity-io/plugins/tree/main/plugins/sanity-naive-html-serializer#readme", + "bugs": { + "url": "https://github.com/sanity-io/plugins/issues" + }, + "license": "MIT", + "author": "Sanity.io ", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/sanity-io/plugins.git", + "directory": "plugins/sanity-naive-html-serializer" + }, + "files": [ + "dist" + ], + "type": "module", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "build": "tsdown", + "prepack": "turbo run build" + }, + "dependencies": { + "@portabletext/block-tools": "catalog:", + "@portabletext/to-html": "catalog:", + "@sanity/mutator": "catalog:", + "@sanity/schema": "catalog:", + "@sanity/util": "catalog:" + }, + "devDependencies": { + "@portabletext/types": "catalog:", + "@sanity/tsconfig": "catalog:", + "@sanity/tsdown-config": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "babel-plugin-react-compiler": "catalog:", + "jsdom": "catalog:", + "just-clone": "^6.2.0", + "react": "catalog:", + "react-dom": "catalog:", + "sanity": "catalog:", + "tsdown": "catalog:" + }, + "peerDependencies": { + "react": "catalog:peer", + "react-dom": "catalog:peer", + "sanity": "catalog:peer" + }, + "engines": { + "node": ">=20.19 <22 || >=22.12" + } +} diff --git a/plugins/sanity-naive-html-serializer/src/3rdparty-typings/sanity-parts.d.ts b/plugins/sanity-naive-html-serializer/src/3rdparty-typings/sanity-parts.d.ts new file mode 100644 index 0000000000..b27da742b5 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/3rdparty-typings/sanity-parts.d.ts @@ -0,0 +1 @@ +declare module '@sanity/block-content-to-html' diff --git a/plugins/sanity-naive-html-serializer/src/BaseDocumentDeserializer/BaseDocumentDeserializer.ts b/plugins/sanity-naive-html-serializer/src/BaseDocumentDeserializer/BaseDocumentDeserializer.ts new file mode 100644 index 0000000000..e18f3b8149 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/BaseDocumentDeserializer/BaseDocumentDeserializer.ts @@ -0,0 +1,144 @@ +import {htmlToBlocks} from '@portabletext/block-tools' + +import {customDeserializers, customBlockDeserializers} from '../BaseSerializationConfig' +import type {Deserializer} from '../types' +import {blockContentType, preprocess} from './helpers' + +const deserializeArray = ( + arrayHTML: Element, + deserializers: Record = customDeserializers, + blockDeserializers = customBlockDeserializers, +) => { + const output: any[] = [] + const children = Array.from(arrayHTML.children) + children.forEach((child) => { + let deserializedObject: any + try { + if (child.tagName?.toLowerCase() === 'span') { + deserializedObject = preprocess(child.innerHTML) + } + //has specific class name or data type, so it's an obj + else if (child.className || child.getAttribute('data-type') === 'object') { + deserializedObject = deserializeObject(child, deserializers, blockDeserializers) + deserializedObject._key = child.id + } else { + deserializedObject = htmlToBlocks(child.outerHTML, blockContentType, { + rules: blockDeserializers, + })[0] + deserializedObject._key = child.id + } + } catch (e) { + console.warn( + `Tried to deserialize block: ${child.outerHTML} in an array but failed to identify it! Error: ${e}`, + ) + } + output.push(deserializedObject) + }) + return output +} + +const deserializeObject = ( + objectHTML: Element, + deserializers: Record = customDeserializers, + blockDeserializers = customBlockDeserializers, +) => { + const deserialize = deserializers.types[objectHTML.className] + if (deserialize) { + return deserialize(objectHTML) + } + + const output: Record = {} + //account for anonymous inline objects + if (objectHTML.className) { + output._type = objectHTML.className + } + const children = Array.from(objectHTML.children) + + children.forEach((child) => { + //string field + if (child.tagName?.toLowerCase() === 'span') { + output[child.className] = preprocess(child.innerHTML) + } + //richer field, either object or array + else if (child.getAttribute('data-level') === 'field') { + const deserialized = deserializeHTML(child.outerHTML, deserializers, blockDeserializers) + if (deserialized && Object.keys(deserialized).length) { + output[child.className] = deserialized + } else { + console.warn(`Deserializer: Skipping empty or unreadable HTML: ${child.outerHTML}`) + } + } else if (child.getAttribute('data-type') === 'array') { + output[child.className] = deserializeArray(child, deserializers, blockDeserializers) + } + }) + return output +} + +const deserializeHTML = ( + html: string, + deserializers: Record, + blockDeserializers: Array, +): Record | any[] => { + //parent node is always div with classname of field -- get its child + let HTMLnode = new DOMParser().parseFromString(html, 'text/html').body.children[0] + + //catch embedded object as a field + if (HTMLnode?.getAttribute('data-level') === 'field') { + HTMLnode = HTMLnode.children[0] + } + + if (!HTMLnode) { + return {} + } + + let output: Record | any[] + + //prioritize custom deserialization + const deserialize = deserializers.types[HTMLnode.className] + if (deserialize) { + output = deserialize(HTMLnode) + } else if (HTMLnode.getAttribute('data-type') === 'object') { + output = deserializeObject(HTMLnode, deserializers, blockDeserializers) + } else if (HTMLnode.getAttribute('data-type') === 'array') { + output = deserializeArray(HTMLnode, deserializers, blockDeserializers) + } else { + output = {} + //eslint-disable-next-line no-console + console.debug(`Tried to deserialize block ${HTMLnode.outerHTML} but failed to identify it!`) + } + + return output +} + +const deserializeDocument = ( + serializedDoc: string, + deserializers: Record = customDeserializers, + blockDeserializers = customBlockDeserializers, +): Record => { + const metadata: Record = {} + const head = new DOMParser().parseFromString(serializedDoc, 'text/html').head + + Array.from(head.children).forEach((metaTag) => { + const validTags = ['_id', '_rev', '_type'] + const metaName = metaTag.getAttribute('name') + if (metaName && validTags.includes(metaName)) { + metadata[metaName] = metaTag.getAttribute('content') + } + }) + + const content: Record = deserializeHTML( + serializedDoc, + deserializers, + blockDeserializers, + ) + + return { + ...content, + ...metadata, + } +} + +export const BaseDocumentDeserializer: Deserializer = { + deserializeDocument, + deserializeHTML, +} diff --git a/plugins/sanity-naive-html-serializer/src/BaseDocumentDeserializer/helpers.ts b/plugins/sanity-naive-html-serializer/src/BaseDocumentDeserializer/helpers.ts new file mode 100644 index 0000000000..766c629ef8 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/BaseDocumentDeserializer/helpers.ts @@ -0,0 +1,41 @@ +import {htmlToBlocks} from '@portabletext/block-tools' +import {Schema} from '@sanity/schema' +import type {ObjectField, PortableTextSpan, PortableTextTextBlock} from 'sanity' + +const defaultSchema = Schema.compile({ + name: 'default', + types: [ + { + type: 'object', + name: 'default', + fields: [ + { + name: 'block', + type: 'array', + of: [{type: 'block'}], + }, + ], + }, + ], +}) + +export const blockContentType = defaultSchema + .get('default') + .fields.find((field: ObjectField) => field.name === 'block').type + +//helper to handle messy input -- take advantage +//of blockTools' sanitizing behavior for single strings +export const preprocess = (html: string): string => { + const intermediateBlocks = htmlToBlocks( + `

${html}

`, + blockContentType, + ) as PortableTextTextBlock[] + if (!intermediateBlocks.length) { + throw new Error(`Error parsing string '${html}'`) + } + const firstChild = intermediateBlocks[0]?.children?.[0] + if (!firstChild || !('text' in firstChild)) { + throw new Error(`Error parsing string '${html}'`) + } + return firstChild.text +} diff --git a/plugins/sanity-naive-html-serializer/src/BaseDocumentDeserializer/index.ts b/plugins/sanity-naive-html-serializer/src/BaseDocumentDeserializer/index.ts new file mode 100644 index 0000000000..fd64ad43e8 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/BaseDocumentDeserializer/index.ts @@ -0,0 +1 @@ +export * from './BaseDocumentDeserializer' diff --git a/plugins/sanity-naive-html-serializer/src/BaseDocumentMerger.ts b/plugins/sanity-naive-html-serializer/src/BaseDocumentMerger.ts new file mode 100644 index 0000000000..a6d9aaf50a --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/BaseDocumentMerger.ts @@ -0,0 +1,239 @@ +import {extractWithPath, arrayToJSONMatchPath, extract} from '@sanity/mutator' +import {randomKey} from '@sanity/util/content' +import type {SanityDocument} from 'sanity' + +import {getItemLanguage, LANGUAGE_FIELD, usesLanguageField} from './internationalizedArrayHelpers' +import type {Merger} from './types' + +//based on args required for a sanityClient.insert operation +//https://github.com/sanity-io/client/blob/d061e116cea10096c262fe3a8b0926d4fecdb6f3/src/data/patch.ts#L102 + +interface I18nArrayItem { + _key: string + _type: string + language?: string + value: Record | string | Array +} +interface I18nArrayInsert { + at: 'before' | 'after' | 'replace' + selector: string + items: Array +} + +const reconcileArray = (origArray: any[], translatedArray: any[]): any[] => { + //arrays of strings don't have keys, so just replace the array and return + if (translatedArray && translatedArray.some((el) => typeof el === 'string')) { + return translatedArray + } + + //deep copy needed for field level patching + const combined = JSON.parse(JSON.stringify(origArray)) + + translatedArray.forEach((block) => { + if (!block._key) { + return + } + const foundBlockIdx = origArray.findIndex((origBlock) => origBlock._key === block._key) + if (foundBlockIdx < 0) { + console.warn( + `This block no longer exists on the original document. Was it removed? ${JSON.stringify( + block, + )}`, + ) + } else if ( + origArray[foundBlockIdx]._type === 'block' || + origArray[foundBlockIdx]._type === 'span' + ) { + combined[foundBlockIdx] = block + } else if (Array.isArray(origArray[foundBlockIdx])) { + combined[foundBlockIdx] = reconcileArray(origArray[foundBlockIdx], block) + } else { + combined[foundBlockIdx] = reconcileObject(origArray[foundBlockIdx], block) + } + }) + return combined +} + +const reconcileObject = ( + origObject: Record, + translatedObject: Record, +): Record => { + if (typeof translatedObject !== 'object' || !Object.keys(translatedObject).length) { + return origObject + } + + const updatedObj = JSON.parse(JSON.stringify(origObject)) + Object.entries(translatedObject).forEach(([key, value]) => { + if (!value || key[0] === '_') { + return + } + if (typeof value === 'string') { + updatedObj[key] = value + } else if (Array.isArray(value)) { + updatedObj[key] = reconcileArray(origObject[key] ?? [], value) + } else { + updatedObj[key] = reconcileObject(origObject[key] ?? {}, value) + } + }) + return updatedObj +} + +const fieldLevelMerge = ( + translatedFields: Record, + //should be fetched according to the revision and id of the translated obj above + baseDoc: SanityDocument, + localeId: string, + baseLang: string = 'en', +): Record => { + const merged: Record = {} + const metaKeys = ['_rev', '_id', '_type'] + metaKeys.forEach((metaKey) => { + if (translatedFields[metaKey]) { + merged[metaKey] = translatedFields[metaKey] + } + }) + + //get any field that matches the base language, because it's been translated + const originPaths = extractWithPath(`..${baseLang}`, translatedFields) + originPaths.forEach((match) => { + const origMatch = extractWithPath(arrayToJSONMatchPath(match.path), baseDoc)[0] + const translatedMatch = extractWithPath(arrayToJSONMatchPath(match.path), translatedFields)[0] + if (!origMatch || !translatedMatch) { + return + } + const origVal = origMatch.value + const translatedVal = translatedMatch.value + let valToPatch + if (typeof translatedVal === 'string') { + valToPatch = translatedVal + } else if (Array.isArray(translatedVal) && translatedVal.length) { + valToPatch = reconcileArray((origVal as Array) ?? [], translatedVal) + } else if ( + typeof translatedVal === 'object' && + Object.keys(translatedVal as Record).length + ) { + valToPatch = reconcileObject(origVal ?? {}, translatedVal as Record) + } + const destinationPath = [ + ...match.path.slice(0, match.path.length - 1), //cut off the "en" + localeId.replace('-', '_'), // replace it with our locale + ] + + merged[arrayToJSONMatchPath(destinationPath)] = valToPatch + }) + + return merged +} + +const internationalizedArrayMerge = ( + translatedItems: Record, + //should be fetched according to the revision and id of the translated obj above + baseDoc: SanityDocument, + localeId: string, + baseLang: string = 'en', + localeArrayPosition: number = 0, +): Record => { + const patches: I18nArrayInsert[] = [] + + //get all items that match the base language from the translated doc, + //since those are the strings that have been translated. + //translated files produced by the serializer hold the language in `_key`, + //but raw v5-format documents hold it in `language` -- extract both + const extractionKeys = [`..[_key == "${baseLang}"]`, `..[${LANGUAGE_FIELD} == "${baseLang}"]`] + const originPaths = extractionKeys.flatMap((extractionKey) => + extractWithPath(extractionKey, translatedItems), + ) + + //slice off the index to get the arrays at which all the translated fields live + //then transform to string so we can extract + const i18nArrayPaths = originPaths + .map((match) => match.path.slice(0, match.path.length - 1)) + .map((path) => arrayToJSONMatchPath(path)) + + //extract produces duplicates. Likely we need to replace + //the function we're using. For now, just dedupe + Array.from(new Set(i18nArrayPaths)).forEach((path) => { + //we need to merge the translated values with those things + //that were not set off for translation. Get the original first + const origArray = extract(path, baseDoc)[0] as Array | undefined + if (!origArray?.length) { + return + } + const origVal = origArray.find( + (item: I18nArrayItem) => getItemLanguage(item) === baseLang, + )?.value + + const translatedArray = extract(path, translatedItems)[0] as Array | undefined + const translatedVal = translatedArray?.find( + (item: I18nArrayItem) => getItemLanguage(item) === baseLang, + )?.value + + //then, combine the translated values with the original recursively + let valToPatch + if (typeof translatedVal === 'string') { + valToPatch = translatedVal + } else if (Array.isArray(translatedVal) && translatedVal.length) { + valToPatch = reconcileArray((origVal as Array) ?? [], translatedVal) + } else if ( + typeof translatedVal === 'object' && + Object.keys(translatedVal as Record).length + ) { + valToPatch = reconcileObject( + (origVal as Record) ?? {}, + translatedVal as Record, + ) + } + + //check if the array is long enough for it to have a position + + //mirror the format of the existing data: v5 stores the language in a + //`language` field with a random `_key`, while v4 stores it in `_key`. + const isLanguageField = usesLanguageField(origArray) + + //check the original array to see what operation we should run + //(we don't want duplicates of locale keys) + const existingLocaleKey = origArray.find((item) => getItemLanguage(item) === localeId) + const at = existingLocaleKey ? 'replace' : 'after' + //target the existing entry by its real `_key` so it works for both formats + const selector: string = existingLocaleKey + ? `${path}[_key == "${existingLocaleKey._key}"]` + : `${path}[${localeArrayPosition - 1}]` + + if (valToPatch) { + //preserve the existing item's `_key` when replacing, so item identity + //stays stable across repeated imports + const newItem: I18nArrayItem = isLanguageField + ? { + _key: existingLocaleKey?._key ?? randomKey(), + _type: origArray[0]!._type, + [LANGUAGE_FIELD]: localeId, + value: valToPatch, + } + : {_key: localeId, _type: origArray[0]!._type, value: valToPatch} + + patches.push({ + at, + selector, + items: [newItem], + }) + } + }) + + return patches +} + +const documentLevelMerge = ( + translatedFields: Record, + //should be fetched according to the revision and id of the translated obj above + baseDoc: SanityDocument, +): Record => { + return reconcileObject(baseDoc, translatedFields) +} + +export const BaseDocumentMerger: Merger = { + fieldLevelMerge, + documentLevelMerge, + internationalizedArrayMerge, + reconcileArray, + reconcileObject, +} diff --git a/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/fieldFilters/baseFieldFilter.ts b/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/fieldFilters/baseFieldFilter.ts new file mode 100644 index 0000000000..1f0aecf2f0 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/fieldFilters/baseFieldFilter.ts @@ -0,0 +1,39 @@ +import type {ObjectField, TypedObject} from 'sanity' + +const META_FIELDS = ['_key', '_type', '_id'] + +/* + * Eliminates stop-types and non-localizable fields + * for document-level translation. + */ +export const fieldFilter = ( + obj: Record, + objFields: ObjectField[], + stopTypes: string[], +): TypedObject => { + const filteredObj: TypedObject = {_type: obj._type} + + const fieldFilterFunc = (field: Record) => { + if (field.localize === false) { + return false + } else if (field.type === 'string' || field.type === 'text') { + return true + } else if (Array.isArray(obj[field.name])) { + return true + } else if (!stopTypes.includes(field.type)) { + return true + } + return false + } + + const validFields = [ + ...META_FIELDS, + ...(objFields ?? []).filter(fieldFilterFunc).map((field) => field.name), + ] + validFields.forEach((field) => { + if (obj[field]) { + filteredObj[field] = obj[field] + } + }) + return filteredObj +} diff --git a/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/fieldFilters/index.ts b/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/fieldFilters/index.ts new file mode 100644 index 0000000000..3a1314e13d --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/fieldFilters/index.ts @@ -0,0 +1,3 @@ +export {fieldFilter} from './baseFieldFilter' +export {languageObjectFieldFilter} from './languageObjectFieldFilter' +export {internationalizedArrayFilter} from './internationalizedArrayFilter' diff --git a/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/fieldFilters/internationalizedArrayFilter.ts b/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/fieldFilters/internationalizedArrayFilter.ts new file mode 100644 index 0000000000..6535f1a8db --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/fieldFilters/internationalizedArrayFilter.ts @@ -0,0 +1,110 @@ +import type {SanityDocument, TypedObject} from 'sanity' + +import {getItemLanguage, LANGUAGE_FIELD} from '../../internationalizedArrayHelpers' + +const META_FIELDS = ['_key', '_type', '_id'] + +const isValidInternationalizedArray = (arr: any[], baseLang: string): boolean => { + const internationalizedRegex = /^internationalizedArray/ + return ( + arr.length > 0 && + typeof arr[0] === 'object' && + internationalizedRegex.test(arr[0]._type) && + arr.filter((obj) => getItemLanguage(obj) === baseLang).length > 0 + ) +} + +/* + * Filters an internationalized array down to the base language item, and normalizes + * it to the legacy shape (`_key` = language, no `language` field) so the existing + * serialize -> id -> deserialize -> _key round trip works for both v4 and v5 data, + * and the language code is never exposed to translators as a translatable string. + */ +const filterToBaseLang = (arr: TypedObject[], baseLang: string): TypedObject[] => { + const filtered: TypedObject[] = [] + for (const obj of arr) { + if (getItemLanguage(obj) !== baseLang) { + continue + } + const normalized: TypedObject = {...obj, _key: baseLang} + delete normalized[LANGUAGE_FIELD] + filtered.push(normalized) + } + return filtered +} + +/* + * Reduces an array like [ + * {_key: 'en', _type: 'internationalizedArrayStringValue', value: 'eng text'}, + * {_key: 'es', _type: 'internationalizedArrayStringValue', value: 'spanish text'} + * ] + * to [{value: 'eng text', _key, _type}] + * (for any base language, not just english) + * Works recursively, in case there are nested arrays. + */ +const findArraysWithBaseLang = ( + childObj: Record, + baseLang: string, +): Record => { + const filteredObj: Record = {} + META_FIELDS.forEach((field) => { + if (childObj[field]) { + filteredObj[field] = childObj[field] + } + }) + + for (const key in childObj) { + if (childObj[key]) { + const value: any = childObj[key] + if (Array.isArray(value) && isValidInternationalizedArray(value, baseLang)) { + //we've reached an internationalized array, add it to + //what we want to send to translation + filteredObj[key] = filterToBaseLang(value, baseLang) + } + //we have an array that may have language arrays in its objects + else if (Array.isArray(value) && value.length && typeof value[0] === 'object') { + //recursively find and filter for any objects that have an internationalized array + const validArr: Record[] = [] + value.forEach((objInArray) => { + //we recurse down for each object. if there's a value + //that's not default system value it passed the filter + const filtered = findArraysWithBaseLang(objInArray, baseLang) + const nonMetaFields = Object.keys(filtered).filter( + (objInArrayKey) => !META_FIELDS.includes(objInArrayKey), + ) + if (nonMetaFields.length) { + validArr.push(filtered) + } + }) + if (validArr.length) { + filteredObj[key] = validArr + } + } + //we have an object nested in an object + //recurse down the tree + else if (typeof value === 'object') { + const nestedLangObj = findArraysWithBaseLang(value, baseLang) + const nonMetaFields = Object.keys(nestedLangObj).filter( + (nestedObjKey) => !META_FIELDS.includes(nestedObjKey), + ) + if (nonMetaFields.length) { + filteredObj[key] = nestedLangObj + } + } + } + } + return filteredObj +} + +/* + * Helper. If field-level translation pattern used, only sends over + * content from the base language. Works recursively, so if users + * use this pattern several layers deep, base language fields will still be found. + */ +export const internationalizedArrayFilter = ( + document: SanityDocument, + baseLang: string, +): Record => { + //send top level object into recursive function + return findArraysWithBaseLang(document, baseLang) +} diff --git a/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/fieldFilters/languageObjectFieldFilter.ts b/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/fieldFilters/languageObjectFieldFilter.ts new file mode 100644 index 0000000000..5a02c47a03 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/fieldFilters/languageObjectFieldFilter.ts @@ -0,0 +1,92 @@ +import type {SanityDocument} from 'sanity' + +const META_FIELDS = ['_key', '_type', '_id'] + +/* + * Reduces an object like {en: 'eng text', es: 'spanish text', _key, _type} + * to {en: 'eng text', _key, _type} + * (for any base language, not just english) + */ +const filterToLangField = (childObj: Record, baseLang: string) => { + const filteredObj: Record = {} + filteredObj[baseLang] = childObj[baseLang] + META_FIELDS.forEach((field) => { + if (childObj[field]) { + filteredObj[field] = childObj[field] + } + }) + return filteredObj +} + +/* + * Recursive function. Descends down the tree of objects + * and arrays to create simplified objects that only + * contain the base language. + */ +const findBaseLang = (childObj: Record, baseLang: string): Record => { + const filteredObj: Record = {} + META_FIELDS.forEach((field) => { + if (childObj[field]) { + filteredObj[field] = childObj[field] + } + }) + + for (const key in childObj) { + if (childObj[key]) { + const value: any = childObj[key] + if (value.hasOwnProperty(baseLang)) { + //we've reached a base language field, add it to + //what we want to send to translation + filteredObj[key] = filterToLangField(value, baseLang) + } + //we have an array that may have language fields in its objects + else if (Array.isArray(value) && value.length && typeof value[0] === 'object') { + const validArr: Record = [] + //recursively find and filter for any objects that have the base language + value.forEach((objInArray) => { + if (objInArray._type === 'block') { + validArr.push(objInArray) + } else if (objInArray.hasOwnProperty(baseLang)) { + validArr.push(filterToLangField(objInArray, baseLang)) + } else { + const filtered = findBaseLang(objInArray, baseLang) + const nonMetaFields = Object.keys(filtered).filter( + (objInArrayKey) => !META_FIELDS.includes(objInArrayKey), + ) + if (nonMetaFields.length) { + validArr.push(filtered) + } + } + }) + if (validArr.length) { + filteredObj[key] = validArr + } + } + //we have an object nested in an object + //recurse down the tree + else if (typeof value === 'object') { + const nestedLangObj = findBaseLang(value, baseLang) + const nonMetaFields = Object.keys(nestedLangObj).filter( + (nestedObjKey) => META_FIELDS.indexOf(nestedObjKey) === -1, + ) + if (nonMetaFields.length) { + filteredObj[key] = nestedLangObj + } + } + } + } + return filteredObj +} + +/* + * Helper. If field-level translation pattern used, only sends over + * content from the base language. Works recursively, so if users + * use this pattern several layers deep, base language fields will still be found. + */ +export const languageObjectFieldFilter = ( + document: SanityDocument, + baseLang: string, +): Record => { + //send top level object into recursive function + return findBaseLang(document, baseLang) +} diff --git a/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/index.ts b/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/index.ts new file mode 100644 index 0000000000..4de7b5fcfd --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/BaseDocumentSerializer/index.ts @@ -0,0 +1,259 @@ +import {type PortableTextTypeComponent, toHTML} from '@portabletext/to-html' +import type {SanityDocument, TypedObject, Schema} from 'sanity' + +import {defaultStopTypes, customSerializers} from '../BaseSerializationConfig' +import type {TranslationLevel, SerializerClosure} from '../types' +import {fieldFilter, internationalizedArrayFilter, languageObjectFieldFilter} from './fieldFilters' + +const META_FIELDS = ['_key', '_type', '_id', '_weak'] + +export const BaseDocumentSerializer: SerializerClosure = (schemas: Schema) => { + /* + * Helper function that allows us to get metadata (like `localize: false`) from schema fields. + */ + const getSchema = (name: string) => schemas?._original?.types.find((s) => s.name === name) as any + + const serializeObject = ( + obj: TypedObject, + stopTypes: string[], + serializers: Record, + ) => { + if (stopTypes.includes(obj._type)) { + return '' + } + + // if user has declared a custom serializer, use that + // instead of this method + const hasSerializer = serializers.types && Object.keys(serializers.types).includes(obj._type) + if (hasSerializer) { + return toHTML([obj], {components: serializers}) + } + + // we don't need to worry about PT types + if (obj._type === 'span' || obj._type === 'block') { + return toHTML(obj, {components: serializers}) + } + + // If schema is available, encode values in the order they're declared in the schema, + // since this will likely be more intuitive for a translator. + let fieldNames = Object.keys(obj).filter((key) => key !== '_type') + const schema = getSchema(obj._type) + if (schema && schema.fields) { + fieldNames = schema.fields + .map((field: Record) => field.name) + .filter((schemaKey: string) => Object.keys(obj).includes(schemaKey)) + } + + //account for anonymous inline objects + if (typeof obj === 'object' && !obj._type) { + obj._type = '' + } + + // In some cases, we might recurse through many objects of the same type. + // We should take all methods necessary to ensure state does not persist + // otherwise we risk using old serialization methods on new items. + const newSerializationMethods: Record = {} + const tempType = `${obj._type}__temp_type__${Math.random().toString(36).substring(7)}` + const objToSerialize: TypedObject = {_type: tempType} + // For our default serialization method, we only need to + // capture metadata. The rest will be recursively turned into strings. + META_FIELDS.filter((f) => f !== '_type').forEach((field) => { + objToSerialize[field] = obj[field] + }) + + let innerHTML = '' + + // If it's a custom object, iterate through its keys to find and serialize translatable content. + fieldNames.forEach((fieldName) => { + let htmlField = '' + + if (!META_FIELDS.includes(fieldName)) { + const value = obj[fieldName] + // Strings are either string fields or have recursively been turned + // into HTML because they were a nested object or array. + if (typeof value === 'string') { + const htmlRegex = new RegExp(/<("[^"]*"|'[^']*'|[^'">])*>/) + if (htmlRegex.test(value)) { + htmlField = value + } else { + htmlField = `${value}` + } + } + + // Array fields get filtered and its children serialized. + else if (Array.isArray(value)) { + htmlField = serializeArray(value, fieldName, stopTypes, { + ...serializers, + types: {...serializers.types}, + }) + } + + // This is an object in an object, serialize it first. + else { + const embeddedObject = value as TypedObject + const embeddedObjectSchema = getSchema(embeddedObject._type) + let toTranslate = embeddedObject + if (embeddedObjectSchema && embeddedObjectSchema.fields) { + toTranslate = fieldFilter(toTranslate, embeddedObjectSchema.fields, stopTypes) + } + const objHTML = serializeObject(toTranslate, stopTypes, { + ...serializers, + types: {...serializers.types}, + }) + htmlField = `
${objHTML}
` + } + + innerHTML += htmlField + } + }) + + if (!innerHTML) { + return '' + } + + newSerializationMethods[tempType] = ({value}: {value: TypedObject}) => { + let div = `
${innerHTML}
`].join('') + } + + let serializedBlock = '' + try { + serializedBlock = toHTML(objToSerialize, { + components: { + ...serializers, + types: { + ...serializers.types, + ...newSerializationMethods, + }, + }, + }) + } catch (err) { + console.warn( + `Had issues serializing block of type "${obj._type}". Please specify a serialization method for this block in your serialization config. Received error: ${err}`, + ) + } + + return serializedBlock + } + + const serializeArray = ( + fieldContent: Record[], + fieldName: string, + stopTypes: string[], + serializers: Record, + ) => { + // Filter for any blocks that user has indicated + // should not be sent for translation. + const validBlocks = fieldContent.filter((block) => !stopTypes.includes(block._type)) + + // Take out any fields in these blocks that should + // not be sent to translation. + const filteredBlocks = validBlocks.map((block) => { + const schema = getSchema(block._type) + if (schema && schema.fields) { + return fieldFilter(block, schema.fields, stopTypes) + } + return block + }) + + const output = filteredBlocks.map((obj) => { + // If object in array is just a string, just return it. + if (typeof obj === 'string') { + return `${obj}` + } + // Send to serialization method. + return serializeObject(obj as TypedObject, stopTypes, serializers) + }) + + // Encode this with data-level field. + return `
${output.join('')}
` + } + + /* + * Main parent function: finds fields to translate, and feeds them to appropriate child serialization + * methods. + */ + const serializeDocument = ( + doc: SanityDocument, + translationLevel: TranslationLevel = 'document', + baseLang = 'en', + stopTypes = defaultStopTypes, + serializers = customSerializers, + ) => { + const schema = getSchema(doc._type) + let filteredObj: Record = {} + + // Field level translations explicitly send over any fields that + // match the base language, regardless of depth. + if (translationLevel === 'field') { + filteredObj = languageObjectFieldFilter(doc, baseLang) + } + // InternationalizedArray level translations send over fields + // that follow the _type naming pattern and have a _key of the base language. + else if (translationLevel === 'internationalizedArray') { + filteredObj = internationalizedArrayFilter(doc, baseLang) + } + // Otherwise, we can refer to the schema and a list of stop types + // to determine what should not be sent. + else { + filteredObj = fieldFilter(doc, schema.fields, stopTypes) + } + + const serializedFields: Record = {} + + for (const key in filteredObj) { + if (!filteredObj.hasOwnProperty(key)) continue + const value: Record | Array | string = filteredObj[key] + + if (typeof value === 'string') { + serializedFields[key] = value + } else if (Array.isArray(value)) { + serializedFields[key] = serializeArray(value, key, stopTypes, serializers) + } else if (value && !stopTypes.find((stopType) => stopType == value?._type)) { + const serialized = serializeObject(value as TypedObject, stopTypes, serializers) + serializedFields[key] = `
${serialized}
` + } + } + + // Create a valid HTML file. + const rawHTMLBody = document.createElement('body') + rawHTMLBody.innerHTML = serializeObject(serializedFields as TypedObject, stopTypes, serializers) + + const rawHTMLHead = document.createElement('head') + const metaFields = ['_id', '_type', '_rev'] + // Save our metadata as meta tags so we can use them later on. + metaFields.forEach((field) => { + const metaEl = document.createElement('meta') + metaEl.setAttribute('name', field) + metaEl.setAttribute('content', doc[field] as string) + rawHTMLHead.appendChild(metaEl) + }) + // Encode version so we can use the correct deserialization methods. + const versionMeta = document.createElement('meta') + versionMeta.setAttribute('name', 'version') + versionMeta.setAttribute('content', '3') + rawHTMLHead.appendChild(versionMeta) + + const rawHTML = document.createElement('html') + rawHTML.appendChild(rawHTMLHead) + rawHTML.appendChild(rawHTMLBody) + + return { + name: doc._id, + content: rawHTML.outerHTML, + } + } + + return { + serializeDocument, + fieldFilter, + languageObjectFieldFilter, + internationalizedArrayFilter, + serializeArray, + serializeObject, + } +} diff --git a/plugins/sanity-naive-html-serializer/src/BaseSerializationConfig.ts b/plugins/sanity-naive-html-serializer/src/BaseSerializationConfig.ts new file mode 100644 index 0000000000..fad2a1e201 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/BaseSerializationConfig.ts @@ -0,0 +1,154 @@ +import {htmlToBlocks} from '@portabletext/block-tools' +import type { + PortableTextBlockComponent, + PortableTextListComponent, + PortableTextListItemComponent, +} from '@portabletext/to-html' +import type {PortableTextBlockStyle} from '@portabletext/types' +import type {PortableTextTextBlock, TypedObject} from 'sanity' + +import {blockContentType} from './BaseDocumentDeserializer/helpers' + +export const defaultStopTypes = [ + 'reference', + 'date', + 'datetime', + 'file', + 'geopoint', + 'image', + 'number', + 'crop', + 'hotspot', + 'boolean', + 'url', + 'color', + 'code', +] + +const defaultPortableTextBlockStyles: Record< + PortableTextBlockStyle, + PortableTextBlockComponent | undefined +> = { + normal: ({value, children}) => `

${children}

`, + blockquote: ({value, children}) => `
${children}
`, + h1: ({value, children}) => `

${children}

`, + h2: ({value, children}) => `

${children}

`, + h3: ({value, children}) => `

${children}

`, + h4: ({value, children}) => `

${children}

`, + h5: ({value, children}) => `
${children}
`, + h6: ({value, children}) => `
${children}
`, +} + +const defaultLists: Record<'number' | 'bullet', PortableTextListComponent> = { + number: ({value, children}) => `
    ${children}
`, + bullet: ({value, children}) => `
    ${children}
`, +} + +const defaultListItem: PortableTextListItemComponent = ({value, children}) => { + const {_key, level} = value + return `
  • ${children}
  • ` +} + +const unknownBlockFunc: PortableTextBlockComponent = ({value, children}) => + `

    ${children}

    ` + +export const customSerializers: Record = { + unknownType: ({value}: {value: Record}) => `
    `, + types: {}, + block: defaultPortableTextBlockStyles, + list: defaultLists, + listItem: defaultListItem, + unknownBlockStyle: unknownBlockFunc, +} + +export const customDeserializers: Record = {types: {}} + +export const customBlockDeserializers: Array = [ + //handle undeclared styles + { + deserialize( + el: HTMLParagraphElement, + next: (elements: Node | Node[] | NodeList) => TypedObject | TypedObject[] | undefined, + ): PortableTextTextBlock | TypedObject | undefined { + if (!el.hasChildNodes()) { + return undefined + } + + if (el.getAttribute('data-type') !== 'unknown-block-style') { + return undefined + } + + const style = el.getAttribute('data-style') ?? '' + const block = htmlToBlocks(el.outerHTML, blockContentType)[0] + + return { + ...block, + style, + children: next(el.childNodes), + } as PortableTextTextBlock + }, + }, + //handle list items + { + deserialize( + el: HTMLParagraphElement, + next: (elements: Node | Node[] | NodeList) => TypedObject | TypedObject[] | undefined, + ): PortableTextTextBlock | TypedObject | undefined { + if (!el.hasChildNodes()) { + return undefined + } + + if (el.tagName.toLowerCase() !== 'li') { + return undefined + } + + const tagsToStyle: Record = { + ul: 'bullet', + ol: 'number', + } + + const parent = el.parentNode as HTMLUListElement | HTMLOListElement + if (!parent || !parent.tagName) { + return undefined + } + + const listItem = tagsToStyle[parent.tagName.toLowerCase()] + if (!listItem) { + return undefined + } + + const level = + el.getAttribute('data-level') && parseInt(el.getAttribute('data-level') || '0', 10) + const _key = el.id + let block = htmlToBlocks(parent.outerHTML, blockContentType)[0] + const customStyle = el.children?.[0]?.getAttribute('data-style') + + //check if the object inside is also serialized -- that means it has a style + //or custom annotation and we should use childNode serialization + const regex = new RegExp(/<("[^"]*"|'[^']*'|[^'">])*>/) + if (regex.test(el.innerHTML)) { + const newBlock = htmlToBlocks(el.innerHTML, blockContentType)[0] + if (newBlock) { + block = { + ...block, + ...newBlock, + style: customStyle ?? (newBlock as PortableTextTextBlock).style, + } + + //next(childNodes) plays poorly with custom styles, issue to be filed. + if (customStyle) { + return block + } + } + } + + return { + ...block, + level, + _key, + listItem, + children: next(el.childNodes), + } as PortableTextTextBlock + }, + }, +] diff --git a/plugins/sanity-naive-html-serializer/src/index.test.ts b/plugins/sanity-naive-html-serializer/src/index.test.ts new file mode 100644 index 0000000000..3cae7de449 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/index.test.ts @@ -0,0 +1,27 @@ +import {fileURLToPath} from 'node:url' + +import {expect, test} from 'vitest' +import {getPackageExportsManifest} from 'vitest-package-exports' + +test('package exports', {timeout: 30_000}, async () => { + const manifest = await getPackageExportsManifest({ + importMode: 'dist', + cwd: fileURLToPath(import.meta.url), + }) + + expect(manifest.exports).toMatchInlineSnapshot(` + { + ".": { + "BaseDocumentDeserializer": "object", + "BaseDocumentMerger": "object", + "BaseDocumentSerializer": "function", + "LANGUAGE_FIELD": "string", + "customBlockDeserializers": "object", + "customSerializers": "object", + "defaultStopTypes": "object", + "getItemLanguage": "function", + "usesLanguageField": "function", + }, + } + `) +}) diff --git a/plugins/sanity-naive-html-serializer/src/index.ts b/plugins/sanity-naive-html-serializer/src/index.ts new file mode 100644 index 0000000000..aab8f01a65 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/index.ts @@ -0,0 +1,11 @@ +export {BaseDocumentMerger} from './BaseDocumentMerger' +export {BaseDocumentSerializer} from './BaseDocumentSerializer' +export {BaseDocumentDeserializer} from './BaseDocumentDeserializer' +export { + defaultStopTypes, + customSerializers, + customBlockDeserializers, +} from './BaseSerializationConfig' +export {getItemLanguage, usesLanguageField, LANGUAGE_FIELD} from './internationalizedArrayHelpers' + +export type {SerializedDocument, Serializer, SerializerClosure, Deserializer, Merger} from './types' diff --git a/plugins/sanity-naive-html-serializer/src/internationalizedArrayHelpers.ts b/plugins/sanity-naive-html-serializer/src/internationalizedArrayHelpers.ts new file mode 100644 index 0000000000..4b2477d35b --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/internationalizedArrayHelpers.ts @@ -0,0 +1,23 @@ +/* + * Helpers for supporting both data formats of `sanity-plugin-internationalized-array`: + * - v4 (legacy): the language id is stored in `_key` + * e.g. {_key: 'en', _type: 'internationalizedArrayStringValue', value: 'hello'} + * - v5 (current): the language id is stored in a dedicated `language` field and `_key` + * holds a stable random key + * e.g. {_key: 'abc123', _type: 'internationalizedArrayStringValue', language: 'en', value: 'hello'} + */ + +export const LANGUAGE_FIELD = 'language' + +/* + * Resolve the language id of an internationalized array item, regardless of whether + * it uses the legacy `_key` format or the new `language` field format. + */ +export const getItemLanguage = (item: Record | undefined | null): string | undefined => + item?.[LANGUAGE_FIELD] ?? item?._key + +/* + * Returns true if any item in the array uses the v5 `language` field format. + */ +export const usesLanguageField = (arr: Array> | undefined | null): boolean => + Array.isArray(arr) && arr.some((item) => item && LANGUAGE_FIELD in item) diff --git a/plugins/sanity-naive-html-serializer/src/types.ts b/plugins/sanity-naive-html-serializer/src/types.ts new file mode 100644 index 0000000000..a7de31a5af --- /dev/null +++ b/plugins/sanity-naive-html-serializer/src/types.ts @@ -0,0 +1,75 @@ +import type {ObjectField, SanityDocument, TypedObject, Schema} from 'sanity' + +export type SerializedDocument = { + name: string + content: string +} + +export type TranslationLevel = 'document' | 'field' | 'internationalizedArray' +export interface Serializer { + serializeDocument: ( + doc: SanityDocument, + translationLevel: TranslationLevel, + baseLang?: string, + stopTypes?: string[], + serializers?: Record, + ) => SerializedDocument + fieldFilter: ( + obj: Record, + objFields: ObjectField[], + stopTypes: string[], + ) => TypedObject + languageObjectFieldFilter: (document: SanityDocument, baseLang: string) => Record + internationalizedArrayFilter: (document: SanityDocument, baseLang: string) => Record + serializeArray: ( + fieldContent: Record[], + fieldName: string, + stopTypes: string[], + serializers: Record, + ) => string + serializeObject: ( + obj: TypedObject, + stopTypes: string[], + serializers: Record, + ) => string +} + +export type SerializerClosure = (schemas: Schema) => Serializer + +export interface Deserializer { + deserializeDocument: ( + serializedDoc: string, + deserializers?: Record, + blockDeserializers?: Array, + ) => Record + deserializeHTML: ( + html: string, + deserializers: Record, + blockDeserializers: Array, + ) => Record | any[] +} + +export interface Merger { + fieldLevelMerge: ( + translatedFields: Record, + baseDoc: SanityDocument, + localeId: string, + baseLang: string, + ) => Record + internationalizedArrayMerge: ( + translatedFields: Record, + baseDoc: SanityDocument, + localeId: string, + baseLang: string, + localeArrayPosition: number, + ) => Record + documentLevelMerge: ( + translatedFields: Record, + baseDoc: SanityDocument, + ) => Record + reconcileArray: (origArray: any[], translatedArray: any[]) => any[] + reconcileObject: ( + origObject: Record, + translatedObject: Record, + ) => Record +} diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/__snapshots__/documentLevelDeserialization.test.ts.snap b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/__snapshots__/documentLevelDeserialization.test.ts.snap new file mode 100644 index 0000000000..46751fa030 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/__snapshots__/documentLevelDeserialization.test.ts.snap @@ -0,0 +1,189 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Global test of working doc-level functionality and snapshot match 1`] = ` +{ + "_id": "drafts.d8ffc675-ce86-4f60-9ac8-da164cde3b0a", + "_rev": "fxcnqj-uew-gr7-v6r-z965tfm69", + "_type": "documentLevelArticle", + "config": { + "_type": "objectField", + "nestedArrayField": [ + { + "_key": "4a58adc7c507", + "_type": "block", + "children": [ + { + "_key": "randomKey-16", + "_type": "span", + "marks": [], + "text": "This is block text 1 level deep", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "1aa228ac848b", + "_type": "block", + "children": [ + { + "_key": "randomKey-14", + "_type": "span", + "marks": [], + "text": "This is a block text 2 levels deep", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "title": "This is one level deeper", + }, + "title": "This is an object nested in a document", + }, + "content": [ + { + "_key": "a4d177e92666", + "_type": "block", + "children": [ + { + "_key": "randomKey-18", + "_type": "span", + "marks": [], + "text": "This is block text at the top level.", + }, + ], + "markDefs": [], + "style": "normal", + }, + { + "_key": "c0313627775e", + "_type": "objectField", + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "4f24f6fbfae7", + "_type": "block", + "children": [ + { + "_key": "randomKey-24", + "_type": "span", + "marks": [], + "text": "This is block text in a nested object in an object in top-level block text.", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "title": "This is a nested object in an object in top-level block text.", + }, + "title": "This is an object in top-level block text.", + }, + { + "_key": "9e2ab13c6d63", + "_type": "block", + "children": [ + { + "_key": "randomKey-26", + "_type": "span", + "marks": [], + "text": "This is h1 text", + }, + ], + "markDefs": [], + "style": "h1", + }, + { + "_key": "297142fbaf21", + "_type": "block", + "children": [ + { + "_key": "randomKey-28", + "_type": "span", + "marks": [], + "text": "This is h2 text", + }, + ], + "markDefs": [], + "style": "h2", + }, + { + "_key": "76e648fc3845", + "_type": "block", + "children": [ + { + "_key": "randomKey-32", + "_type": "span", + "marks": [], + "text": "Bullet 1", + }, + ], + "level": 1, + "listItem": "bullet", + "markDefs": [], + "style": "normal", + }, + { + "_key": "d090cd8b27d2", + "_type": "block", + "children": [ + { + "_key": "randomKey-36", + "_type": "span", + "marks": [], + "text": "nested bullet a", + }, + ], + "level": 2, + "listItem": "bullet", + "markDefs": [], + "style": "normal", + }, + { + "_key": "1cdffa5d50f5", + "_type": "block", + "children": [ + { + "_key": "randomKey-42", + "_type": "span", + "marks": [], + "text": "Styled bullet 2", + }, + ], + "level": 1, + "listItem": "bullet", + "markDefs": [], + "style": "h2", + }, + { + "_key": "da29f5063059", + "_type": "block", + "children": [ + { + "_key": "randomKey-48", + "_type": "span", + "marks": [], + "text": "Number 1", + }, + ], + "level": 1, + "listItem": "number", + "markDefs": [], + "style": "h3", + }, + ], + "snippet": "This is text in my text field.", + "tags": [ + "tag 1", + "tag 2", + "tag 3", + ], + "title": "My Document-Level Article", +} +`; diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/__snapshots__/fieldLevelDeserialization.test.ts.snap b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/__snapshots__/fieldLevelDeserialization.test.ts.snap new file mode 100644 index 0000000000..c41e3bc781 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/__snapshots__/fieldLevelDeserialization.test.ts.snap @@ -0,0 +1,107 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Global test of working field-level functionality and snapshot match 1`] = ` +{ + "_id": "drafts.2947533e-1ea5-4116-955b-339608d3445d", + "_rev": "l58oha-n06-1s4-f6i-74g6cuakl", + "_type": "fieldLevelArticle", + "config": { + "en": { + "_type": "objectField", + "nestedArrayField": [ + { + "_key": "f49b4d7e3e51", + "_type": "block", + "children": [ + { + "_key": "randomKey-16", + "_type": "span", + "marks": [], + "text": "This is block text 1 level deep", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "869168fe3a2a", + "_type": "block", + "children": [ + { + "_key": "randomKey-14", + "_type": "span", + "marks": [], + "text": "This is block text 2 levels deep", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "title": "This is one level deeper", + }, + "title": "This is an object nested in a document", + }, + }, + "content": { + "en": [ + { + "_key": "e2a39d768ff8", + "_type": "block", + "children": [ + { + "_key": "randomKey-18", + "_type": "span", + "marks": [], + "text": "This is block text at the top level.", + }, + ], + "markDefs": [], + "style": "normal", + }, + { + "_key": "271b0c6ee984", + "_type": "objectField", + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "4f24f6fbfae7", + "_type": "block", + "children": [ + { + "_key": "randomKey-24", + "_type": "span", + "marks": [], + "text": "This is block text in a nested object in an object in top-level block text.", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "title": "This is a nested object in an object in top-level block text.", + }, + "title": "This is an object in top-level block text.", + }, + ], + }, + "snippet": { + "en": "This is text in my text field", + }, + "tags": { + "en": [ + "tag 1", + "tag 2", + "tag 3", + ], + }, + "title": { + "en": "My Field-Level Article", + }, +} +`; diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/__snapshots__/internationalizedArrayDeserializer.test.ts.snap b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/__snapshots__/internationalizedArrayDeserializer.test.ts.snap new file mode 100644 index 0000000000..41599a2728 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/__snapshots__/internationalizedArrayDeserializer.test.ts.snap @@ -0,0 +1,155 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Global test of working internationalized array functionality and snapshot match 1`] = ` +{ + "_id": "drafts.2947533e-1ea5-4116-955b-339608d3445d", + "_rev": "l58oha-n06-1s4-f6i-74g6cuakl", + "_type": "internationalizedArrayArticle", + "config": [ + { + "_key": "en", + "_type": "internationalizedArrayObjectFieldValue", + "value": { + "_type": "objectField", + "nestedArrayField": [ + { + "_key": "4a58adc7c507", + "_type": "block", + "children": [ + { + "_key": "randomKey-6", + "_type": "span", + "marks": [], + "text": "This is block text 1 level deep", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "869168fe3a2a", + "_type": "block", + "children": [ + { + "_key": "randomKey-4", + "_type": "span", + "marks": [], + "text": "This is block text 2 levels deep", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "title": "This is one level deeper", + }, + "title": "This is an object nested in a document", + }, + }, + ], + "content": [ + { + "_key": "en", + "_type": "internationalizedArrayPortableTextValue", + "value": [ + { + "_key": "e2a39d768ff8", + "_type": "block", + "children": [ + { + "_key": "randomKey-8", + "_type": "span", + "marks": [], + "text": "This is block text at the top level.", + }, + ], + "markDefs": [], + "style": "normal", + }, + { + "_key": "271b0c6ee984", + "_type": "objectField", + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "4f24f6fbfae7", + "_type": "block", + "children": [ + { + "_key": "randomKey-14", + "_type": "span", + "marks": [], + "text": "This is block text in a nested object in an object in top-level block text.", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "title": "This is a nested object in an object in top-level block text.", + }, + "title": "This is an object in top-level block text.", + }, + ], + }, + ], + "slices": [ + { + "_key": "6b9d0b28810f", + "_type": "nestedlocaleBlock", + "content": [ + { + "_key": "en", + "_type": "internationalizedArrayBlockValue", + "value": [ + { + "_key": "e2a39d768ff8", + "_type": "block", + "children": [ + { + "_key": "randomKey-26", + "_type": "span", + "marks": [], + "text": "This is block text 1 level deep", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + }, + ], + }, + ], + "snippet": [ + { + "_key": "en", + "_type": "internationalizedArrayStringFieldValue", + "value": "This is text in my text field", + }, + ], + "tags": [ + { + "_key": "en", + "_type": "internationalizedArrayTagsValue", + "value": [ + "tag 1", + "tag 2", + "tag 3", + ], + }, + ], + "title": [ + { + "_key": "en", + "_type": "internationalizedArrayStringFieldValue", + "value": "My Internationalized Array Article", + }, + ], +} +`; diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/baseDeserialization.test.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/baseDeserialization.test.ts new file mode 100644 index 0000000000..4854cbd20e --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/baseDeserialization.test.ts @@ -0,0 +1,356 @@ +import {readFileSync} from 'node:fs' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +import type {PortableTextBlock, PortableTextTextBlock} from 'sanity' +import {beforeEach, expect, test, vi} from 'vitest' + +import { + BaseDocumentDeserializer, + BaseDocumentSerializer, + customBlockDeserializers, + defaultStopTypes, +} from '../../src' +import customStyles from '../__fixtures__/customStyles.json' +import { + annotationAndInlineBlocks, + documentLevelArticle, + inlineDocumentLevelArticle, + inlineSchema, + schema, +} from '../BaseDocumentSerializer/utils' +import { + addedBlockDeserializers, + addedCustomDeserializers, + addedCustomSerializers, + getDeserialized, +} from '../helpers' + +const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), '../__fixtures__') + +let mockTestKey = 0 + +vi.mock('@portabletext/block-tools', async () => { + const originalModule = await vi.importActual( + '@portabletext/block-tools', + ) + return { + ...originalModule, + //not ideal but vi.mock('@sanity/block-tools/src/util/randomKey.ts' is not working + htmlToBlocks: (html: string, blockContentType: any, options: any) => { + const blocks = originalModule.htmlToBlocks(html, blockContentType, options) + const newBlocks = blocks.map((block) => { + const newChildren = (block as unknown as PortableTextTextBlock).children.map((child) => { + return Object.assign(child, {_key: `randomKey-${mockTestKey++}`}) + }) + return Object.assign(block, {children: newChildren, _key: `randomKey-${mockTestKey++}`}) + }) + return newBlocks + }, + } +}) + +beforeEach(() => { + mockTestKey = 0 +}) + +test('Contains id of original document', () => { + const deserialized = getDeserialized(documentLevelArticle, 'document') + const id = deserialized._id + expect(id).toEqual(documentLevelArticle._id) +}) + +test('Contains rev of original document', () => { + const deserialized = getDeserialized(documentLevelArticle, 'document') + const rev = deserialized._rev + expect(rev).toEqual(documentLevelArticle._rev) +}) + +test('Contains type of original document', () => { + const deserialized = getDeserialized(documentLevelArticle, 'document') + const type = deserialized._type + expect(type).toEqual(documentLevelArticle._type) +}) + +/* + * CUSTOM SETTINGS + */ + +test('Custom deserialization should manifest at all levels', () => { + const serialized = BaseDocumentSerializer(schema).serializeDocument( + documentLevelArticle, + 'document', + 'en', + defaultStopTypes, + addedCustomSerializers, + ) + + const deserialized = BaseDocumentDeserializer.deserializeDocument( + serialized.content, + addedCustomDeserializers, + customBlockDeserializers, + ) + expect(deserialized.config.title).toEqual(documentLevelArticle.config.title) + expect(deserialized.config._type).toEqual(documentLevelArticle.config._type) + + const origArrayObj: any = documentLevelArticle.content.find( + (b: Record) => b._type === 'objectField', + ) + const deserializedArrayObj = deserialized.content.find( + (b: Record) => b._type === 'objectField', + ) + + expect(deserializedArrayObj.title).toEqual(origArrayObj.title) + expect(deserializedArrayObj._key).toEqual(origArrayObj._key) +}) + +test('Content with custom styles deserializes correctly and maintains style', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const customStyledDocument = { + ...documentLevelArticle, + ...customStyles, + } + + const serialized = BaseDocumentSerializer(schema).serializeDocument( + customStyledDocument, + 'document', + ) + + const deserialized = BaseDocumentDeserializer.deserializeDocument(serialized.content) + const origCustomStyleBlock: any = customStyledDocument.content.find( + (b: Record) => b._type === 'block' && b.style === 'custom1', + ) + const origCustomStyleListItem: any = customStyledDocument.content.find( + (b: Record) => + b._type === 'block' && b.listItem === 'number' && b.style === 'custom1', + ) + const deserializedCustomStyleBlock = deserialized.content.find( + (b: Record) => b._type === 'block' && b.style === 'custom1', + ) + const deserializedCustomStyleListItem = deserialized.content.find( + (b: Record) => + b._type === 'block' && b.listItem === 'number' && b.style === 'custom1', + ) + + expect(deserializedCustomStyleBlock.children[0].text).toEqual( + origCustomStyleBlock.children[0].text, + ) + + expect(deserializedCustomStyleListItem.children[0].text).toEqual( + origCustomStyleListItem.children[0].text, + ) +}) + +//test -- unhandled annotations and inlines don't break when they get deserialized back +test('Handled inline objects should be accurately deserialized', () => { + const inlineDocument = { + ...documentLevelArticle, + ...annotationAndInlineBlocks, + } + + const serialized = BaseDocumentSerializer(schema).serializeDocument( + inlineDocument, + 'document', + 'en', + defaultStopTypes, + addedCustomSerializers, + ) + + const deserialized = BaseDocumentDeserializer.deserializeDocument( + serialized.content, + addedCustomDeserializers, + addedBlockDeserializers, + ) + + const getInlineObj = (content: PortableTextBlock[], level?: number) => { + let child: Record = {} + const blocks = content.filter((block: PortableTextBlock) => { + if (level) { + return block.level === level + } + return !block.level + }) + + blocks.forEach((block: PortableTextBlock) => { + if (block.children && Array.isArray(block.children)) { + child = block.children.find((span: Record) => { + if (level) { + return span._type === 'childObjectField' && block.level === level + } + return span._type === 'childObjectField' && !block.level + }) + } + }) + + return child + } + const origInlineObject = getInlineObj(inlineDocument.content) + const origInlineListObject = getInlineObj(inlineDocument.content, 1) + + const deserializedInlineObject = getInlineObj(deserialized.content) + const deserializedInlineListObject = getInlineObj(deserialized.content, 1) + + expect(deserializedInlineObject.title).toEqual(origInlineObject.title) + expect(deserializedInlineObject._type).toEqual(origInlineObject._type) + + expect(deserializedInlineListObject.title).toEqual(origInlineListObject.title) + expect(deserializedInlineListObject._type).toEqual(origInlineListObject._type) +}) + +test('Handled annotations should be accurately deserialized', () => { + const inlineDocument = { + ...documentLevelArticle, + ...annotationAndInlineBlocks, + } + + const serialized = BaseDocumentSerializer(schema).serializeDocument( + inlineDocument, + 'document', + 'en', + defaultStopTypes, + addedCustomSerializers, + ) + + const deserialized = BaseDocumentDeserializer.deserializeDocument( + serialized.content, + addedCustomDeserializers, + addedBlockDeserializers, + ) + + let origAnnotation: Record | null = null + let deserializedAnnotation: Record | null = null + + inlineDocument.content.forEach((block: PortableTextBlock) => { + if (block.children && Array.isArray(block.children)) { + block.children.forEach((span: Record) => { + if (span.marks && span.marks.length) { + origAnnotation = span + } + }) + } + }) + + deserialized.content.forEach((block: PortableTextBlock) => { + if (block.children && Array.isArray(block.children)) { + block.children.forEach((span: Record) => { + if (span.marks && span.marks.length) { + deserializedAnnotation = span + } + }) + } + }) + + expect(deserializedAnnotation!.text).toEqual(origAnnotation!.text) +}) + +/* + * STYLE TAGS + */ +test('Deserialized content should preserve style tags', () => { + const deserialized = getDeserialized(documentLevelArticle, 'document') + const origH1: any = documentLevelArticle.content.find( + (block: PortableTextBlock) => block.style === 'h1', + ) + const deserializedH1 = deserialized.content.find( + (block: PortableTextBlock) => block.style === 'h1', + ) + const origH2: any = documentLevelArticle.content.find( + (block: PortableTextBlock) => block.style === 'h2', + ) + const deserializedH2 = deserialized.content.find( + (block: PortableTextBlock) => block.style === 'h2', + ) + expect(deserializedH1).toBeDefined() + expect(deserializedH2).toBeDefined() + expect(deserializedH1._key).toEqual(origH1._key) + expect(deserializedH2._key).toEqual(origH2._key) + expect(deserializedH1.children[0].text).toEqual(origH1.children[0].text) + expect(deserializedH2.children[0].text).toEqual(origH2.children[0].text) +}) + +/* + * LIST ITEMS + */ +test('Deserialized list items should preserve level, style and tag', () => { + const deserialized = getDeserialized(documentLevelArticle, 'document') + const origListItem: any = documentLevelArticle.content.find( + (block: PortableTextBlock) => block.listItem === 'bullet' && block.style === 'h2', + ) + const deserializedListItem = deserialized.content.find( + (block: PortableTextBlock) => block.listItem === 'bullet' && block.style === 'h2', + ) + const origNestedListItem: any = documentLevelArticle.content.find( + (block: PortableTextBlock) => block.listItem === 'bullet' && block.level === 2, + ) + const deserializedNestedListItem = deserialized.content.find( + (block: PortableTextBlock) => block.listItem === 'bullet' && block.level === 2, + ) + expect(deserializedListItem).toBeDefined() + expect(deserializedNestedListItem).toBeDefined() + expect(deserializedListItem._key).toEqual(origListItem._key) + expect(deserializedNestedListItem._key).toEqual(origNestedListItem._key) + expect(deserializedListItem.children[0].text).toEqual(origListItem.children[0].text) + expect(deserializedNestedListItem.children[0].text).toEqual(origNestedListItem.children[0].text) +}) + +/* + * MESSY INPUT + */ +test('  whitespace should not be escaped', () => { + vi.spyOn(console, 'debug').mockImplementation(() => {}) + + const content = readFileSync(join(fixturesDir, 'messy-html.html'), { + encoding: 'utf-8', + }) + const result = BaseDocumentDeserializer.deserializeDocument(content) + expect(result.title).toEqual('Här är artikel titeln') + expect(result.content[1].nestedArrayField[0].title).toEqual('Det här är en dragspels titeln') +}) + +/* + * V2 functionality -- be able to operate without a strict schema + */ +test('Content with anonymous inline objects deserializes all fields, at any depth', () => { + vi.spyOn(console, 'debug').mockImplementation(() => {}) + + const serialized = BaseDocumentSerializer(inlineSchema).serializeDocument( + inlineDocumentLevelArticle, + 'document', + ) + + const deserialized = BaseDocumentDeserializer.deserializeDocument(serialized.content) + //object in field + expect(deserialized.tabs.config.title).toEqual(inlineDocumentLevelArticle.tabs.config.title) + + //array in object in object + expect(deserialized.tabs.config.objectAsField.content[0]!.children[0]!.text).toEqual( + inlineDocumentLevelArticle.tabs.config.objectAsField.content[0]!.children[0]!.text, + ) + + //arrays + expect(deserialized.tabs.content).toBeInstanceOf(Array) + expect(deserialized.tabs.content.map((block: any) => block._key)).toEqual( + inlineDocumentLevelArticle.tabs.content.map((block: any) => block._key), + ) + + //object in array + const origObj: any = inlineDocumentLevelArticle.tabs.content.find( + (block: any) => block._type === 'objectField', + ) + const deserializedObj = deserialized.tabs.content.find( + (block: any) => block._type === 'objectField', + ) + + expect(deserializedObj.title).toEqual(origObj.title) + expect(deserializedObj.objectAsField.content[0].children[0].text).toEqual( + origObj.objectAsField.content[0].children[0].text, + ) + + //anonymous object in array + const origArray = inlineDocumentLevelArticle.tabs.arrayWithAnonymousObjects + const deserializedArray = deserialized.tabs.arrayWithAnonymousObjects + expect(deserializedArray.length).toEqual(origArray.length) + expect(deserializedArray[0]!._key).toEqual(origArray[0]!._key) + expect(Object.keys(deserializedArray[0])).not.toContain('span') +}) diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/documentLevelDeserialization.test.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/documentLevelDeserialization.test.ts new file mode 100644 index 0000000000..0cc783137a --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/documentLevelDeserialization.test.ts @@ -0,0 +1,96 @@ +import type {PortableTextBlock} from 'sanity' +import {expect, test} from 'vitest' + +import {documentLevelArticle} from '../BaseDocumentSerializer/utils' +import {getDeserialized, toPlainText} from '../helpers' + +test('Global test of working doc-level functionality and snapshot match', () => { + const deserialized = getDeserialized(documentLevelArticle, 'document') + expect(deserialized).toMatchSnapshot() +}) + +/* + * Top-level plain text + */ + +test('String and text types get deserialized correctly at top-level -- document level', () => { + const deserialized = getDeserialized(documentLevelArticle, 'document') + expect(deserialized.title).toEqual(documentLevelArticle.title) + expect(deserialized.snippet).toEqual(documentLevelArticle.snippet) +}) + +/* + * Presence and accuracy of fields in "vanilla" deserialization -- objects + */ +test('Nested object contains accurate values -- document level', () => { + const deserialized = getDeserialized(documentLevelArticle, 'document') + const origTitle = documentLevelArticle.config.title + const deserializedTitle = deserialized.config.title + expect(origTitle).toEqual(deserializedTitle) + + const origBlockText = documentLevelArticle.config.nestedArrayField + const deserializedBlockText = deserialized.config.nestedArrayField + + const origKeys = origBlockText.map((block: PortableTextBlock) => block._key) + const deserializedKeys = deserializedBlockText.map((block: PortableTextBlock) => block._key) + + expect(deserializedKeys.sort()).toEqual(origKeys.sort()) + expect(toPlainText(deserializedBlockText)).toEqual(toPlainText(origBlockText)) +}) + +test('Nested object in an object contains accurate values -- document level', () => { + const deserialized = getDeserialized(documentLevelArticle, 'document') + const origNestedObject = documentLevelArticle.config.objectAsField + const deserializedNestedObject = deserialized.config.objectAsField + + expect(origNestedObject.title).toEqual(deserializedNestedObject.title) + + const origKeys = origNestedObject.content.map((block: PortableTextBlock) => block._key) + const deserializedKeys = deserializedNestedObject.content.map( + (block: PortableTextBlock) => block._key, + ) + + expect(origKeys.sort()).toEqual(deserializedKeys.sort()) + expect(toPlainText(deserializedNestedObject.content)).toEqual( + toPlainText(origNestedObject.content), + ) +}) + +/* + * Presence and accuracy of fields in vanilla deserialization -- arrays + */ + +test('Array contains all serializable blocks with keys, in order -- document level', () => { + const deserialized = getDeserialized(documentLevelArticle, 'document') + const origKeys = documentLevelArticle.content.map((block: PortableTextBlock) => block._key) + const deserializedKeys = deserialized.content.map((block: PortableTextBlock) => block._key) + expect(deserializedKeys.sort()).toEqual(origKeys.sort()) +}) + +test('Array contains top-level block text -- document level', () => { + const deserialized = getDeserialized(documentLevelArticle, 'document') + expect(toPlainText(deserialized.content)).toEqual(toPlainText(documentLevelArticle.content)) +}) + +test('Object in array contains accurate values in nested object -- document level', () => { + const deserialized = getDeserialized(documentLevelArticle, 'document') + const origTitle = documentLevelArticle.content.find( + (block: Record) => block._type === 'objectField', + )!.objectAsField!.title + const deserializedTitle = deserialized.content.find( + (block: Record) => block._type === 'objectField', + ).objectAsField.title + expect(deserializedTitle).toEqual(origTitle) + + const origBlockText = toPlainText( + documentLevelArticle.content.find( + (block: Record) => block._type === 'objectField', + )!.objectAsField!.content, + ).trim() + const deserializedBlockText = toPlainText( + documentLevelArticle.content.find( + (block: Record) => block._type === 'objectField', + )!.objectAsField!.content, + ).trim() + expect(deserializedBlockText).toEqual(origBlockText) +}) diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/fieldLevelDeserialization.test.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/fieldLevelDeserialization.test.ts new file mode 100644 index 0000000000..c88030b245 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/fieldLevelDeserialization.test.ts @@ -0,0 +1,96 @@ +import type {PortableTextBlock} from 'sanity' +import {expect, test} from 'vitest' + +import {fieldLevelArticle} from '../BaseDocumentSerializer/utils' +import {getDeserialized, toPlainText} from '../helpers' + +test('Global test of working field-level functionality and snapshot match', () => { + const deserialized = getDeserialized(fieldLevelArticle, 'field') + expect(deserialized).toMatchSnapshot() +}) + +/* + * Top-level plain text + */ + +test('String and text types get deserialized correctly at top-level -- field level', () => { + const deserialized = getDeserialized(fieldLevelArticle, 'field') + expect(deserialized.title.en).toEqual(fieldLevelArticle.title.en) + expect(deserialized.snippet.en).toEqual(fieldLevelArticle.snippet.en) +}) + +/* + * Presence and accuracy of fields in "vanilla" deserialization -- objects + */ +test('Nested object contains accurate values -- field level', () => { + const deserialized = getDeserialized(fieldLevelArticle, 'field') + const origTitle = fieldLevelArticle.config.en.title + const deserializedTitle = deserialized.config.en.title + expect(origTitle).toEqual(deserializedTitle) + + const origBlockText = fieldLevelArticle.config.en.nestedArrayField + const deserializedBlockText = deserialized.config.en.nestedArrayField + + const origKeys = origBlockText.map((block: PortableTextBlock) => block._key) + const deserializedKeys = deserializedBlockText.map((block: PortableTextBlock) => block._key) + + expect(deserializedKeys.sort()).toEqual(origKeys.sort()) + expect(toPlainText(deserializedBlockText)).toEqual(toPlainText(origBlockText)) +}) + +test('Nested object in an object contains accurate values -- field level', () => { + const deserialized = getDeserialized(fieldLevelArticle, 'field') + const origNestedObject = fieldLevelArticle.config.en.objectAsField + const deserializedNestedObject = deserialized.config.en.objectAsField + + expect(origNestedObject.title).toEqual(deserializedNestedObject.title) + + const origKeys = origNestedObject.content.map((block: PortableTextBlock) => block._key) + const deserializedKeys = deserializedNestedObject.content.map( + (block: PortableTextBlock) => block._key, + ) + + expect(origKeys.sort()).toEqual(deserializedKeys.sort()) + expect(toPlainText(deserializedNestedObject.content)).toEqual( + toPlainText(origNestedObject.content), + ) +}) + +/* + * Presence and accuracy of fields in vanilla deserialization -- arrays + */ + +test('Array contains all serializable blocks with keys, in order -- field level', () => { + const deserialized = getDeserialized(fieldLevelArticle, 'field') + const origKeys = fieldLevelArticle.content.en.map((block: PortableTextBlock) => block._key) + const deserializedKeys = deserialized.content.en.map((block: PortableTextBlock) => block._key) + expect(deserializedKeys.sort()).toEqual(origKeys.sort()) +}) + +test('Array contains top-level block text -- field level', () => { + const deserialized = getDeserialized(fieldLevelArticle, 'field') + expect(toPlainText(deserialized.content.en)).toEqual(toPlainText(fieldLevelArticle.content.en)) +}) + +test('Object in array contains accurate values in nested object -- field level', () => { + const deserialized = getDeserialized(fieldLevelArticle, 'field') + const origTitle = fieldLevelArticle.content.en.find( + (block: Record) => block._type === 'objectField', + )!.objectAsField!.title + const deserializedTitle = deserialized.content.en.find( + (block: Record) => block._type === 'objectField', + ).objectAsField.title + expect(deserializedTitle).toEqual(origTitle) + + const origBlockText = toPlainText( + fieldLevelArticle.content.en.find( + (block: Record) => block._type === 'objectField', + )!.objectAsField!.content, + ).trim() + const deserializedBlockText = toPlainText( + fieldLevelArticle.content.en.find( + (block: Record) => block._type === 'objectField', + )!.objectAsField!.content, + ).trim() + expect(deserializedBlockText).toEqual(origBlockText) +}) diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/internationalizedArrayDeserializer.test.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/internationalizedArrayDeserializer.test.ts new file mode 100644 index 0000000000..46eb67bde3 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentDeserializer/internationalizedArrayDeserializer.test.ts @@ -0,0 +1,104 @@ +import type {PortableTextBlock} from 'sanity' +import {expect, test, describe} from 'vitest' + +import {internationalizedArrayArticle} from '../BaseDocumentSerializer/utils' +import {getDeserialized, getI18nArrayItem, toPlainText} from '../helpers' + +const deserialized = getDeserialized(internationalizedArrayArticle, 'internationalizedArray') + +test('Global test of working internationalized array functionality and snapshot match', () => { + expect(deserialized).toMatchSnapshot() +}) + +/* + * Top-level plain text + */ + +test('String and text types get deserialized correctly at top-level -- internationalized array', () => { + const origTitle = getI18nArrayItem(internationalizedArrayArticle.title, 'en')?.value + const origSnippet = getI18nArrayItem(internationalizedArrayArticle.snippet, 'en')?.value + + const deserializedTitle = getI18nArrayItem(deserialized.title, 'en')?.value + const deserializedSnippet = getI18nArrayItem(deserialized.snippet, 'en')?.value + + expect(deserializedTitle).toEqual(origTitle) + expect(deserializedSnippet).toEqual(origSnippet) +}) + +describe('Presence and accuracy of fields in "vanilla" deserialization -- objects', () => { + const origObject = getI18nArrayItem(internationalizedArrayArticle.config, 'en')?.value as Record< + string, + any + > + const deserializedObject = getI18nArrayItem(deserialized.config, 'en')?.value as Record< + string, + any + > + + test('Nested object contains accurate values -- internationalized array', () => { + expect(origObject.title).toEqual(deserializedObject.title) + + const origBlockText = origObject.nestedArrayField + const deserializedBlockText = deserializedObject.nestedArrayField + + const origKeys = origBlockText.map((block: PortableTextBlock) => block._key) + const deserializedKeys = deserializedBlockText.map((block: PortableTextBlock) => block._key) + + expect(deserializedKeys.sort()).toEqual(origKeys.sort()) + expect(toPlainText(deserializedBlockText)).toEqual(toPlainText(origBlockText)) + }) + + test('Nested object in an object contains accurate values -- internationalized array', () => { + const origNestedObject = origObject.objectAsField + const deserializedNestedObject = deserializedObject.objectAsField + + expect(origNestedObject.title).toEqual(deserializedNestedObject.title) + + const origKeys = origNestedObject.content.map((block: PortableTextBlock) => block._key) + const deserializedKeys = deserializedNestedObject.content.map( + (block: PortableTextBlock) => block._key, + ) + + expect(origKeys.sort()).toEqual(deserializedKeys.sort()) + expect(toPlainText(deserializedNestedObject.content)).toEqual( + toPlainText(origNestedObject.content), + ) + }) +}) + +describe('Presence and accuracy of fields in "vanilla" deserialization -- arrays', () => { + const origContent = getI18nArrayItem(internationalizedArrayArticle.content, 'en')?.value as any[] + const deserializedContent = getI18nArrayItem(deserialized.content, 'en')?.value as any[] + + test('Array contains all serializable blocks with keys, in order', () => { + const origKeys = origContent.map((block: PortableTextBlock) => block._key) + const deserializedKeys = deserializedContent.map((block: PortableTextBlock) => block._key) + expect(deserializedKeys.sort()).toEqual(origKeys.sort()) + }) + + test('Array contains top-level block text', () => { + expect(toPlainText(deserializedContent)).toEqual(toPlainText(origContent)) + }) + + test('Object in array contains accurate values in nested object', () => { + const origTitle = origContent.find( + (block: Record) => block._type === 'objectField', + ).objectAsField.title + const deserializedTitle = deserializedContent.find( + (block: Record) => block._type === 'objectField', + ).objectAsField.title + expect(deserializedTitle).toEqual(origTitle) + + const origBlockText = toPlainText( + origContent.find((block: Record) => block._type === 'objectField').objectAsField + .content, + ).trim() + + const deserializedBlockText = toPlainText( + deserializedContent.find((block: Record) => block._type === 'objectField') + .objectAsField.content, + ).trim() + + expect(deserializedBlockText).toEqual(origBlockText) + }) +}) diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/__snapshots__/documentLevelMerge.test.ts.snap b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/__snapshots__/documentLevelMerge.test.ts.snap new file mode 100644 index 0000000000..dad6b0aba6 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/__snapshots__/documentLevelMerge.test.ts.snap @@ -0,0 +1,193 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Global document level snapshot test 1`] = ` +{ + "_createdAt": "2021-09-01T23:00:13Z", + "_id": "drafts.d8ffc675-ce86-4f60-9ac8-da164cde3b0a", + "_rev": "fxcnqj-uew-gr7-v6r-z965tfm69", + "_type": "documentLevelArticle", + "_updatedAt": "2021-09-01T23:02:05Z", + "config": { + "_type": "objectField", + "nestedArrayField": [ + { + "_key": "4a58adc7c507", + "_type": "block", + "children": [ + { + "_key": "4f6bc15ae261", + "_type": "span", + "marks": [], + "text": "New text", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "1aa228ac848b", + "_type": "block", + "children": [ + { + "_key": "e2cf67af8e62", + "_type": "span", + "marks": [], + "text": "This is a block text 2 levels deep", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "title": "A new nested title", + }, + "title": "A new title", + }, + "content": [ + { + "_key": "a4d177e92666", + "_type": "block", + "children": [ + { + "_key": "randomKey-18", + "_type": "span", + "marks": [], + "text": "New block text", + }, + ], + "markDefs": [], + "style": "normal", + }, + { + "_key": "c0313627775e", + "_type": "objectField", + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "4f24f6fbfae7", + "_type": "block", + "children": [ + { + "_key": "d68f4288f5b7", + "_type": "span", + "marks": [], + "text": "This is block text in a nested object in an object in top-level block text.", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "title": "This is a nested object in an object in top-level block text.", + }, + "title": "This is an object in top-level block text.", + }, + { + "_key": "9e2ab13c6d63", + "_type": "block", + "children": [ + { + "_key": "17e856dc4766", + "_type": "span", + "marks": [], + "text": "This is h1 text", + }, + ], + "markDefs": [], + "style": "h1", + }, + { + "_key": "297142fbaf21", + "_type": "block", + "children": [ + { + "_key": "0b2f9e94a161", + "_type": "span", + "marks": [], + "text": "This is h2 text", + }, + ], + "markDefs": [], + "style": "h2", + }, + { + "_key": "76e648fc3845", + "_type": "block", + "children": [ + { + "_key": "49f466bbc78c", + "_type": "span", + "marks": [], + "text": "Bullet 1", + }, + ], + "level": 1, + "listItem": "bullet", + "markDefs": [], + "style": "normal", + }, + { + "_key": "d090cd8b27d2", + "_type": "block", + "children": [ + { + "_key": "599c6991018f", + "_type": "span", + "marks": [], + "text": "nested bullet a", + }, + ], + "level": 2, + "listItem": "bullet", + "markDefs": [], + "style": "normal", + }, + { + "_key": "1cdffa5d50f5", + "_type": "block", + "children": [ + { + "_key": "c16eb16f01cb", + "_type": "span", + "marks": [], + "text": "Styled bullet 2", + }, + ], + "level": 1, + "listItem": "bullet", + "markDefs": [], + "style": "h2", + }, + { + "_key": "da29f5063059", + "_type": "block", + "children": [ + { + "_key": "e3d7e3eaad54", + "_type": "span", + "marks": [], + "text": "Number 1", + }, + ], + "level": 1, + "listItem": "number", + "markDefs": [], + "style": "h3", + }, + ], + "hidden": true, + "meta": "Do not translate this", + "snippet": "A new document snippet", + "tags": [ + "tag 1", + "tag 2", + "tag 3", + ], + "title": "A new document title", +} +`; diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/__snapshots__/fieldLevelMerge.test.ts.snap b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/__snapshots__/fieldLevelMerge.test.ts.snap new file mode 100644 index 0000000000..35d022f41d --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/__snapshots__/fieldLevelMerge.test.ts.snap @@ -0,0 +1,97 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Global field level snapshot test 1`] = ` +{ + "_id": "drafts.2947533e-1ea5-4116-955b-339608d3445d", + "_rev": "l58oha-n06-1s4-f6i-74g6cuakl", + "_type": "fieldLevelArticle", + "config.es_ES": { + "_type": "objectField", + "nestedArrayField": [ + { + "_key": "f49b4d7e3e51", + "_type": "block", + "children": [ + { + "_key": "a6170f21181c", + "_type": "span", + "marks": [], + "text": "New text", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "869168fe3a2a", + "_type": "block", + "children": [ + { + "_key": "10d423df228a", + "_type": "span", + "marks": [], + "text": "This is block text 2 levels deep", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "title": "A new nested title", + }, + "title": "A new title", + }, + "content.es_ES": [ + { + "_key": "e2a39d768ff8", + "_type": "block", + "children": [ + { + "_key": "randomKey-18", + "_type": "span", + "marks": [], + "text": "New block text", + }, + ], + "markDefs": [], + "style": "normal", + }, + { + "_key": "271b0c6ee984", + "_type": "objectField", + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "4f24f6fbfae7", + "_type": "block", + "children": [ + { + "_key": "d68f4288f5b7", + "_type": "span", + "marks": [], + "text": "This is block text in a nested object in an object in top-level block text.", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "title": "This is a nested object in an object in top-level block text.", + }, + "title": "This is an object in top-level block text.", + }, + ], + "snippet.es_ES": "A new document snippet", + "tags.es_ES": [ + "tag 1", + "tag 2", + "tag 3", + ], + "title.es_ES": "A new document title", +} +`; diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/__snapshots__/internationalizedArrayMerge.test.ts.snap b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/__snapshots__/internationalizedArrayMerge.test.ts.snap new file mode 100644 index 0000000000..aa9c9fd96d --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/__snapshots__/internationalizedArrayMerge.test.ts.snap @@ -0,0 +1,170 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Global internationalized array snapshot test 1`] = ` +[ + { + "at": "after", + "items": [ + { + "_key": "es_ES", + "_type": "internationalizedArrayObjectFieldValue", + "value": { + "_type": "objectField", + "nestedArrayField": [ + { + "_key": "4a58adc7c507", + "_type": "block", + "children": [ + { + "_key": "4f6bc15ae261", + "_type": "span", + "marks": [], + "text": "New text", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "869168fe3a2a", + "_type": "block", + "children": [ + { + "_key": "10d423df228a", + "_type": "span", + "marks": [], + "text": "This is block text 2 levels deep", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "title": "A new nested title", + }, + "title": "A new title", + }, + }, + ], + "selector": "config[-1]", + }, + { + "at": "after", + "items": [ + { + "_key": "es_ES", + "_type": "internationalizedArrayPortableTextValue", + "value": [ + { + "_key": "e2a39d768ff8", + "_type": "block", + "children": [ + { + "_key": "216e97c5a5cc", + "_type": "span", + "marks": [], + "text": "This is block text at the top level.", + }, + ], + "markDefs": [], + "style": "normal", + }, + { + "_key": "271b0c6ee984", + "_type": "objectField", + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "4f24f6fbfae7", + "_type": "block", + "children": [ + { + "_key": "randomKey-14", + "_type": "span", + "marks": [], + "text": "This is block text in a nested object in an object in top-level block text.", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + "title": "This is a nested object in an object in top-level block text.", + }, + "title": "This is an object in top-level block text.", + }, + ], + }, + ], + "selector": "content[-1]", + }, + { + "at": "after", + "items": [ + { + "_key": "es_ES", + "_type": "internationalizedArrayStringFieldValue", + "value": "A new document snippet", + }, + ], + "selector": "snippet[-1]", + }, + { + "at": "after", + "items": [ + { + "_key": "es_ES", + "_type": "internationalizedArrayTagsValue", + "value": [ + "tag 1", + "tag 2", + "tag 3", + ], + }, + ], + "selector": "tags[-1]", + }, + { + "at": "after", + "items": [ + { + "_key": "es_ES", + "_type": "internationalizedArrayStringFieldValue", + "value": "A new document title", + }, + ], + "selector": "title[-1]", + }, + { + "at": "after", + "items": [ + { + "_key": "es_ES", + "_type": "internationalizedArrayBlockValue", + "value": [ + { + "_key": "e2a39d768ff8", + "_type": "block", + "children": [ + { + "_key": "randomKey-26", + "_type": "span", + "marks": [], + "text": "This is block text 1 level deep", + }, + ], + "markDefs": [], + "style": "normal", + }, + ], + }, + ], + "selector": "slices[0].content[-1]", + }, +] +`; diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/baseMerge.test.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/baseMerge.test.ts new file mode 100644 index 0000000000..c436bc9330 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/baseMerge.test.ts @@ -0,0 +1,30 @@ +import type {PortableTextBlock} from 'sanity' +import {expect, test} from 'vitest' + +import {BaseDocumentMerger} from '../../src' +import {documentLevelArticle} from '../BaseDocumentSerializer/utils' +import {getNewDocument} from './utils' + +/* + * STYLE TAGS + */ +test('Merged document should maintain style tags', () => { + const newDocument = getNewDocument() + const mergedDocument = BaseDocumentMerger.documentLevelMerge(newDocument, documentLevelArticle) + const origH1Block: any = documentLevelArticle.content.find( + (block: PortableTextBlock) => block.style === 'h1', + ) + const origH2Block: any = documentLevelArticle.content.find( + (block: PortableTextBlock) => block.style === 'h2', + ) + const mergedH1Block = mergedDocument.content.find( + (block: PortableTextBlock) => block.style === 'h1', + ) + const mergedH2Block = mergedDocument.content.find( + (block: PortableTextBlock) => block.style === 'h2', + ) + expect(mergedH1Block).toBeDefined() + expect(mergedH2Block).toBeDefined() + expect(mergedH1Block._key).toEqual(origH1Block._key) + expect(mergedH2Block._key).toEqual(origH2Block._key) +}) diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/documentLevelMerge.test.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/documentLevelMerge.test.ts new file mode 100644 index 0000000000..55dd5e97c9 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/documentLevelMerge.test.ts @@ -0,0 +1,80 @@ +import {expect, test} from 'vitest' + +import {BaseDocumentMerger} from '../../src' +import {documentLevelArticle} from '../BaseDocumentSerializer/utils' +import {getNewDocument, getNewObject} from './utils' + +const newDocument = getNewDocument() +const mergedDocument = BaseDocumentMerger.documentLevelMerge(newDocument, documentLevelArticle) + +test('Global document level snapshot test', () => { + expect(mergedDocument).toMatchSnapshot() +}) + +/* + * Objects + */ +test('Top-level string / text fields from new document override old document', () => { + expect(mergedDocument.title).toEqual(newDocument.title) + expect(mergedDocument.snippet).toEqual(newDocument.snippet) + expect(mergedDocument.title).not.toEqual(documentLevelArticle.title) + expect(mergedDocument.snippet).not.toEqual(documentLevelArticle.snippet) +}) + +test('Nested object fields override old object fields', () => { + expect(mergedDocument.config.title).toEqual(newDocument.config.title) + expect(mergedDocument.config.title).not.toEqual(documentLevelArticle.config.title) + expect(mergedDocument.config.nestedArrayField[0]!.children[0]!.text).toEqual( + newDocument.config.nestedArrayField[0]!.children[0]!.text, + ) + expect(mergedDocument.config.nestedArrayField[0]!.children[0]!.text).not.toEqual( + documentLevelArticle.config.nestedArrayField[0]!.children[0]!.text, + ) +}) + +test('Nested object merge uses old fields when not present on new object', () => { + expect(newDocument.config.objectAsField.content).toBeUndefined() + expect(mergedDocument.config.objectAsField.content).toBeDefined() +}) + +/* + * Arrays + */ +test('Arrays will use new objects when they exist', () => { + expect(mergedDocument.content[0]!.children[0]!.text).toEqual( + newDocument.content[0]!.children[0]!.text, + ) + expect(mergedDocument.content[0]!.children[0]!.text).not.toEqual( + documentLevelArticle.content[0]!.children![0]!.text, + ) +}) + +test('Arrays will use old blocks if they do not exist on new object', () => { + expect(newDocument.content[1]).toBeUndefined() + expect(mergedDocument.content[1]).toBeDefined() + expect(mergedDocument.content[1]!._key).toEqual(documentLevelArticle.content[1]!._key) +}) + +test('Arrays will merge objects in the array', () => { + const documentWithIncompleteObj = getNewDocument() + const incompleteObj = getNewObject() + + //add a new block with some new content, but not all new content + documentWithIncompleteObj.content.push({ + _key: documentLevelArticle.content[1]!._key, + objectAsField: incompleteObj.objectAsField, + title: incompleteObj.title, + }) + const documentWithMergedObj = BaseDocumentMerger.documentLevelMerge( + documentWithIncompleteObj, + documentLevelArticle, + ) + + expect(documentWithMergedObj.content[1].title).toEqual(documentWithIncompleteObj.content[1].title) + expect(documentWithMergedObj.content[1].objectAsField.title).toEqual( + documentWithIncompleteObj.content[1].objectAsField.title, + ) + //content existed on old doc but not new, so the two coexist happily + expect(documentWithIncompleteObj.content[1].objectAsField.content).toBeUndefined() + expect(documentWithMergedObj.content[1].objectAsField.content).toBeDefined() +}) diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/fieldLevelMerge.test.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/fieldLevelMerge.test.ts new file mode 100644 index 0000000000..59e0735c3b --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/fieldLevelMerge.test.ts @@ -0,0 +1,119 @@ +import clone from 'just-clone' +import {expect, test} from 'vitest' + +import {BaseDocumentMerger} from '../../src' +import {fieldLevelArticle, nestedLanguageFields} from '../BaseDocumentSerializer/utils' +import {getDeserialized} from '../helpers' +import {getNewFieldLevelDocument, getNewObject} from './utils' + +const newDocument = getNewFieldLevelDocument() +const fieldLevelPatches = BaseDocumentMerger.fieldLevelMerge( + newDocument, + fieldLevelArticle, + 'es_ES', + 'en', +) +test('Global field level snapshot test', () => { + expect(fieldLevelPatches).toMatchSnapshot() +}) + +/* + * Objects + */ +test('Top-level string / text fields from new document patch to new field, and will be maintained in old field', () => { + expect(fieldLevelPatches['title.es_ES']).toEqual(newDocument.title.en) + expect(fieldLevelPatches['snippet.es_ES']).toEqual(newDocument.snippet.en) + expect(fieldLevelPatches['title.es_ES']).not.toEqual(fieldLevelArticle.title.en) + expect(fieldLevelPatches['snippet.es_ES']).not.toEqual(fieldLevelArticle.snippet.en) + expect(fieldLevelPatches['title.en']).toBeUndefined() + expect(fieldLevelPatches['snippet.en']).toBeUndefined() +}) + +test('Nested object fields override old object fields', () => { + expect(fieldLevelPatches['config.es_ES'].title).toEqual(newDocument.config.en.title) + expect(fieldLevelPatches['config.es_ES'].title).not.toEqual(fieldLevelArticle.config.en.title) + expect(fieldLevelPatches['config.es_ES'].nestedArrayField[0]!.children[0]!.text).toEqual( + newDocument.config.en.nestedArrayField[0]!.children[0]!.text, + ) + expect(fieldLevelPatches['config.es_ES'].nestedArrayField[0]!.children[0]!.text).not.toEqual( + fieldLevelArticle.config.en.nestedArrayField[0]!.children[0]!.text, + ) +}) + +test('Nested object merge uses old fields when not present on new object', () => { + expect(fieldLevelPatches['config.es_ES'].objectAsField.content).toEqual( + fieldLevelArticle.config.en.objectAsField.content, + ) +}) + +/* + * Arrays + */ +test('Arrays will use new objects when they exist', () => { + expect(fieldLevelPatches['content.es_ES'][0]!.children[0]!.text).toEqual( + newDocument.content.en[0]!.children[0]!.text, + ) + expect(fieldLevelPatches['content.es_ES'][0]!.children[0]!.text).not.toEqual( + fieldLevelArticle.content.en[0]!.children![0]!.text, + ) +}) + +test('Arrays will use old blocks if they do not exist on new object', () => { + expect(newDocument.content.en[1]).toBeUndefined() + expect(fieldLevelPatches['content.es_ES'][1]).toBeDefined() + expect(fieldLevelPatches['content.es_ES'][1]!._key).toEqual(fieldLevelArticle.content.en[1]!._key) +}) + +test('Arrays will merge objects in the array', () => { + const documentWithIncompleteObj = getNewFieldLevelDocument() + const incompleteObj = getNewObject() + + //add a new block with some new content, but not all new content + documentWithIncompleteObj.content.en.push({ + _key: fieldLevelArticle.content.en[1]!._key, + objectAsField: incompleteObj.objectAsField, + title: incompleteObj.title, + //does not include "content" field -- we want that to be merged with the old + }) + + const fieldDocWithMergedObj = BaseDocumentMerger.fieldLevelMerge( + documentWithIncompleteObj, + fieldLevelArticle, + 'es_ES', + 'en', + ) + + expect(fieldDocWithMergedObj['content.es_ES'][1].title).toEqual( + documentWithIncompleteObj.content.en[1].title, + ) + expect(fieldDocWithMergedObj['content.es_ES'][1].objectAsField.title).toEqual( + documentWithIncompleteObj.content.en[1].objectAsField.title, + ) + //"content" field existed on old doc but not new, so the two coexist happily + expect(documentWithIncompleteObj.content.en[1].objectAsField.content).toBeUndefined() + expect(fieldDocWithMergedObj['content.es_ES'][1].objectAsField.content).toBeDefined() +}) + +test('nested locale fields will be merged', () => { + const newNestedFields = clone(nestedLanguageFields) + newNestedFields.pageFields.name.en = 'This is a new page field name' + ;(newNestedFields as any).slices[0].en[0].children[0].text = 'This is new slice text' + const baseDocumentWithNestedFields = {...fieldLevelArticle, ...nestedLanguageFields} + const newDocumentWithNestedFields = getDeserialized( + {...fieldLevelArticle, ...newNestedFields}, + 'field', + ) + const nestedFieldLevelPatches = BaseDocumentMerger.fieldLevelMerge( + newDocumentWithNestedFields, + baseDocumentWithNestedFields, + 'es_ES', + 'en', + ) + + expect(nestedFieldLevelPatches['slices[0].es_ES'][0].children[0].text).toEqual( + newDocumentWithNestedFields.slices[0].en[0].children[0].text, + ) + expect(nestedFieldLevelPatches['pageFields.name.es_ES']).toEqual( + newDocumentWithNestedFields.pageFields.name.en, + ) +}) diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/internationalizedArrayMerge.test.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/internationalizedArrayMerge.test.ts new file mode 100644 index 0000000000..ca8d43d3a3 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/internationalizedArrayMerge.test.ts @@ -0,0 +1,18 @@ +import {expect, test} from 'vitest' + +import {BaseDocumentMerger} from '../../src' +import {internationalizedArrayArticle} from '../BaseDocumentSerializer/utils' +import {getInternationalizedArrayDocument} from './utils' + +const newDocument = getInternationalizedArrayDocument() +const internationalizedArrayPatches = BaseDocumentMerger.internationalizedArrayMerge( + newDocument, + internationalizedArrayArticle, + 'es_ES', + 'en', + 0, +) + +test('Global internationalized array snapshot test', () => { + expect(internationalizedArrayPatches).toMatchSnapshot() +}) diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/utils.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/utils.ts new file mode 100644 index 0000000000..b33b4b94be --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentMerger/utils.ts @@ -0,0 +1,63 @@ +import clone from 'just-clone' + +import { + documentLevelArticle, + fieldLevelArticle, + internationalizedArrayArticle, +} from '../BaseDocumentSerializer/utils' +import {getDeserialized} from '../helpers' + +export const getNewObject = (): Record => { + const newObject = { + title: 'A new title', + nestedArrayField: clone(documentLevelArticle.config.nestedArrayField), + objectAsField: {title: 'A new nested title'}, + _key: null, + } + newObject.nestedArrayField[0]!.children[0]!.text = 'New text' + return newObject +} + +export const getNewDocument = (): Record => { + const newDocument = getDeserialized(documentLevelArticle, 'document') + newDocument.title = 'A new document title' + newDocument.snippet = 'A new document snippet' + newDocument.config = getNewObject() + const newBlockText = newDocument.content[0] + newBlockText.children[0].text = 'New block text' + newDocument.content = [newBlockText] + return newDocument +} + +export const getNewFieldLevelObject = (): Record => { + const newObject = { + title: 'A new title', + nestedArrayField: clone(fieldLevelArticle.config.en.nestedArrayField), + objectAsField: {title: 'A new nested title'}, + _key: null, + } + newObject.nestedArrayField[0]!.children[0]!.text = 'New text' + return newObject +} + +export const getNewFieldLevelDocument = (): Record => { + const newDocument = getDeserialized(fieldLevelArticle, 'field') + newDocument.title.en = 'A new document title' + newDocument.snippet.en = 'A new document snippet' + newDocument.config.en = getNewFieldLevelObject() + const newBlockText = newDocument.content.en[0] + newBlockText.children[0].text = 'New block text' + newDocument.content.en = [newBlockText] + return newDocument +} + +export const getInternationalizedArrayDocument = (): Record => { + const newDocument = getDeserialized(internationalizedArrayArticle, 'internationalizedArray') + newDocument.title[0].value = 'A new document title' + newDocument.snippet[0].value = 'A new document snippet' + newDocument.config[0].value = getNewObject() + const newBlockText = newDocument.content[0].value[0] + newBlockText.children[0].text = 'New block text' + newDocument.content[0].value[0] = [newBlockText] + return newDocument +} diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/__snapshots__/documentLevelSerialization.test.ts.snap b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/__snapshots__/documentLevelSerialization.test.ts.snap new file mode 100644 index 0000000000..17f669edaf --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/__snapshots__/documentLevelSerialization.test.ts.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Global test of working doc-level functionality and snapshot match 1`] = ` +{ + "content": "
    My Document-Level ArticleThis is text in my text field.
    tag 1tag 2tag 3
    This is an object nested in a document
    This is one level deeper

    This is a block text 2 levels deep

    This is block text 1 level deep

    This is block text at the top level.

    This is an object in top-level block text.
    This is a nested object in an object in top-level block text.

    This is block text in a nested object in an object in top-level block text.

    This is h1 text

    This is h2 text

    • Bullet 1
    • nested bullet a
    • Styled bullet 2

    1. Number 1

    ", + "name": "drafts.d8ffc675-ce86-4f60-9ac8-da164cde3b0a", +} +`; diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/__snapshots__/fieldLevelSerialization.test.ts.snap b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/__snapshots__/fieldLevelSerialization.test.ts.snap new file mode 100644 index 0000000000..eba26f08fe --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/__snapshots__/fieldLevelSerialization.test.ts.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Global test of working field-level functionality and snapshot match 1`] = ` +{ + "content": "
    My Field-Level Article
    This is text in my text field
    tag 1tag 2tag 3
    This is an object nested in a document
    This is one level deeper

    This is block text 2 levels deep

    This is block text 1 level deep

    This is block text at the top level.

    This is an object in top-level block text.
    This is a nested object in an object in top-level block text.

    This is block text in a nested object in an object in top-level block text.

    ", + "name": "drafts.2947533e-1ea5-4116-955b-339608d3445d", +} +`; diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/__snapshots__/internationalizedArraySerialization.test.ts.snap b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/__snapshots__/internationalizedArraySerialization.test.ts.snap new file mode 100644 index 0000000000..01c433cfb3 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/__snapshots__/internationalizedArraySerialization.test.ts.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Global test of working internationalized array-level functionality and snapshot match 1`] = ` +{ + "content": "
    This is an object nested in a document
    This is one level deeper

    This is block text 2 levels deep

    This is block text 1 level deep

    This is block text at the top level.

    This is an object in top-level block text.
    This is a nested object in an object in top-level block text.

    This is block text in a nested object in an object in top-level block text.

    This is text in my text field
    tag 1tag 2tag 3
    My Internationalized Array Article

    This is block text 1 level deep

    ", + "name": "drafts.2947533e-1ea5-4116-955b-339608d3445d", +} +`; diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/baseSerialization.test.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/baseSerialization.test.ts new file mode 100644 index 0000000000..c9f0479f3f --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/baseSerialization.test.ts @@ -0,0 +1,324 @@ +import type {PortableTextBlock} from 'sanity' +import {describe, expect, test, vi} from 'vitest' + +import {BaseDocumentSerializer, customSerializers, defaultStopTypes} from '../../src' +import { + addedCustomSerializers, + createCustomInnerHTML, + getSerialized, + getValidFields, +} from '../helpers' +import { + annotationAndInlineBlocks, + documentLevelArticle, + findByClass, + getHTMLNode, + inlineDocumentLevelArticle, + inlineSchema, + schema, +} from './utils' + +/* + * METADATA PRESENCE + */ +describe('Has all required metadata', () => { + const serialized = getSerialized(documentLevelArticle, 'document') + const docTree = getHTMLNode(serialized) + test('Contains metadata field containing document id', () => { + const idMetaTag = Array.from(docTree.head.children).find( + (metaTag) => metaTag.getAttribute('name') === '_id', + ) + const id = idMetaTag?.getAttribute('content') + expect(id).toEqual(documentLevelArticle._id) + }) + + test('Contains metadata field containing document revision', () => { + const revMetaTag = Array.from(docTree.head.children).find( + (metaTag) => metaTag.getAttribute('name') === '_rev', + ) + const rev = revMetaTag?.getAttribute('content') + expect(rev).toEqual(documentLevelArticle._rev) + }) + + test('Contains metadata field containing document type', () => { + const typeMetaTag = Array.from(docTree.head.children).find( + (metaTag) => metaTag.getAttribute('name') === '_type', + ) + const type = typeMetaTag?.getAttribute('content') + expect(type).toEqual(documentLevelArticle._type) + }) + + test('Contains metadata field containing version', () => { + const typeMetaTag = Array.from(docTree.head.children).find( + (metaTag) => metaTag.getAttribute('name') === 'version', + ) + const version = typeMetaTag?.getAttribute('content') + expect(version).toEqual('3') + }) +}) + +/* + * CUSTOM SETTINGS + */ + +test('Custom serialization should manifest at all levels', () => { + const serializer = BaseDocumentSerializer(schema) + const serialized = serializer.serializeDocument( + documentLevelArticle, + 'document', + 'en', + defaultStopTypes, + addedCustomSerializers, + ) + const docTree = getHTMLNode(serialized).body.children[0]! + + const topLevelCustomSerialized = findByClass(docTree.children, 'config') + const requiredTopLevelTitle = documentLevelArticle.config.title + expect(topLevelCustomSerialized?.innerHTML).toContain( + createCustomInnerHTML(requiredTopLevelTitle), + ) + + const arrayField = findByClass(docTree.children, 'content') + const nestedSerialized = findByClass(arrayField!.children, 'objectField') + const requiredNestedTitle: any = documentLevelArticle.content.find( + (b: Record) => b._type === 'objectField', + )!.title + expect(nestedSerialized?.innerHTML).toContain(createCustomInnerHTML(requiredNestedTitle)) +}) + +test('Fields marked "localize: false" should not be serialized', () => { + const serialized = getSerialized(documentLevelArticle, 'document') + const docTree = getHTMLNode(serialized).body.children[0]! + //"meta" is localize: false field + const meta = findByClass(docTree.children, 'meta') + expect(documentLevelArticle.meta).toBeDefined() + expect(meta).toBeUndefined() +}) + +test('Expect default stop types to be absent', () => { + const serialized = getSerialized(documentLevelArticle, 'document') + const docTree = getHTMLNode(serialized).body.children[0]! + //"hidden" is boolean field + const hidden = findByClass(docTree.children, 'hidden') + expect(documentLevelArticle.hidden).toBeDefined() + expect(hidden).toBeUndefined() +}) + +test('Expect custom stop types to be absent at all levels', () => { + const customStopTypes = [...defaultStopTypes, 'objectField'] + const serializer = BaseDocumentSerializer(schema) + const serialized = serializer.serializeDocument( + documentLevelArticle, + 'document', + 'en', + customStopTypes, + customSerializers, + ) + + const docTree = getHTMLNode(serialized).body.children[0]! + const config = findByClass(docTree.children, 'config') + expect(documentLevelArticle.config).toBeDefined() + expect(config).toBeUndefined() + + const arrayField = findByClass(docTree.children, 'content') + const nestedSerialized = findByClass(arrayField!.children, 'objectField') + const nestedObjField = documentLevelArticle.content.find( + (b: Record) => b._type === 'objectField', + ) + expect(nestedObjField).toBeDefined() + expect(nestedSerialized).toBeUndefined() +}) + +/* + * ANNOTATION AND INLINE BLOCK CONTENT + */ + +test('Unhandled inline objects and annotations should not hinder translation flows', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const inlineDocument = { + ...documentLevelArticle, + ...annotationAndInlineBlocks, + } + const serialized = getSerialized(inlineDocument, 'document') + const docTree = getHTMLNode(serialized).body.children[0]! + const arrayField = findByClass(docTree.children, 'content') + + //expect annotated object to have underlying text + const blockWithAnnotation = Array.from(arrayField!.children).find( + (node) => node.id === '0e55995095df', + ) + const unhandledAnnotation = findByClass( + blockWithAnnotation!.children, + 'unknown__pt__mark__annotation', + ) + expect(unhandledAnnotation?.innerHTML).toContain('text') + + //expect unknown inline object to be present but empty + //(this allows it to be merged back safely, but not sent to translation) + const inlineObject = findByClass(arrayField!.children, 'childObjectField') + expect(inlineObject?.innerHTML.length).toEqual(0) +}) + +test('Handled inline objects should be accurately represented per serializer', () => { + const inlineDocument = { + ...documentLevelArticle, + ...annotationAndInlineBlocks, + } + + const serializer = BaseDocumentSerializer(schema) + const serialized = serializer.serializeDocument( + inlineDocument, + 'document', + 'en', + defaultStopTypes, + addedCustomSerializers, + ) + const docTree = getHTMLNode(serialized).body.children[0]! + const arrayField = findByClass(docTree.children, 'content') + let inlineObject: Element | null = null + let inlineObjectBlock: Record | null = null + + Array.from(arrayField!.children).forEach((block: any) => { + if (!inlineObject) { + inlineObject = findByClass(block.children, 'childObjectField') ?? inlineObject + } + }) + + inlineDocument.content.forEach((block: Record) => { + if (block.children) { + block.children.forEach((span: Record) => { + if (span._type === 'childObjectField') { + inlineObjectBlock = span + } + }) + } + }) + + expect(inlineObject!.innerHTML).toContain(createCustomInnerHTML(inlineObjectBlock!.title)) +}) + +test('Handled annotations should be accurately represented per serializer', () => { + const inlineDocument = { + ...documentLevelArticle, + ...annotationAndInlineBlocks, + } + + const serializer = BaseDocumentSerializer(schema) + const serialized = serializer.serializeDocument( + inlineDocument, + 'document', + 'en', + defaultStopTypes, + addedCustomSerializers, + ) + const docTree = getHTMLNode(serialized).body.children[0]! + const arrayField = findByClass(docTree.children, 'content') + let annotation: Element | null = null + let annotationBlock: Record | null = null + + Array.from(arrayField!.children).forEach((block: any) => { + if (!annotation) { + annotation = findByClass(block.children, 'annotation') ?? annotation + } + }) + + inlineDocument.content.forEach((block: PortableTextBlock) => { + if (block.children && Array.isArray(block.children)) { + block.children.forEach((span: Record) => { + if (span.marks && span.marks.length) { + annotationBlock = span + } + }) + } + }) + + expect(annotation!.innerHTML).toEqual(annotationBlock!.text) +}) + +/* + * STYLE TAGS + */ +test('Serialized content should preserve style tags from Portable Text', () => { + const serialized = getSerialized(documentLevelArticle, 'document') + const docTree = getHTMLNode(serialized).body.children[0]! + const arrayField = findByClass(docTree.children, 'content') + const blockH1: any = documentLevelArticle.content.find( + (block: PortableTextBlock) => block.style === 'h1', + ) + const serializedH1 = arrayField?.querySelector('h1') + const blockH2: any = documentLevelArticle.content.find( + (block: PortableTextBlock) => block.style === 'h2', + ) + const serializedH2 = arrayField?.querySelector('h2') + expect(serializedH1?.innerHTML).toEqual(blockH1.children[0].text) + expect(serializedH2?.innerHTML).toEqual(blockH2.children[0].text) +}) + +/* + * V2 functionality -- be able to operate without a strict schema + */ + +test('Content with anonymous inline objects serializes all fields, at any depth', () => { + const serialized = BaseDocumentSerializer(inlineSchema).serializeDocument( + inlineDocumentLevelArticle, + 'document', + ) + const docTree = getHTMLNode(serialized).body.children[0]! + const tabs = findByClass(docTree.children, 'tabs')!.children[0]! + const config = findByClass(tabs.children, 'config')!.children[0]! + const fieldNames = getValidFields(inlineDocumentLevelArticle.tabs.config) + const foundFieldNames = Array.from(config.children).map((child) => child.className) + expect(foundFieldNames.sort()).toEqual(fieldNames.sort()) + const nestedObjHTML = findByClass(config.children, 'objectAsField')!.children[0]! + const nestedObj = inlineDocumentLevelArticle.tabs.config.objectAsField + const nestedFieldNames = Array.from(nestedObjHTML.children).map((child) => child.className) + expect(nestedFieldNames.sort()).toEqual(getValidFields(nestedObj).sort()) + + const content = findByClass(tabs.children, 'content')! + const keysHTML = Array.from(content.children).map((child) => child.id) + const keysJSON = inlineDocumentLevelArticle.tabs.content.map((child: any) => child._key as string) + expect(keysHTML.sort()).toEqual(keysJSON.sort()) + + const objectInArrayHTML = findByClass(content.children, 'objectField') + const objectInArrayHTMLFieldNames = Array.from(objectInArrayHTML!.children).map( + (child) => child.className, + ) + const objectInArray: any = inlineDocumentLevelArticle.tabs.content.find( + (obj: any) => obj._type === 'objectField', + ) + expect(objectInArrayHTMLFieldNames.sort()).toEqual(getValidFields(objectInArray).sort()) +}) + +/* + * LIST ITEMS + */ +test('Serialized content should preserve list style and depth from Portable text', () => { + const serialized = getSerialized(documentLevelArticle, 'document') + const docTree = getHTMLNode(serialized).body.children[0]! + const arrayField = findByClass(docTree.children, 'content') + const listItem: any = documentLevelArticle.content.find( + (block: PortableTextBlock) => block.listItem === 'bullet' && block.style === 'h2', + ) + + const serializedListItem = arrayField?.querySelectorAll('li')[2] + const nestedListItem: any = documentLevelArticle.content.find( + (block: PortableTextBlock) => block.listItem === 'bullet' && block.level === 2, + ) + const serializedNestedListItem = arrayField?.querySelectorAll('li')[1] + //include quote style for completeness + expect(serializedListItem?.innerHTML).toContain(listItem.children[0].text) + expect(serializedListItem?.innerHTML).toContain('h2') + + expect(serializedNestedListItem?.innerHTML).toEqual(nestedListItem.children[0].text) +}) + +test('Values in a field are not repeated (indicating serializers are stateless)', () => { + const serialized = getSerialized(documentLevelArticle, 'document') + const docTree = getHTMLNode(serialized).body.children[0]! + const HTMLList = findByClass(docTree.children, 'tags') + const tags = documentLevelArticle.tags + expect(HTMLList?.innerHTML).toContain(tags[0]) + expect(HTMLList?.innerHTML).toContain(tags[1]) + expect(HTMLList?.innerHTML).toContain(tags[2]) +}) diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/documentLevelSerialization.test.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/documentLevelSerialization.test.ts new file mode 100644 index 0000000000..ea6ab40cb6 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/documentLevelSerialization.test.ts @@ -0,0 +1,103 @@ +import type {PortableTextBlock} from 'sanity' +import {describe, expect, test} from 'vitest' + +import {getSerialized, getValidFields, toPlainText} from '../helpers' +import {documentLevelArticle, findByClass, getHTMLNode} from './utils' + +const serialized = getSerialized(documentLevelArticle, 'document') +const docTree = getHTMLNode(serialized).body.children[0]! + +test('Global test of working doc-level functionality and snapshot match', () => { + expect(serialized).toMatchSnapshot() +}) +/* + * Top-level plain text + */ +test('String and text types get serialized correctly at top-level', () => { + const HTMLString = findByClass(docTree.children, 'title') + const HTMLText = findByClass(docTree.children, 'snippet') + expect(HTMLString?.innerHTML).toEqual(documentLevelArticle.title) + expect(HTMLText?.innerHTML).toEqual(documentLevelArticle.snippet) +}) + +/* + * Presence and accuracy of fields + */ +describe('Presence and accuracy of fields in "vanilla" deserialization -- objects', () => { + //parent node is always div with classname of field with a nested div + //that has classname of obj type + const configObj = findByClass(docTree.children, 'config') + const objectField = configObj!.children[0]! + + test('Top-level nested objects contain all serializable fields -- document level', () => { + const fieldNames = getValidFields(documentLevelArticle.config) + const foundFieldNames = Array.from(objectField.children).map((child) => child.className) + expect(foundFieldNames.sort()).toEqual(fieldNames.sort()) + }) + + test('Nested object in object contains all serializable fields -- document level', () => { + const nestedObject = findByClass(objectField.children, 'objectAsField')!.children[0]! + const fieldNames = getValidFields(documentLevelArticle.config.objectAsField) + const foundFieldNames = Array.from(nestedObject.children).map((child) => child.className) + expect(foundFieldNames.sort()).toEqual(fieldNames.sort()) + }) + + test('Nested object contains accurate values -- document level', () => { + const title = documentLevelArticle.config.title + const blockText = toPlainText(documentLevelArticle.config.nestedArrayField) + + expect(objectField?.innerHTML).toContain(title) + expect(objectField?.innerHTML).toContain(blockText) + }) + + test('Nested object in an object contains accurate values -- document level', () => { + const nestedObject = findByClass(objectField.children, 'objectAsField')!.children[0]! + const title = documentLevelArticle.config.objectAsField.title + const blockText = toPlainText(documentLevelArticle.config.objectAsField.content) + + expect(nestedObject.innerHTML).toContain(title) + expect(nestedObject.innerHTML).toContain(blockText) + }) +}) + +describe('Presence and accuracy of fields in vanilla deserialization -- arrays', () => { + const arrayField = findByClass(docTree.children, 'content') + + test('Array contains all serializable blocks with keys, in order -- document level', () => { + const origKeys = documentLevelArticle.content.map((block: PortableTextBlock) => block._key) + const serializedKeys = Array.from(arrayField!.children).map((block) => block.id) + expect(serializedKeys).toEqual(origKeys) + }) + + test('Array contains top-level block text -- document level', () => { + const blockText = toPlainText(documentLevelArticle.content).trim() + const blockStrings = blockText.split('\n\n') + blockStrings.forEach((substring: string) => expect(arrayField?.innerHTML).toContain(substring)) + }) + + test('Object in array contains all serializable fields -- document level', () => { + const objectInArray = findByClass(arrayField!.children, 'objectField') + const fieldNames = getValidFields( + documentLevelArticle.content.find( + (block: Record) => block._type === 'objectField', + )!, + ) + const foundFieldNames = Array.from(objectInArray!.children).map((child) => child.className) + expect(foundFieldNames.sort()).toEqual(fieldNames.sort()) + }) + + test('Object in array contains accurate values in nested object -- document level', () => { + const objectInArray = findByClass(arrayField!.children, 'objectField') + const nestedObject = findByClass(objectInArray!.children, 'objectAsField') + const title = documentLevelArticle.content.find( + (block: Record) => block._type === 'objectField', + )!.objectAsField!.title + const blockText = toPlainText( + documentLevelArticle.content.find( + (block: Record) => block._type === 'objectField', + )!.objectAsField!.content, + ).trim() + expect(nestedObject?.innerHTML).toContain(title) + expect(nestedObject?.innerHTML).toContain(blockText) + }) +}) diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/fieldLevelSerialization.test.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/fieldLevelSerialization.test.ts new file mode 100644 index 0000000000..557e678cf2 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/fieldLevelSerialization.test.ts @@ -0,0 +1,129 @@ +import type {PortableTextBlock} from 'sanity' +import {describe, expect, test} from 'vitest' + +import {getSerialized, getValidFields, toPlainText} from '../helpers' +import {fieldLevelArticle, findByClass, getHTMLNode, nestedLanguageFields} from './utils' + +const serialized = getSerialized(fieldLevelArticle, 'field') +const docTree = getHTMLNode(serialized).body.children[0]! + +test('Global test of working field-level functionality and snapshot match', () => { + expect(serialized).toMatchSnapshot() +}) + +test('String and text types get serialized correctly at top-level -- field level', () => { + const titleObj = findByClass(docTree.children, 'title')?.children[0] + const HTMLString = findByClass(titleObj!.children, 'en') + const snippetObj = findByClass(docTree.children, 'snippet')?.children[0] + const HTMLText = findByClass(snippetObj!.children, 'en') + expect(HTMLString?.innerHTML).toEqual(fieldLevelArticle.title.en) + expect(HTMLText?.innerHTML).toEqual(fieldLevelArticle.snippet.en) +}) + +describe('Presence and accuracy of fields in "vanilla" deserialization -- objects', () => { + const getFieldLevelObjectField = () => { + const config = findByClass(docTree.children, 'config')?.children[0] + //return english field + const englishConfig = findByClass(config!.children, 'en') + return findByClass(englishConfig!.children, 'objectField') + } + + const objectField = getFieldLevelObjectField() + + test('Top-level nested objects contain all serializable fields -- field level', () => { + const fieldNames = getValidFields(fieldLevelArticle.config.en) + const foundFieldNames = Array.from(objectField!.children).map((child) => child.className) + + expect(foundFieldNames.sort()).toEqual(fieldNames.sort()) + }) + + test('Nested object in object contains all serializable fields -- field Level', () => { + const nestedObject = findByClass(objectField!.children, 'objectAsField')!.children[0] + const fieldNames = getValidFields(fieldLevelArticle.config.en.objectAsField) + const foundFieldNames = Array.from(nestedObject!.children).map((child) => child.className) + expect(foundFieldNames.sort()).toEqual(fieldNames.sort()) + }) + + test('Nested object contains accurate values -- field level', () => { + const title = fieldLevelArticle.config.en.title + const blockText = toPlainText(fieldLevelArticle.config.en.nestedArrayField) + + expect(objectField?.innerHTML).toContain(title) + expect(objectField?.innerHTML).toContain(blockText) + }) + + test('Nested object in an object contains accurate values -- field level', () => { + const nestedObject = findByClass(objectField!.children, 'objectAsField')!.children[0] + const title = fieldLevelArticle.config.en.objectAsField.title + const blockText = toPlainText(fieldLevelArticle.config.en.objectAsField.content) + + expect(nestedObject!.innerHTML).toContain(title) + expect(nestedObject!.innerHTML).toContain(blockText) + }) +}) + +/* + * Presence and accuracy of fields in "vanilla" deserialization -- arrays + */ +describe('Presence and accurancy of fields in "vanilla" deserialization -- arrays', () => { + const getFieldLevelArrayField = () => { + const content = findByClass(docTree.children, 'content')?.children[0] + return findByClass(content!.children, 'en') + } + const arrayField = getFieldLevelArrayField() + + test('Array contains all serializable blocks with keys, in order -- field level', () => { + const origKeys = fieldLevelArticle.content.en.map((block: PortableTextBlock) => block._key) + const serializedKeys = Array.from(arrayField!.children).map((block) => block.id) + expect(serializedKeys).toEqual(origKeys) + }) + + test('Array contains top-level block text -- field level', () => { + const blockText = toPlainText(fieldLevelArticle.content.en).trim() + expect(arrayField?.innerHTML).toContain(blockText) + }) + + test('Object in array contains all serializable fields -- field level', () => { + const objectInArray = findByClass(arrayField!.children, 'objectField') + const fieldNames = getValidFields( + fieldLevelArticle.content.en.find( + (block: Record) => block._type === 'objectField', + )!, + ) + const foundFieldNames = Array.from(objectInArray!.children).map((child) => child.className) + expect(foundFieldNames.sort()).toEqual(fieldNames.sort()) + }) + + test('Object in array contains accurate values in nested object -- field level', () => { + const objectInArray = findByClass(arrayField!.children, 'objectField') + const nestedObject = findByClass(objectInArray!.children, 'objectAsField') + const title = fieldLevelArticle.content.en.find( + (block: Record) => block._type === 'objectField', + )!.objectAsField!.title + const blockText = toPlainText( + fieldLevelArticle.content.en.find( + (block: Record) => block._type === 'objectField', + )!.objectAsField!.content, + ).trim() + expect(nestedObject?.innerHTML).toContain(title) + expect(nestedObject?.innerHTML).toContain(blockText) + }) +}) + +test('Nested locale fields make it to serialization, but only base lang', () => { + const nestedLocales = {...fieldLevelArticle, ...nestedLanguageFields} + const nestedSerialized = getSerialized(nestedLocales, 'field') + const nestedDocTree = getHTMLNode(nestedSerialized).body.children[0]! + const slices = findByClass(nestedDocTree.children, 'slices') + const pageFields = findByClass(nestedDocTree.children, 'pageFields') + expect(slices?.innerHTML).toContain( + (nestedLanguageFields as any).slices[0].en[0].children[0].text, + ) + expect(pageFields?.innerHTML).toContain(nestedLanguageFields.pageFields.name.en) + expect(slices?.innerHTML).not.toContain( + (nestedLanguageFields as any).slices[0].fr_FR[0].children[0].text, + ) + expect(pageFields?.innerHTML).not.toContain(nestedLanguageFields.pageFields.name.fr_FR) +}) + +//also test: setting different base language! diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/internationalizedArraySerialization.test.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/internationalizedArraySerialization.test.ts new file mode 100644 index 0000000000..1423786f54 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/internationalizedArraySerialization.test.ts @@ -0,0 +1,138 @@ +import type {PortableTextBlock} from 'sanity' +import {expect, test, describe} from 'vitest' + +import {getI18nArrayItem, getSerialized, getValidFields, toPlainText} from '../helpers' +import {findByClass, getHTMLNode, internationalizedArrayArticle} from './utils' + +const serialized = getSerialized(internationalizedArrayArticle, 'internationalizedArray') +const docTree = getHTMLNode(serialized).body.children[0]! + +const findById = (children: HTMLCollection, id: string): Element | undefined => { + return Array.from(children).find((node) => { + return node.id.toLowerCase() === id.toLowerCase() + }) +} + +test('Global test of working internationalized array-level functionality and snapshot match', () => { + expect(serialized).toMatchSnapshot() +}) + +test('String and text types get serialized correctly at top-level -- internationalized array', () => { + const titleObj = findByClass(docTree.children, 'title') + const englishTitleHTML = findById(titleObj!.children, 'en') + const englishTitleValueHTML = findByClass(englishTitleHTML!.children, 'value') + + const snippetObj = findByClass(docTree.children, 'snippet') + const englishSnippetHTML = findById(snippetObj!.children, 'en') + const englishSnippetValueHTML = findByClass(englishSnippetHTML!.children, 'value') + + expect(englishTitleValueHTML?.innerHTML).toEqual( + getI18nArrayItem(internationalizedArrayArticle.title, 'en')?.value, + ) + + expect(englishSnippetValueHTML?.innerHTML).toEqual( + getI18nArrayItem(internationalizedArrayArticle.snippet, 'en')?.value, + ) +}) + +describe('Presence and accuracy of fields in "vanilla" deserialization -- objects', () => { + const getInternationalizedArrayObjectField = () => { + const config = findByClass(docTree.children, 'config') + const englishConfig = findById(config!.children, 'en') + return findByClass(englishConfig!.children, 'value')?.children[0] + } + + const objectField = getInternationalizedArrayObjectField() + const origObjectField = getI18nArrayItem(internationalizedArrayArticle.config, 'en') + ?.value as Record + + test('Top-level nested objects contain all serializable fields -- internationalized array', () => { + const fieldNames = getValidFields(origObjectField) + + const foundFieldNames = Array.from(objectField!.children).map((child) => child.className) + expect(foundFieldNames.sort()).toEqual(fieldNames.sort()) + }) + + test('Nested object in object contains all serializable fields -- internationalized array', () => { + const nestedObject = findByClass(objectField!.children, 'objectAsField')!.children[0] + const fieldNames = getValidFields(origObjectField.objectAsField) + const foundFieldNames = Array.from(nestedObject!.children).map((child) => child.className) + expect(foundFieldNames.sort()).toEqual(fieldNames.sort()) + }) + + test('Nested object contains accurate values -- internationalized array', () => { + const title = origObjectField.objectAsField.title + const blockText = toPlainText(origObjectField.nestedArrayField) + + expect(objectField?.innerHTML).toContain(title) + expect(objectField?.innerHTML).toContain(blockText) + }) + + test('Nested object in an object contains accurate values -- internationalized array', () => { + const nestedObject = findByClass(objectField!.children, 'objectAsField')!.children[0] + const title = origObjectField.objectAsField.title + const blockText = toPlainText(origObjectField.objectAsField.content) + + expect(nestedObject!.innerHTML).toContain(title) + expect(nestedObject!.innerHTML).toContain(blockText) + }) +}) + +/* + * Presence and accuracy of fields in "vanilla" deserialization -- arrays + */ +describe('Presence and accurancy of fields in "vanilla" deserialization -- arrays', () => { + const getInternationalizedArrayArrayField = () => { + const content = findByClass(docTree.children, 'content') + const englishContent = findById(content!.children, 'en') + return findByClass(englishContent!.children, 'value') + } + const arrayField = getInternationalizedArrayArrayField() + const origArrayField = getI18nArrayItem(internationalizedArrayArticle.content, 'en') + ?.value as any[] + + test('Array contains all serializable blocks with keys, in order -- internationalized array', () => { + const origKeys = origArrayField.map((block: PortableTextBlock) => block._key) + const serializedKeys = Array.from(arrayField!.children).map((block) => block.id) + expect(serializedKeys).toEqual(origKeys) + }) + + test('Array contains top-level block text -- internationalized array', () => { + const blockText = toPlainText(origArrayField).trim() + expect(arrayField?.innerHTML).toContain(blockText) + }) + + test('Object in array contains all serializable fields -- internationalized array', () => { + const objectInArray = findByClass(arrayField!.children, 'objectField') + const fieldNames = getValidFields( + origArrayField.find((block: Record) => block._type === 'objectField'), + ) + const foundFieldNames = Array.from(objectInArray!.children).map((child) => child.className) + expect(foundFieldNames.sort()).toEqual(fieldNames.sort()) + }) + + test('Object in array contains accurate values in nested object -- internationalized array', () => { + const objectInArray = findByClass(arrayField!.children, 'objectField') + const nestedObject = findByClass(objectInArray!.children, 'objectAsField') + const title = origArrayField.find((block: Record) => block._type === 'objectField') + .objectAsField.title + const blockText = toPlainText( + origArrayField.find((block: Record) => block._type === 'objectField') + .objectAsField.content, + ).trim() + expect(nestedObject?.innerHTML).toContain(title) + expect(nestedObject?.innerHTML).toContain(blockText) + }) +}) + +//works, but requires another schema declaration. resolve later. +test('Nested locale fields make it to serialization, but only base lang', () => { + const slices = findByClass(docTree.children, 'slices')?.children[0] + const origSlices: any = internationalizedArrayArticle.slices[0]!.content + const engSlice = getI18nArrayItem(origSlices, 'en').value + const frenchSlice = getI18nArrayItem(origSlices, 'fr_FR').value + // @ts-expect-error i18n array item value is typed as unknown + expect(slices?.innerHTML).toContain(engSlice[0]!.children![0].text) + // @ts-expect-error i18n array item value is typed as unknown + expect(slices?.innerHTML).not.toContain(frenchSlice[0]!.children![0].text) +}) diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/internationalizedArrayV5.test.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/internationalizedArrayV5.test.ts new file mode 100644 index 0000000000..33e84708c1 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/internationalizedArrayV5.test.ts @@ -0,0 +1,155 @@ +import {expect, test, describe} from 'vitest' + +import {BaseDocumentMerger} from '../../src' +import {getInternationalizedArrayDocument} from '../BaseDocumentMerger/utils' +import {getI18nArrayItem, getSerialized} from '../helpers' +import {findByClass, getHTMLNode, internationalizedArrayArticle} from './utils' + +const findById = (children: HTMLCollection, id: string): Element | undefined => { + return Array.from(children).find((node) => node.id.toLowerCase() === id.toLowerCase()) +} + +/* + * Recursively converts a v4 internationalized array document (language in `_key`) + * to the v5 format (language in a `language` field, with a stable random `_key`). + */ +const toV5 = (value: any): any => { + if (Array.isArray(value)) { + const isI18nArray = + value.length > 0 && + typeof value[0] === 'object' && + value[0] !== null && + value[0]._type.startsWith('internationalizedArray') + + return value.map((item) => { + if (isI18nArray && item && typeof item === 'object') { + const {_key, ...rest} = item + return {...toV5(rest), _key: `v5-${_key}`, language: _key} + } + return toV5(item) + }) + } + + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, toV5(val)])) + } + + return value +} + +const v5Article = toV5(internationalizedArrayArticle) + +describe('Serialization supports v5 (language field) internationalized arrays', () => { + const serialized = getSerialized(v5Article, 'internationalizedArray') + const docTree = getHTMLNode(serialized).body.children[0]! + + test('Base language string fields are exported for v5 data', () => { + const titleObj = findByClass(docTree.children, 'title') + const englishTitleHTML = findById(titleObj!.children, 'en') + const englishTitleValueHTML = findByClass(englishTitleHTML!.children, 'value') + + expect(englishTitleValueHTML?.innerHTML).toEqual( + getI18nArrayItem(internationalizedArrayArticle.title, 'en')?.value, + ) + }) + + test('The `language` code is not exposed as a translatable string', () => { + expect(serialized.content).not.toContain('class="language"') + }) + + test('v5 documents serialize identically to v4 documents', () => { + const serializedV4 = getSerialized(internationalizedArrayArticle, 'internationalizedArray') + expect(serialized.content).toEqual(serializedV4.content) + }) + + test('Items are identified by language code, not by random key', () => { + expect(serialized.content).toContain('id="en"') + expect(serialized.content).not.toContain('v5-') + }) +}) + +describe('Merge mirrors the document format', () => { + const translated = getInternationalizedArrayDocument() + + test('Writes v5 format (language field + random _key) when base doc is v5', () => { + const patches = BaseDocumentMerger.internationalizedArrayMerge( + translated, + v5Article, + 'es_ES', + 'en', + 0, + ) + expect(patches.length).toBeGreaterThan(0) + for (const patch of patches as Array>) { + const item = patch.items[0] + expect(item.language).toEqual('es_ES') + expect(item._key).not.toEqual('es_ES') + expect(item._key).toBeTruthy() + } + }) + + test('Writes legacy format (_key = language) when base doc is v4', () => { + const patches = BaseDocumentMerger.internationalizedArrayMerge( + translated, + internationalizedArrayArticle, + 'es_ES', + 'en', + 0, + ) + expect(patches.length).toBeGreaterThan(0) + for (const patch of patches as Array>) { + const item = patch.items[0] + expect(item._key).toEqual('es_ES') + expect(item.language).toBeUndefined() + } + }) + + test('Replaces an existing v5 locale entry by its real _key', () => { + const withExisting = toV5(internationalizedArrayArticle) + withExisting.title.push({ + _key: 'existing-es-key', + _type: 'internationalizedArrayStringFieldValue', + language: 'es_ES', + value: 'Old translation', + }) + + const patches = BaseDocumentMerger.internationalizedArrayMerge( + translated, + withExisting, + 'es_ES', + 'en', + 0, + ) as Array> + + const titlePatch = patches.find((patch) => patch.selector.startsWith('title')) + expect(titlePatch?.at).toEqual('replace') + expect(titlePatch?.selector).toContain('existing-es-key') + //the replaced item keeps its `_key`, so item identity stays stable + //across repeated imports + expect(titlePatch?.items[0]._key).toEqual('existing-es-key') + expect(titlePatch?.items[0].language).toEqual('es_ES') + }) + + test('Reads translated values from a raw v5-format translated document', () => { + //a translated document that never round-tripped through the serializer + //and still stores its base language in the v5 format + const rawV5Translated = toV5(internationalizedArrayArticle) + const titleItem = rawV5Translated.title.find( + (item: Record) => item.language === 'en', + ) + titleItem.value = 'A raw v5 translated title' + + const patches = BaseDocumentMerger.internationalizedArrayMerge( + rawV5Translated, + v5Article, + 'es_ES', + 'en', + 0, + ) as Array> + + const titlePatch = patches.find((patch) => patch.selector.startsWith('title')) + expect(titlePatch?.at).toEqual('after') + expect(titlePatch?.items[0].language).toEqual('es_ES') + expect(titlePatch?.items[0].value).toEqual('A raw v5 translated title') + }) +}) diff --git a/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/utils.ts b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/utils.ts new file mode 100644 index 0000000000..a3eeee5d62 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/BaseDocumentSerializer/utils.ts @@ -0,0 +1,28 @@ +import type {SerializedDocument} from '../../src' +import annotationAndInlineBlocks from '../__fixtures__/annotationAndInlineBlocks.json' +import documentLevelArticle from '../__fixtures__/documentLevelArticle.json' +import fieldLevelArticle from '../__fixtures__/fieldLevelArticle.json' +import inlineDocumentLevelArticle from '../__fixtures__/inlineDocumentLevelArticle.json' +import internationalizedArrayArticle from '../__fixtures__/internationalizedArrayArticle.json' +import nestedLanguageFields from '../__fixtures__/nestedLanguageFields.json' + +export {default as inlineSchema} from '../__fixtures__/inlineSchema' +export {default as schema} from '../__fixtures__/schema' +export { + annotationAndInlineBlocks, + documentLevelArticle, + fieldLevelArticle, + inlineDocumentLevelArticle, + internationalizedArrayArticle, + nestedLanguageFields, +} + +export const getHTMLNode = (serialized: SerializedDocument): Document => { + const htmlString = serialized.content + const parser = new DOMParser() + return parser.parseFromString(htmlString, 'text/html') +} + +export const findByClass = (children: HTMLCollection, className: string): Element | undefined => { + return Array.from(children).find((node) => node.className === className) +} diff --git a/plugins/sanity-naive-html-serializer/test/__fixtures__/annotationAndInlineBlocks.json b/plugins/sanity-naive-html-serializer/test/__fixtures__/annotationAndInlineBlocks.json new file mode 100644 index 0000000000..e40f818169 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/__fixtures__/annotationAndInlineBlocks.json @@ -0,0 +1,140 @@ +{ + "content": [ + { + "_key": "6fe9a3ecaf0f", + "style": "normal", + "markDefs": [], + "_type": "block", + "listItem": "number", + "level": 1, + "children": [ + { + "_type": "span", + "marks": [], + "text": "", + "_key": "47eb0224d6b5" + }, + { + "_key": "d3dcb15f4bfe", + "_type": "childObjectField", + "title": "This is an inline object in a top-level block list" + } + ] + }, + { + "_key": "a1b026261117", + "_type": "block", + "markDefs": [], + "style": "normal", + "level": 2, + "listItem": "number", + "children": [ + { + "_key": "d3dcb15f4bfe", + "_type": "childObjectField", + "title": "This is an inline object in a nested list item" + } + ] + }, + { + "_key": "0e55995095df", + "_type": "block", + "children": [ + { + "_key": "a94d5e26cc99", + "_type": "span", + "marks": [], + "text": "This is block " + }, + { + "_key": "3d31216296ab", + "_type": "span", + "marks": ["2f5ec56ab061"], + "text": "text" + }, + { + "_key": "d669ccc1799e", + "_type": "span", + "marks": [], + "text": " at the top level. " + }, + { + "_key": "0871af4fa11c", + "_type": "childObjectField", + "title": "This is an inline object in a top-level block text field" + }, + { + "_key": "be01d00a77a2", + "_type": "span", + "marks": [], + "text": "" + } + ], + "markDefs": [ + { + "_key": "2f5ec56ab061", + "_type": "annotation", + "content": [ + { + "_key": "f94daf68d620", + "_type": "block", + "children": [ + { + "_key": "0f9944aec4a2", + "_type": "span", + "marks": [], + "text": "This is an annotation on the word \"text\" on block-level content" + } + ], + "markDefs": [], + "style": "normal" + } + ], + "title": "Annotation" + } + ], + "style": "normal" + }, + { + "_key": "7e1268803bda", + "_type": "objectField", + "nestedArrayField": [ + { + "_key": "40703bc96a33", + "_type": "block", + "children": [ + { + "_key": "103500a73aa4", + "_type": "span", + "marks": [], + "text": "This is block text in a nested object." + } + ], + "markDefs": [], + "style": "normal" + } + ], + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "663b9b01b63a", + "_type": "block", + "children": [ + { + "_key": "c64b6cda3790", + "_type": "span", + "marks": [], + "text": "This is block text in a nested object in an object in top-level block text." + } + ], + "markDefs": [], + "style": "normal" + } + ], + "title": "This is a nested object in an object in top-level block text." + }, + "title": "This is an object in top-level block text." + } + ] +} diff --git a/plugins/sanity-naive-html-serializer/test/__fixtures__/customStyles.json b/plugins/sanity-naive-html-serializer/test/__fixtures__/customStyles.json new file mode 100644 index 0000000000..333be51db1 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/__fixtures__/customStyles.json @@ -0,0 +1,62 @@ +{ + "content": [ + { + "_key": "a4d177e92666", + "_type": "block", + "children": [ + { + "_key": "a896ea6fcd59", + "_type": "span", + "marks": [], + "text": "This is block text at the top level." + } + ], + "markDefs": [], + "style": "custom1" + }, + { + "_key": "6fe9a3ecaf0f", + "style": "custom1", + "markDefs": [], + "_type": "block", + "listItem": "number", + "level": 1, + "children": [ + { + "_type": "span", + "marks": [], + "text": "This is custom-styled text in a list item", + "_key": "47eb0224d6b5" + } + ] + }, + { + "_key": "9e2ab13c6d63", + "_type": "block", + "children": [ + { + "_key": "17e856dc4766", + "_type": "span", + "marks": [], + "text": "This is h1 text" + } + ], + "markDefs": [], + "style": "h1" + }, + { + "_key": "297142fbaf21", + "_type": "block", + "children": [ + { + "_key": "0b2f9e94a161", + "_type": "span", + "marks": [], + "text": "This is h2 text" + } + ], + "markDefs": [], + "style": "h2" + } + ] +} diff --git a/plugins/sanity-naive-html-serializer/test/__fixtures__/documentLevelArticle.json b/plugins/sanity-naive-html-serializer/test/__fixtures__/documentLevelArticle.json new file mode 100644 index 0000000000..679ea4cbce --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/__fixtures__/documentLevelArticle.json @@ -0,0 +1,185 @@ +{ + "_id": "drafts.d8ffc675-ce86-4f60-9ac8-da164cde3b0a", + "_rev": "fxcnqj-uew-gr7-v6r-z965tfm69", + "_type": "documentLevelArticle", + "_createdAt": "2021-09-01T23:00:13Z", + "_updatedAt": "2021-09-01T23:02:05Z", + "config": { + "_type": "objectField", + "nestedArrayField": [ + { + "_key": "4a58adc7c507", + "_type": "block", + "children": [ + { + "_key": "4f6bc15ae261", + "_type": "span", + "marks": [], + "text": "This is block text 1 level deep" + } + ], + "markDefs": [], + "style": "normal" + } + ], + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "1aa228ac848b", + "_type": "block", + "children": [ + { + "_key": "e2cf67af8e62", + "_type": "span", + "marks": [], + "text": "This is a block text 2 levels deep" + } + ], + "markDefs": [], + "style": "normal" + } + ], + "title": "This is one level deeper" + }, + "title": "This is an object nested in a document" + }, + "content": [ + { + "_key": "a4d177e92666", + "_type": "block", + "children": [ + { + "_key": "a896ea6fcd59", + "_type": "span", + "marks": [], + "text": "This is block text at the top level." + } + ], + "markDefs": [], + "style": "normal" + }, + { + "_key": "c0313627775e", + "_type": "objectField", + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "4f24f6fbfae7", + "_type": "block", + "children": [ + { + "_key": "d68f4288f5b7", + "_type": "span", + "marks": [], + "text": "This is block text in a nested object in an object in top-level block text." + } + ], + "markDefs": [], + "style": "normal" + } + ], + "title": "This is a nested object in an object in top-level block text." + }, + "title": "This is an object in top-level block text." + }, + { + "_key": "9e2ab13c6d63", + "_type": "block", + "children": [ + { + "_key": "17e856dc4766", + "_type": "span", + "marks": [], + "text": "This is h1 text" + } + ], + "markDefs": [], + "style": "h1" + }, + { + "_key": "297142fbaf21", + "_type": "block", + "children": [ + { + "_key": "0b2f9e94a161", + "_type": "span", + "marks": [], + "text": "This is h2 text" + } + ], + "markDefs": [], + "style": "h2" + }, + { + "_key": "76e648fc3845", + "_type": "block", + "children": [ + { + "_key": "49f466bbc78c", + "_type": "span", + "marks": [], + "text": "Bullet 1" + } + ], + "level": 1, + "listItem": "bullet", + "markDefs": [], + "style": "normal" + }, + { + "_key": "d090cd8b27d2", + "_type": "block", + "children": [ + { + "_key": "599c6991018f", + "_type": "span", + "marks": [], + "text": "nested bullet a" + } + ], + "level": 2, + "listItem": "bullet", + "markDefs": [], + "style": "normal" + }, + { + "_key": "1cdffa5d50f5", + "_type": "block", + "children": [ + { + "_key": "c16eb16f01cb", + "_type": "span", + "marks": [], + "text": "Styled bullet 2" + } + ], + "level": 1, + "listItem": "bullet", + "markDefs": [], + "style": "h2" + }, + { + "_key": "da29f5063059", + "_type": "block", + "children": [ + { + "_key": "e3d7e3eaad54", + "_type": "span", + "marks": [], + "text": "Number 1" + } + ], + "level": 1, + "listItem": "number", + "markDefs": [], + "style": "h3" + } + ], + "hidden": true, + "meta": "Do not translate this", + "snippet": "This is text in my text field.", + "tags": ["tag 1", "tag 2", "tag 3"], + "title": "My Document-Level Article" +} diff --git a/plugins/sanity-naive-html-serializer/test/__fixtures__/fieldLevelArticle.json b/plugins/sanity-naive-html-serializer/test/__fixtures__/fieldLevelArticle.json new file mode 100644 index 0000000000..0d555ec42f --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/__fixtures__/fieldLevelArticle.json @@ -0,0 +1,107 @@ +{ + "_createdAt": "2021-10-10T23:00:13Z", + "_id": "drafts.2947533e-1ea5-4116-955b-339608d3445d", + "_rev": "l58oha-n06-1s4-f6i-74g6cuakl", + "_type": "fieldLevelArticle", + "_updatedAt": "2021-10-10T23:02:05Z", + "slug": { + "_type": "slug", + "current": "happy-kitchen-hamburger" + }, + "config": { + "en": { + "_type": "objectField", + "nestedArrayField": [ + { + "_key": "f49b4d7e3e51", + "_type": "block", + "children": [ + { + "_key": "a6170f21181c", + "_type": "span", + "marks": [], + "text": "This is block text 1 level deep" + } + ], + "markDefs": [], + "style": "normal" + } + ], + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "869168fe3a2a", + "_type": "block", + "children": [ + { + "_key": "10d423df228a", + "_type": "span", + "marks": [], + "text": "This is block text 2 levels deep" + } + ], + "markDefs": [], + "style": "normal" + } + ], + "title": "This is one level deeper" + }, + "title": "This is an object nested in a document" + } + }, + "content": { + "en": [ + { + "_key": "e2a39d768ff8", + "_type": "block", + "children": [ + { + "_key": "216e97c5a5cc", + "_type": "span", + "marks": [], + "text": "This is block text at the top level." + } + ], + "markDefs": [], + "style": "normal" + }, + { + "_key": "271b0c6ee984", + "_type": "objectField", + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "4f24f6fbfae7", + "_type": "block", + "children": [ + { + "_key": "d68f4288f5b7", + "_type": "span", + "marks": [], + "text": "This is block text in a nested object in an object in top-level block text." + } + ], + "markDefs": [], + "style": "normal" + } + ], + "title": "This is a nested object in an object in top-level block text." + }, + "title": "This is an object in top-level block text." + } + ] + }, + "hidden": true, + "meta": "Do not translate this", + "snippet": { + "en": "This is text in my text field" + }, + "tags": { + "en": ["tag 1", "tag 2", "tag 3"] + }, + "title": { + "en": "My Field-Level Article" + } +} diff --git a/plugins/sanity-naive-html-serializer/test/__fixtures__/inlineDocumentLevelArticle.json b/plugins/sanity-naive-html-serializer/test/__fixtures__/inlineDocumentLevelArticle.json new file mode 100644 index 0000000000..e929c99eb5 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/__fixtures__/inlineDocumentLevelArticle.json @@ -0,0 +1,134 @@ +{ + "_id": "drafts.d8ffc675-ce86-4f60-9ac8-da164cde3b0a", + "_rev": "fxcnqj-uew-gr7-v6r-z965tfm69", + "_type": "documentLevelArticle", + "_createdAt": "2021-09-01T23:00:13Z", + "_updatedAt": "2021-09-01T23:02:05Z", + "tabs": { + "_type": "object", + "config": { + "_type": "objectField", + "nestedArrayField": [ + { + "_key": "4a58adc7c507", + "_type": "block", + "children": [ + { + "_key": "4f6bc15ae261", + "_type": "span", + "marks": [], + "text": "This is block text 1 level deep" + } + ], + "markDefs": [], + "style": "normal" + } + ], + "objectAsField": { + "_type": "object", + "content": [ + { + "_key": "1aa228ac848b", + "_type": "block", + "children": [ + { + "_key": "e2cf67af8e62", + "_type": "span", + "marks": [], + "text": "This is a block text 2 levels deep" + } + ], + "markDefs": [], + "style": "normal" + } + ], + "title": "This is one level deeper" + }, + "title": "This is an object nested in a document" + }, + "content": [ + { + "_key": "a4d177e92666", + "_type": "block", + "children": [ + { + "_key": "a896ea6fcd59", + "_type": "span", + "marks": [], + "text": "This is block text at the top level." + } + ], + "markDefs": [], + "style": "normal" + }, + { + "_key": "c0313627775e", + "_type": "objectField", + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "4f24f6fbfae7", + "_type": "block", + "children": [ + { + "_key": "d68f4288f5b7", + "_type": "span", + "marks": [], + "text": "This is block text in a nested object in an object in top-level block text." + } + ], + "markDefs": [], + "style": "normal" + } + ], + "title": "This is a nested object in an object in top-level block text." + }, + "title": "This is an object in top-level block text." + }, + { + "_key": "9e2ab13c6d63", + "_type": "block", + "children": [ + { + "_key": "17e856dc4766", + "_type": "span", + "marks": [], + "text": "This is h1 text" + } + ], + "markDefs": [], + "style": "h1" + }, + { + "_key": "297142fbaf21", + "_type": "block", + "children": [ + { + "_key": "0b2f9e94a161", + "_type": "span", + "marks": [], + "text": "This is h2 text" + } + ], + "markDefs": [], + "style": "h2" + } + ], + "hidden": true, + "meta": "Do not translate this", + "snippet": "This is text in my text field.", + "tags": ["tag 1", "tag 2", "tag 3"], + "title": "My Document-Level Article", + "arrayWithAnonymousObjects": [ + { + "_key": "4c146e0ab346", + "cells": ["Standard", "Americas", "EMEA"] + }, + { + "_key": "038f8c197939", + "cells": ["Friends", "Family", "Other"] + } + ] + } +} diff --git a/plugins/sanity-naive-html-serializer/test/__fixtures__/inlineSchema.ts b/plugins/sanity-naive-html-serializer/test/__fixtures__/inlineSchema.ts new file mode 100644 index 0000000000..2a293d3e9c --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/__fixtures__/inlineSchema.ts @@ -0,0 +1,271 @@ +import {Schema} from '@sanity/schema' + +const arrayField = { + name: 'arrayField', + title: 'Array Field', + type: 'array', + of: [ + {type: 'block'}, + { + type: 'object', + fields: [ + { + name: 'title', + title: 'Title', + type: 'string', + }, + { + name: 'objectAsField', + title: 'Object As Field', + type: 'object', + fields: [ + { + name: 'title', + title: 'Title', + type: 'string', + }, + { + name: 'content', + title: 'Content', + type: 'array', + of: [{type: 'block'}], + }, + ], + }, + { + name: 'nestedArrayField', + title: 'Nested Array Field', + type: 'array', + of: [{type: 'block'}, {type: 'childObjectField'}], + }, + ], + }, + ], +} + +const childObjectField = { + name: 'childObjectField', + title: 'Child Object Field', + type: 'object', + fields: [ + { + name: 'title', + title: 'Title', + type: 'string', + }, + { + name: 'content', + title: 'Content', + type: 'array', + of: [{type: 'block'}], + }, + ], +} + +const objectField = { + name: 'objectField', + title: 'Object Field', + type: 'object', + fields: [ + { + name: 'title', + title: 'Title', + type: 'string', + }, + { + name: 'objectAsField', + title: 'Object As Field', + type: 'object', + fields: [ + { + name: 'title', + title: 'Title', + type: 'string', + }, + { + name: 'content', + title: 'Content', + type: 'array', + of: [{type: 'block'}], + }, + ], + }, + { + name: 'nestedArrayField', + title: 'Nested Array Field', + type: 'array', + of: [{type: 'block'}, {type: 'childObjectField'}], + }, + ], +} + +const documentLevelArticle = { + name: 'documentLevelArticle', + title: 'Document Level Article', + type: 'document', + fields: [ + { + name: 'tabs', + type: 'object', + fields: [ + { + name: 'title', + title: 'Title', + type: 'string', + }, + { + name: 'meta', + title: 'Meta', + type: 'string', + localize: false, + }, + { + name: 'snippet', + title: 'Snippet', + type: 'text', + }, + { + name: 'tags', + title: 'Tags', + type: 'array', + of: [{type: 'string'}], + }, + { + name: 'hidden', + title: 'Hidden', + type: 'boolean', + }, + { + name: 'config', + title: 'Config', + type: 'objectField', + }, + { + name: 'content', + title: 'Content', + type: 'arrayField', + }, + ], + }, + ], +} + +function createLocaleFields(locales: string[], fieldType: Record) { + return locales.map((locale) => ({ + name: locale, + ...fieldType, + })) +} + +const fieldLevelArticle = { + name: 'fieldLevelArticle', + title: 'Field Level Article', + type: 'document', + fields: [ + { + name: 'title', + title: 'Title', + type: 'localeString', + }, + { + name: 'meta', + title: 'Meta', + type: 'string', + localize: false, + }, + { + name: 'snippet', + title: 'Snippet', + type: 'object', + fields: createLocaleFields(['en', 'fr', 'de'], {type: 'text'}), + }, + { + name: 'tags', + title: 'Tags', + type: 'object', + fields: createLocaleFields(['en', 'fr', 'de'], { + type: 'array', + of: [{type: 'string'}], + }), + }, + { + name: 'hidden', + title: 'Hidden', + type: 'boolean', + }, + { + name: 'config', + title: 'Config', + type: 'object', + fields: createLocaleFields(['en', 'fr', 'de'], {type: 'objectField'}), + }, + { + name: 'content', + title: 'Content', + type: 'object', + fields: createLocaleFields(['en', 'fr', 'de'], {type: 'arrayField'}), + }, + { + name: 'slices', + title: 'Slices', + type: 'array', + of: [{type: 'localeBlock'}, {type: 'reference', to: [{type: 'marketText'}]}], + }, + { + name: 'pageFields', + title: 'Page Fields', + type: 'pageFields', + }, + ], +} + +const localeBlock = { + name: 'localeBlock', + title: 'Locale Block', + type: 'object', + fields: createLocaleFields(['en', 'fr_FR', 'de_DE'], {type: 'arrayField'}), +} + +const localeString = { + name: 'localeString', + title: 'Locale String', + type: 'object', + fields: createLocaleFields(['en', 'fr_FR', 'de_DE'], {type: 'string'}), +} + +const pageFields = { + name: 'pageFields', + title: 'Page Fields', + type: 'object', + fields: [ + { + title: 'Page Name', + name: 'name', + type: 'localeString', + }, + { + name: 'slug', + type: 'string', + }, + ], +} + +const types = [ + arrayField, + childObjectField, + objectField, + documentLevelArticle, + fieldLevelArticle, + pageFields, + localeBlock, + localeString, +] + +// The explicit annotation keeps the exported type portable for declaration emit +// (TS2883): the Schema instance type otherwise resolves to a private dts chunk path. +const schema: InstanceType = new Schema({ + name: 'test', + types, +}) + +export default schema diff --git a/plugins/sanity-naive-html-serializer/test/__fixtures__/internationalizedArrayArticle.json b/plugins/sanity-naive-html-serializer/test/__fixtures__/internationalizedArrayArticle.json new file mode 100644 index 0000000000..7cf953b305 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/__fixtures__/internationalizedArrayArticle.json @@ -0,0 +1,180 @@ +{ + "_createdAt": "2021-10-10T23:00:13Z", + "_id": "drafts.2947533e-1ea5-4116-955b-339608d3445d", + "_rev": "l58oha-n06-1s4-f6i-74g6cuakl", + "_type": "internationalizedArrayArticle", + "_updatedAt": "2021-10-10T23:02:05Z", + "slug": { + "_type": "slug", + "current": "happy-kitchen-hamburger" + }, + "config": [ + { + "_key": "en", + "_type": "internationalizedArrayObjectFieldValue", + "value": { + "_type": "objectField", + "nestedArrayField": [ + { + "_key": "4a58adc7c507", + "_type": "block", + "children": [ + { + "_key": "4f6bc15ae261", + "_type": "span", + "marks": [], + "text": "This is block text 1 level deep" + } + ], + "markDefs": [], + "style": "normal" + } + ], + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "869168fe3a2a", + "_type": "block", + "children": [ + { + "_key": "10d423df228a", + "_type": "span", + "marks": [], + "text": "This is block text 2 levels deep" + } + ], + "markDefs": [], + "style": "normal" + } + ], + "title": "This is one level deeper" + }, + "title": "This is an object nested in a document" + } + } + ], + "content": [ + { + "_key": "en", + "_type": "internationalizedArrayPortableTextValue", + "value": [ + { + "_key": "e2a39d768ff8", + "_type": "block", + "children": [ + { + "_key": "216e97c5a5cc", + "_type": "span", + "marks": [], + "text": "This is block text at the top level." + } + ], + "markDefs": [], + "style": "normal" + }, + { + "_key": "271b0c6ee984", + "_type": "objectField", + "objectAsField": { + "_type": "childObjectField", + "content": [ + { + "_key": "4f24f6fbfae7", + "_type": "block", + "children": [ + { + "_key": "d68f4288f5b7", + "_type": "span", + "marks": [], + "text": "This is block text in a nested object in an object in top-level block text." + } + ], + "markDefs": [], + "style": "normal" + } + ], + "title": "This is a nested object in an object in top-level block text." + }, + "title": "This is an object in top-level block text." + } + ] + } + ], + "hidden": true, + "meta": "Do not translate this", + "snippet": [ + { + "_key": "en", + "_type": "internationalizedArrayStringFieldValue", + "value": "This is text in my text field" + } + ], + "tags": [ + { + "_key": "en", + "_type": "internationalizedArrayTagsValue", + "value": ["tag 1", "tag 2", "tag 3"] + } + ], + "title": [ + { + "_key": "en", + "_type": "internationalizedArrayStringFieldValue", + "value": "My Internationalized Array Article" + } + ], + "slices": [ + { + "_key": "6b9d0b28810f", + "_type": "nestedlocaleBlock", + "content": [ + { + "_type": "internationalizedArrayBlockValue", + "_key": "en", + "value": [ + { + "_type": "block", + "_key": "e2a39d768ff8", + "children": [ + { + "_key": "a6170f21181c", + "_type": "span", + "marks": [], + "text": "This is block text 1 level deep" + } + ], + "markDefs": [], + "style": "normal" + } + ] + }, + { + "_type": "internationalizedArrayBlockValue", + "_key": "fr_FR", + "value": [ + { + "_type": "block", + "_key": "e2a39d768ff8", + "children": [ + { + "_key": "44954c1912e1", + "_type": "span", + "marks": [], + "text": "Ceci est une texte du bloque en mon slice" + } + ], + "markDefs": [], + "style": "normal" + } + ] + } + ] + }, + { + "_type": "reference", + "_key": "13ed4b8013e4", + "_ref": "66dd1434-23c3-4d94-9e5a-d117775be828" + } + ] +} diff --git a/plugins/sanity-naive-html-serializer/test/__fixtures__/messy-html.html b/plugins/sanity-naive-html-serializer/test/__fixtures__/messy-html.html new file mode 100644 index 0000000000..f366a5563f --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/__fixtures__/messy-html.html @@ -0,0 +1,26 @@ + + + + + + + + +
    + Här är artikel titeln +
    +

    Här är lite content

    +
    +
    +
    + Det här är en dragspels titeln +
    +

    Lite content
    i vår accordion

    +
    +
    +
    +
    +
    +
    + + diff --git a/plugins/sanity-naive-html-serializer/test/__fixtures__/nestedLanguageFields.json b/plugins/sanity-naive-html-serializer/test/__fixtures__/nestedLanguageFields.json new file mode 100644 index 0000000000..0a0fdfcd3b --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/__fixtures__/nestedLanguageFields.json @@ -0,0 +1,54 @@ +{ + "slices": [ + { + "_key": "6b9d0b28810f", + "_type": "localeBlock", + "en": [ + { + "_key": "cebb6c538ce0", + "_type": "block", + "children": [ + { + "_key": "48cc6e3b0754", + "_type": "span", + "marks": [], + "text": "This is block text in my slice." + } + ], + "markDefs": [], + "style": "normal" + } + ], + "fr_FR": [ + { + "_key": "8bf012dc2eda", + "_type": "block", + "children": [ + { + "_key": "44954c1912e1", + "_type": "span", + "marks": [], + "text": "Ceci est une texte du bloque en mon slice" + } + ], + "markDefs": [], + "style": "normal" + } + ] + }, + { + "_type": "reference", + "_key": "13ed4b8013e4", + "_ref": "66dd1434-23c3-4d94-9e5a-d117775be828" + } + ], + "pageFields": { + "_type": "pageFields", + "name": { + "_type": "localeString", + "en": "Hello, this is my page name in my page field.", + "fr_FR": "C'est une field en frances" + }, + "slug": "current slug" + } +} diff --git a/plugins/sanity-naive-html-serializer/test/__fixtures__/schema.ts b/plugins/sanity-naive-html-serializer/test/__fixtures__/schema.ts new file mode 100644 index 0000000000..0007f220b3 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/__fixtures__/schema.ts @@ -0,0 +1,216 @@ +import {Schema} from '@sanity/schema' + +const arrayField = { + name: 'arrayField', + title: 'Array Field', + type: 'array', + of: [{type: 'block'}, {type: 'objectField'}], +} + +const childObjectField = { + name: 'childObjectField', + title: 'Child Object Field', + type: 'object', + fields: [ + { + name: 'title', + title: 'Title', + type: 'string', + }, + { + name: 'content', + title: 'Content', + type: 'array', + of: [{type: 'block'}], + }, + ], +} + +const objectField = { + name: 'objectField', + title: 'Object Field', + type: 'object', + fields: [ + { + name: 'title', + title: 'Title', + type: 'string', + }, + { + name: 'objectAsField', + title: 'Object As Field', + type: 'childObjectField', + }, + { + name: 'nestedArrayField', + title: 'Nested Array Field', + type: 'array', + of: [{type: 'block'}, {type: 'childObjectField'}], + }, + ], +} + +const documentLevelArticle = { + name: 'documentLevelArticle', + title: 'Document Level Article', + type: 'document', + fields: [ + { + name: 'title', + title: 'Title', + type: 'string', + }, + { + name: 'meta', + title: 'Meta', + type: 'string', + localize: false, + }, + { + name: 'snippet', + title: 'Snippet', + type: 'text', + }, + { + name: 'tags', + title: 'Tags', + type: 'array', + of: [{type: 'string'}], + }, + { + name: 'hidden', + title: 'Hidden', + type: 'boolean', + }, + { + name: 'config', + title: 'Config', + type: 'objectField', + }, + { + name: 'content', + title: 'Content', + type: 'arrayField', + }, + ], +} + +function createLocaleFields(locales: string[], fieldType: Record) { + return locales.map((locale) => ({ + name: locale, + ...fieldType, + })) +} + +const fieldLevelArticle = { + name: 'fieldLevelArticle', + title: 'Field Level Article', + type: 'document', + fields: [ + { + name: 'title', + title: 'Title', + type: 'localeString', + }, + { + name: 'meta', + title: 'Meta', + type: 'string', + localize: false, + }, + { + name: 'snippet', + title: 'Snippet', + type: 'object', + fields: createLocaleFields(['en', 'fr', 'de'], {type: 'text'}), + }, + { + name: 'tags', + title: 'Tags', + type: 'object', + fields: createLocaleFields(['en', 'fr', 'de'], { + type: 'array', + of: [{type: 'string'}], + }), + }, + { + name: 'hidden', + title: 'Hidden', + type: 'boolean', + }, + { + name: 'config', + title: 'Config', + type: 'object', + fields: createLocaleFields(['en', 'fr', 'de'], {type: 'objectField'}), + }, + { + name: 'content', + title: 'Content', + type: 'object', + fields: createLocaleFields(['en', 'fr', 'de'], {type: 'arrayField'}), + }, + { + name: 'slices', + title: 'Slices', + type: 'array', + of: [{type: 'localeBlock'}, {type: 'reference', to: [{type: 'marketText'}]}], + }, + { + name: 'pageFields', + title: 'Page Fields', + type: 'pageFields', + }, + ], +} + +const localeBlock = { + name: 'localeBlock', + title: 'Locale Block', + type: 'object', + fields: createLocaleFields(['en', 'fr_FR', 'de_DE'], {type: 'arrayField'}), +} + +const localeString = { + name: 'localeString', + title: 'Locale String', + type: 'object', + fields: createLocaleFields(['en', 'fr_FR', 'de_DE'], {type: 'string'}), +} + +const pageFields = { + name: 'pageFields', + title: 'Page Fields', + type: 'object', + fields: [ + { + title: 'Page Name', + name: 'name', + type: 'localeString', + }, + { + name: 'slug', + type: 'string', + }, + ], +} + +const types = [ + arrayField, + childObjectField, + objectField, + documentLevelArticle, + fieldLevelArticle, + pageFields, + localeBlock, + localeString, +] + +// The explicit annotation keeps the exported type portable for declaration emit +// (TS2883): the Schema instance type otherwise resolves to a private dts chunk path. +const schema: InstanceType = new Schema({ + name: 'test', + types, +}) + +export default schema diff --git a/plugins/sanity-naive-html-serializer/test/global.setup.ts b/plugins/sanity-naive-html-serializer/test/global.setup.ts new file mode 100644 index 0000000000..7ee69d7d4d --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/global.setup.ts @@ -0,0 +1,24 @@ +import type {PortableTextTextBlock} from 'sanity' +import {vi} from 'vitest' + +let mockTestKey = 0 + +vi.mock('@portabletext/block-tools', async () => { + const originalModule = await vi.importActual( + '@portabletext/block-tools', + ) + return { + ...originalModule, + //not ideal but vi.mock('@sanity/block-tools/src/util/randomKey.ts' is not working + htmlToBlocks: (html: string, blockContentType: any, options: any) => { + const blocks = originalModule.htmlToBlocks(html, blockContentType, options) + const newBlocks = blocks.map((block) => { + const newChildren = (block as unknown as PortableTextTextBlock).children.map((child) => { + return Object.assign(child, {_key: `randomKey-${mockTestKey++}`}) + }) + return Object.assign(block, {children: newChildren, _key: `randomKey-${mockTestKey++}`}) + }) + return newBlocks + }, + } +}) diff --git a/plugins/sanity-naive-html-serializer/test/helpers.ts b/plugins/sanity-naive-html-serializer/test/helpers.ts new file mode 100644 index 0000000000..896b2296b9 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/test/helpers.ts @@ -0,0 +1,142 @@ +import clone from 'just-clone' +import type {PortableTextBlock, SanityDocument, TypedObject} from 'sanity' + +import {BaseDocumentSerializer, BaseDocumentDeserializer} from '../src' +import { + customSerializers, + customDeserializers, + customBlockDeserializers, +} from '../src/BaseSerializationConfig' +import type {SerializedDocument, TranslationLevel} from '../src/types' +import schema from './__fixtures__/schema' + +export const getSerialized = ( + document: SanityDocument, + level: TranslationLevel, +): SerializedDocument => { + const serializer = BaseDocumentSerializer(schema) + return serializer.serializeDocument(document, level) +} + +export const getDeserialized = ( + document: SanityDocument, + level: TranslationLevel, +): Record => { + const serialized = getSerialized(document, level) + const deserializer = BaseDocumentDeserializer + return deserializer.deserializeDocument(serialized.content) +} + +export const getValidFields = (field: Record): Record => { + const invalidFields = new Set(['_type', '_key']) + return Object.keys(field).filter((key) => !invalidFields.has(key)) +} + +export const toPlainText = (blocks: PortableTextBlock[]): string => { + return blocks + .map((block) => { + if (block._type !== 'block' || !block.children) { + return '' + } + return (block.children as Array).map((child) => child.text).join('') + }) + .join('\n\n') +} + +export const getI18nArrayItem = (array: TypedObject[], key: string): TypedObject => { + return array.find((item) => item._key === key)! +} + +export const createCustomInnerHTML = (title: string): string => + `Custom serializer works and includes title: '${title}'` + +const additionalSerializerTypes = { + //block and top-level tests + objectField: ({value}: {value: TypedObject}) => { + const innerText = createCustomInnerHTML(value.title as string) + const html = `
    ${innerText}
    ` + return html + }, + //inline-level tests + childObjectField: ({value}: {value: TypedObject}) => { + const innerText = createCustomInnerHTML(value.title as string) + const html = `${innerText}` + return html + }, +} + +const tempSerializers = clone(customSerializers) +tempSerializers.types = { + ...tempSerializers.types, + ...additionalSerializerTypes, +} +tempSerializers.marks = { + annotation: ({ + value, + markType, + children, + }: { + value: TypedObject + markType: string + children: any[] + }) => { + return `${children}` + }, +} + +export const addedCustomSerializers = tempSerializers + +export const addedDeserializerTypes = { + objectField: (html: HTMLElement): TypedObject => { + const title = html.innerHTML.split(':')[1]!.replace(/'/g, '').trim() + const _type = html.className + const _key = html.id + return {title, _type, _key} + }, +} + +const tempDeserializers = clone(customDeserializers) +tempDeserializers.types = { + ...tempDeserializers.types, + ...addedDeserializerTypes, +} + +export const addedCustomDeserializers = tempDeserializers + +export const addedBlockDeserializers = [ + ...customBlockDeserializers, + { + deserialize(el: HTMLElement): TypedObject | undefined { + if (!el.className || el.className.toLowerCase() !== 'childobjectfield') { + return undefined + } + + const title = el.innerHTML.split(':')[1]!.replace(/'/g, '').trim() + const _type = el.className + const _key = el.id + + return {title, _type, _key} + }, + }, + { + deserialize( + el: HTMLElement, + next: (nodes: NodeListOf) => any, + ): TypedObject | undefined { + if (!el.className || el.className?.toLowerCase() !== 'annotation') { + return undefined + } + + const markDef = { + _key: el.id, + _type: 'annotation', + } + + return { + _type: '__annotation', + markDef: markDef, + children: next(el.childNodes), + } + }, + }, +] diff --git a/plugins/sanity-naive-html-serializer/tsconfig.json b/plugins/sanity-naive-html-serializer/tsconfig.json new file mode 100644 index 0000000000..fe89ad09eb --- /dev/null +++ b/plugins/sanity-naive-html-serializer/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": ["@sanity/tsconfig/strictest"], + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["dist", "node_modules"], + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "resolveJsonModule": true, + "types": ["node"] + } +} diff --git a/plugins/sanity-naive-html-serializer/tsdown.config.ts b/plugins/sanity-naive-html-serializer/tsdown.config.ts new file mode 100644 index 0000000000..31e58147f3 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/tsdown.config.ts @@ -0,0 +1,6 @@ +import {defineConfig} from '@sanity/tsdown-config' +import type {UserConfig} from 'tsdown' + +export default defineConfig({ + reactCompiler: true, +}) satisfies Promise diff --git a/plugins/sanity-naive-html-serializer/vitest.config.ts b/plugins/sanity-naive-html-serializer/vitest.config.ts new file mode 100644 index 0000000000..f1dd964571 --- /dev/null +++ b/plugins/sanity-naive-html-serializer/vitest.config.ts @@ -0,0 +1,13 @@ +import {defineConfig} from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'jsdom', + setupFiles: ['./test/global.setup.ts'], + server: { + deps: { + inline: ['vitest-package-exports'], + }, + }, + }, +}) diff --git a/plugins/sanity-plugin-aprimo/CHANGELOG.md b/plugins/sanity-plugin-aprimo/CHANGELOG.md index 4c3557a3c1..7f67c6fe0b 100644 --- a/plugins/sanity-plugin-aprimo/CHANGELOG.md +++ b/plugins/sanity-plugin-aprimo/CHANGELOG.md @@ -1,5 +1,59 @@ # sanity-plugin-aprimo +## 2.0.16 + +### Patch Changes + +- [#1702](https://github.com/sanity-io/plugins/pull/1702) [`2a3a7ea`](https://github.com/sanity-io/plugins/commit/2a3a7eab8616981991e4a0b345ebe866a5fec8df) Thanks [@stipsan](https://github.com/stipsan)! - Update `@sanity/ui` dependency to ^3.4.3. + +## 2.0.15 + +### Patch Changes + +- [#1684](https://github.com/sanity-io/plugins/pull/1684) [`4ea0d1f`](https://github.com/sanity-io/plugins/commit/4ea0d1fd2eeb05b80f38e11aa17ca29390115999) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Update `@sanity/ui` dependency to the latest catalog version. + +## 2.0.14 + +### Patch Changes + +- [#1622](https://github.com/sanity-io/plugins/pull/1622) [`6fe3c11`](https://github.com/sanity-io/plugins/commit/6fe3c11e32b8187a19fbdc333e4a8b159fe5a616) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.9 + +## 2.0.13 + +### Patch Changes + +- [#1596](https://github.com/sanity-io/plugins/pull/1596) [`f06fd76`](https://github.com/sanity-io/plugins/commit/f06fd767531740a09a5755f41fa1d3d42da202ae) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.8 + +## 2.0.12 + +### Patch Changes + +- [#1571](https://github.com/sanity-io/plugins/pull/1571) [`52975b2`](https://github.com/sanity-io/plugins/commit/52975b2f0d4ea5086c800b2ce16190b862284a95) Thanks [@stipsan](https://github.com/stipsan)! - fix(deps): update tsdown to ^0.22.7 and @sanity/tsdown-config to ^0.14.0 + +## 2.0.11 + +### Patch Changes + +- [#1519](https://github.com/sanity-io/plugins/pull/1519) [`a11d511`](https://github.com/sanity-io/plugins/commit/a11d511b371b332adc08197711583951eb294166) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.5 + +## 2.0.10 + +### Patch Changes + +- [#1491](https://github.com/sanity-io/plugins/pull/1491) [`2361892`](https://github.com/sanity-io/plugins/commit/236189294b6408c9bced43765e53cf26a11a0e66) Thanks [@stipsan](https://github.com/stipsan)! - Build with `tsdown` instead of `@sanity/pkg-utils`. Internal build-tooling change only, with no intended changes to the public API or runtime behavior. + +## 2.0.9 + +### Patch Changes + +- [#1460](https://github.com/sanity-io/plugins/pull/1460) [`f50f060`](https://github.com/sanity-io/plugins/commit/f50f0605968e5cec4f23f5f3455abe5c8ddda23c) Thanks [@stipsan](https://github.com/stipsan)! - Regenerate TypeScript declaration output: `isolatedDeclarations` is no longer used and declarations are now generated with tsgo (`@typescript/native-preview`). Internal build-tooling change only, with no runtime behavior or public API changes. + +## 2.0.8 + +### Patch Changes + +- [#980](https://github.com/sanity-io/plugins/pull/980) [`98d148e`](https://github.com/sanity-io/plugins/commit/98d148e00ef679b422e1effe7fc53dfce9cb046c) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Update `@sanity/pkg-utils` to pick up a DTS generation bug fix. + ## 2.0.7 ### Patch Changes diff --git a/plugins/sanity-plugin-aprimo/package.config.ts b/plugins/sanity-plugin-aprimo/package.config.ts deleted file mode 100644 index 43da34cfa9..0000000000 --- a/plugins/sanity-plugin-aprimo/package.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import config from '@repo/package.config' -import {defineConfig} from '@sanity/pkg-utils' - -export default defineConfig({ - ...config, - babel: {reactCompiler: true}, - reactCompilerOptions: {target: '19'}, -}) diff --git a/plugins/sanity-plugin-aprimo/package.json b/plugins/sanity-plugin-aprimo/package.json index 826f2af9d5..d4acc380dc 100644 --- a/plugins/sanity-plugin-aprimo/package.json +++ b/plugins/sanity-plugin-aprimo/package.json @@ -1,6 +1,6 @@ { "name": "sanity-plugin-aprimo", - "version": "2.0.7", + "version": "2.0.16", "description": "Aprimo asset selector for Sanity", "keywords": [ "sanity", @@ -23,11 +23,7 @@ "type": "module", "types": "./dist/index.d.ts", "exports": { - ".": { - "development": "./src/index.ts", - "source": "./src/index.ts", - "default": "./dist/index.js" - }, + ".": "./src/index.ts", "./package.json": "./package.json" }, "publishConfig": { @@ -37,26 +33,26 @@ } }, "scripts": { - "build": "pkg build --strict --check --clean", + "build": "tsdown", "prepack": "turbo run build" }, "dependencies": { "@sanity/ui": "catalog:" }, "devDependencies": { - "@repo/package.config": "workspace:*", - "@repo/tsconfig": "workspace:*", - "@sanity/pkg-utils": "catalog:", + "@sanity/tsconfig": "catalog:", + "@sanity/tsdown-config": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "babel-plugin-react-compiler": "catalog:", "react": "catalog:", "sanity": "catalog:", - "styled-components": "catalog:" + "styled-components": "catalog:", + "tsdown": "catalog:" }, "peerDependencies": { - "react": "^19.2", - "sanity": "^5 || ^6.0.0-0" + "react": "catalog:peer", + "sanity": "catalog:peer" }, "engines": { "node": ">=20.19 <22 || >=22.12" diff --git a/plugins/sanity-plugin-aprimo/src/components/AprimoWidget.tsx b/plugins/sanity-plugin-aprimo/src/components/AprimoWidget.tsx index fcac960ce4..2bf40867c5 100644 --- a/plugins/sanity-plugin-aprimo/src/components/AprimoWidget.tsx +++ b/plugins/sanity-plugin-aprimo/src/components/AprimoWidget.tsx @@ -96,13 +96,7 @@ export function AprimoWidget(props: AprimoWidgetProps): React.JSX.Element { {preview} -
    + {/* Deployment - alias or regular deployment URL */} + + + + + + + {targetUrl ? ( + <> + {/* Alias icon */} + {deployment.alias && } + + + + {deployment.state === 'READY' ? ( + + {targetUrl} + + ) : ( + targetUrl + )} + + + + ) : ( + Uploading... + )} + + + + {/* State */} + + + + + + {deployment.state + .trim() + .toLowerCase() + .replace(/^[a-z]/i, (t) => t.toUpperCase())} + + + + + + {/* Branch */} + + + + {commitRef} + + {commitMessage && ( + + {commitMessage} + + )} + + + + {/* Age */} + + + + + + + + + {/* Creator */} + + + {deployment?.creator?.username + + + + ) +} + +export default Deployment diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/components/DeploymentPlaceholder/index.tsx b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/DeploymentPlaceholder/index.tsx new file mode 100644 index 0000000000..8091ebc2dc --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/DeploymentPlaceholder/index.tsx @@ -0,0 +1,40 @@ +import {Flex} from '@sanity/ui' + +import PlaceholderAvatar from '../PlaceholderAvatar' +import PlaceholderText from '../PlaceholderText' +import TableCell from '../TableCell' + +const DeploymentPlaceholder = () => { + return ( + + {/* Deployment - alias or regular deployment URL */} + + + + + {/* State */} + + + + + {/* Branch */} + + + + + {/* Age */} + + + + + {/* Creator */} + + + + + + + ) +} + +export default DeploymentPlaceholder diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/components/DeploymentTarget/index.tsx b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/DeploymentTarget/index.tsx new file mode 100644 index 0000000000..4dc2a7bf36 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/DeploymentTarget/index.tsx @@ -0,0 +1,51 @@ +import {EditIcon} from '@sanity/icons/Edit' +import {Box, Button, Flex, Text, Tooltip} from '@sanity/ui' +import type {FC} from 'react' + +import {type Sanity} from '../../types' +import Deployments from '../Deployments' + +type Props = { + item: Sanity.DeploymentTarget + onDialogEdit: (deploymentTarget: Sanity.DeploymentTarget) => void +} + +const DeploymentTarget: FC = (props: Props) => { + const {item, onDialogEdit} = props + + const deploymentTarget: Sanity.DeploymentTargetConfig = { + deployHook: item.deployHook, + deployLimit: item.deployLimit, + name: item.name, + projectId: item.projectId, + teamId: item.teamId, + token: item.token, + } + + return ( + + {/* Header */} + + {item.name} + + + + Edit deployment target + + + } + placement="left" + > + + {/* Deployment */} + Deployment + + {/* State */} + + State + + + {/* Branch */} + + Branch + + + {/* Age */} + + Age + + + {/* Creator */} + + Creator + + + + + + {/* Placeholders */} + {!deployments && + Array.from({length: deploymentTarget.deployLimit}, (_, index) => ( + + ))} + {/* Deployments */} + {hasDeployments && + deployments?.map((deployment) => ( + + ))} + + + + {/* No results */} + {hasFetched && !hasDeployments && ( + + + No deployments found. Don't forget to specify a valid team ID if your project + belongs to a team. + + + )} + + )} + + {/* Error message */} + {refreshState.matches('error') && ( + + + Unable to fetch recent deployments. Please check your network and deployment settings. + + + )} + + {/* Deploy button */} + {!refreshState.matches('error') && deploymentTarget.deployHook && ( + + + + )} + + ) +} + +export default Deployments diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/components/DialogForm/index.tsx b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/DialogForm/index.tsx new file mode 100644 index 0000000000..fdbaeac27f --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/DialogForm/index.tsx @@ -0,0 +1,229 @@ +import {yupResolver} from '@hookform/resolvers/yup' +import {Box, Button, Dialog, Flex, Stack, useToast} from '@sanity/ui' +import {useActor} from '@xstate/react' +import {type FC, useEffect} from 'react' +import {useForm} from 'react-hook-form' +import {toPromise} from 'xstate' +import * as yup from 'yup' + +import {useSanityClient} from '../../client' +import {Z_INDEX_DIALOG} from '../../constants' +import {formMachine} from '../../machines/form' +import {type Sanity} from '../../types' +import sanitizeFormData from '../../utils/sanitizeFormData' +import FormFieldInputText from '../FormFieldInputText' + +type Props = { + deploymentTarget?: Sanity.DeploymentTarget + onClose: () => void + onCreate?: (deploymentTarget: Sanity.DeploymentTarget) => void + onDelete?: (id: string) => void + onUpdate?: (deploymentTarget: Sanity.DeploymentTarget) => void +} + +type FormData = yup.InferType + +const formSchema = yup.object().shape({ + deployHook: yup.string().url('Deploy hook must be a valid URL'), + deployLimit: yup + .number() + .positive() + .integer() + .min(1, 'Deploy limit must no less than 1') + .max(15, 'Deploy limit must no higher than 15') + .typeError('Deploy limit must be a number') + .required('Deploy limit must be a positive integer between 1 and 15'), + name: yup.string().required('Name cannot be empty'), + projectId: yup.string().required('Vercel Project ID cannot be empty'), + teamId: yup.string(), + token: yup.string().required('Vercel Account Token cannot be empty'), +}) + +const DialogForm: FC = (props: Props) => { + const {deploymentTarget, onClose, onCreate, onDelete, onUpdate} = props + const client = useSanityClient() + const toast = useToast() + + const [formState, formStateTransition, formStateActorRef] = useActor(formMachine, { + input: {client}, + }) + + const formUpdating = formState.hasTag('busy') + + // react-hook-form v7 + const { + formState: {errors, isDirty, isValid}, + handleSubmit, + register, + } = useForm({ + // @ts-expect-error - fix typings later + defaultValues: { + deployHook: deploymentTarget?.deployHook || '', + deployLimit: deploymentTarget?.deployLimit || 5, + name: deploymentTarget?.name || '', + projectId: deploymentTarget?.projectId || '', + teamId: deploymentTarget?.teamId || '', + token: deploymentTarget?.token || '', + }, + mode: 'onChange', + resolver: yupResolver(formSchema), + }) + + /** + * Handle errors and reaching the done state + */ + useEffect(() => { + if (formState.matches('error')) { + toast.push({ + status: 'error', + title: formState.context.message || 'An error occurred', + }) + } + /** + * If the machine is done it means it reached updated, created, deleted or error state. + * We don't care which one, we just want to close the dialog + */ + if (formState.status === 'done') { + onClose() + } + }, [formState, onClose, toast]) + + // Callbacks + // - submit react-hook-form + const onSubmit = async (formData: FormData) => { + const sanitizedFormData = sanitizeFormData(formData) + if (deploymentTarget) { + formStateTransition({type: 'UPDATE', id: deploymentTarget._id, formData: sanitizedFormData}) + } else { + formStateTransition({type: 'CREATE', formData: sanitizedFormData}) + } + await toPromise(formStateActorRef) + const snapshot = formStateActorRef.getSnapshot() + const {document} = snapshot.context + if (!document) return + if (snapshot.matches('created')) { + onCreate?.(document) + } else if (snapshot.matches('updated')) { + onUpdate?.(document) + } + } + + const handleDelete = async () => { + if (!deploymentTarget) { + return + } + const id = deploymentTarget._id + formStateTransition({type: 'DELETE', id}) + await toPromise(formStateActorRef) + if (formStateActorRef.getSnapshot().matches('deleted')) { + onDelete?.(id) + } + } + + return ( + + + {/* Delete button */} + {deploymentTarget && ( + + ) +} + +export default DialogForm diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/components/FormFieldInputLabel/index.tsx b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/FormFieldInputLabel/index.tsx new file mode 100644 index 0000000000..e889c34dbb --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/FormFieldInputLabel/index.tsx @@ -0,0 +1,61 @@ +import {red} from '@sanity/color' +import {ErrorOutlineIcon} from '@sanity/icons/ErrorOutline' +import {Box, Inline, Text, Tooltip} from '@sanity/ui' +import type {FC} from 'react' +import {type FieldError} from 'react-hook-form' + +type Props = { + description?: string + error?: FieldError + label: string + name: string +} + +const errorIconStyle = {color: red[500].hex} + +const FormFieldInputLabel: FC = (props: Props) => { + const {description, error, label, name} = props + + return ( + + {/* Label */} + + + {label} + + + {/* Error icon + tooltip */} + {error && ( + + + + + {error.message} + + + } + fallbackPlacements={['top', 'left']} + placement="right" + portal + > + + + + )} + + + {/* Description */} + {description && ( + + + {description} + + + )} + + ) +} + +export default FormFieldInputLabel diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/components/FormFieldInputText/index.tsx b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/FormFieldInputText/index.tsx new file mode 100644 index 0000000000..6d8d7378be --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/FormFieldInputText/index.tsx @@ -0,0 +1,45 @@ +import {Box, TextInput} from '@sanity/ui' +import {type ChangeEventHandler, type FocusEventHandler, type Ref} from 'react' +import {type FieldError} from 'react-hook-form' + +import FormFieldInputLabel from '../FormFieldInputLabel' + +type Props = { + description?: string + disabled?: boolean + error?: FieldError + label: string + name: string + placeholder?: string + value?: string + onChange?: ChangeEventHandler + onBlur?: FocusEventHandler + ref?: Ref +} + +const FormFieldInputText = (props: Props) => { + const {description, disabled, error, label, name, placeholder, value, onChange, onBlur, ref} = + props + + return ( + + {/* Label */} + + {/* Input */} + + + ) +} + +export default FormFieldInputText diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/components/PlaceholderAvatar/index.tsx b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/PlaceholderAvatar/index.tsx new file mode 100644 index 0000000000..b0030f2bc2 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/PlaceholderAvatar/index.tsx @@ -0,0 +1,20 @@ +import {Box} from '@sanity/ui' + +import {useCardColor} from '../../utils/useCardColor' + +const PlaceholderAvatar = () => { + const {border} = useCardColor() + return ( + + ) +} + +export default PlaceholderAvatar diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/components/PlaceholderText/index.tsx b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/PlaceholderText/index.tsx new file mode 100644 index 0000000000..9309903309 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/PlaceholderText/index.tsx @@ -0,0 +1,32 @@ +import {Box, Stack, Text} from '@sanity/ui' + +import {useCardColor} from '../../utils/useCardColor' + +type Props = { + rows: number +} + +const PlaceholderText = (props: Props) => { + const {rows} = props + const {border} = useCardColor() + return ( + + + {Array.from({length: rows}, (_, index) => ( + +   + + ))} + + + ) +} + +export default PlaceholderText diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/components/StateDebug/index.tsx b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/StateDebug/index.tsx new file mode 100644 index 0000000000..b1c425d079 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/StateDebug/index.tsx @@ -0,0 +1,45 @@ +import {Box, Card, Stack, Text} from '@sanity/ui' + +import {DEBUG_MODE} from '../../constants' + +type Props = { + name: string + state: any // TODO: type correctly +} + +const StateDebug = (props: Props) => { + const {name, state} = props + + if (!DEBUG_MODE) { + return null + } + + return ( + + + + Name: {name} + state.value: {JSON.stringify(state.value)} + + + + ) +} + +export default StateDebug diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/components/StatusDot/index.tsx b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/StatusDot/index.tsx new file mode 100644 index 0000000000..9e7ba0124a --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/StatusDot/index.tsx @@ -0,0 +1,21 @@ +import {Box} from '@sanity/ui' + +import {VERCEL_STATUS_COLORS} from '../../constants' +import {type Vercel} from '../../types' + +type Props = { + state: Vercel.DeploymentState +} + +const StatusDot = ({state}: Props) => ( + +) + +export default StatusDot diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/components/TableCell/index.tsx b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/TableCell/index.tsx new file mode 100644 index 0000000000..f10dd7cbd1 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/components/TableCell/index.tsx @@ -0,0 +1,82 @@ +import {Box, Label} from '@sanity/ui' +import type {ReactNode} from 'react' + +import {type Sanity} from '../../types' +import {useCardColor} from '../../utils/useCardColor' + +type Props = { + children: ReactNode + colSpan?: number + header?: boolean + variant?: 'age' | 'branch' | 'creator' | 'state' +} + +const TableCell = (props: Props) => { + const {children, colSpan, header, variant} = props + + let display: Sanity.BoxDisplay | Sanity.BoxDisplay[] = 'table-cell' + let cellWidth: string = 'auto' + + switch (variant) { + case 'age': + cellWidth = '50px' + break + case 'branch': + cellWidth = '300px' + display = ['none', 'none', 'none', 'table-cell'] + break + case 'creator': + cellWidth = '80px' + break + case 'state': + cellWidth = '110px' + display = ['none', 'none', 'none', 'none', 'table-cell'] + break + default: + break + } + + const {border} = useCardColor() + + if (header) { + return ( + + + + ) + } + return ( + + {children} + + ) +} + +export default TableCell diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/constants.ts b/plugins/sanity-plugin-dashboard-widget-vercel/src/constants.ts new file mode 100644 index 0000000000..534a63125e --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/constants.ts @@ -0,0 +1,26 @@ +// https://vercel.com/docs/platform/limits +export const API_ENDPOINT_DEPLOYMENTS = 'https://api.vercel.com/v5/now/deployments' +export const API_ENDPOINT_ALIASES = 'https://api.vercel.com/v3/now/aliases' + +// Sanity API version +export const API_VERSION = '1' + +export const DEBUG_MODE = false + +export const DEPLOYMENT_TARGET_DOCUMENT_TYPE = 'vercel.deploymentTarget' + +export const VERCEL_STATUS_COLORS = { + BUILDING: '#f5a623', + CANCELED: '#ff0000', + ERROR: '#ff0000', + READY: '#50e3c2', + QUEUED: '#333', +} + +// Name displayed in toasts +export const WIDGET_NAME = 'Vercel (dashboard)' + +// NOTE: Manually set plugin z-index values to be higher than Sanity's header search field +// (which is currently 500202). Also ensure toasts always sit above dialogs. +export const Z_INDEX_DIALOG = 600001 +export const Z_INDEX_TOAST_PROVIDER = 600002 diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/hooks/useDeployments.ts b/plugins/sanity-plugin-dashboard-widget-vercel/src/hooks/useDeployments.ts new file mode 100644 index 0000000000..b4f523a39b --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/hooks/useDeployments.ts @@ -0,0 +1,83 @@ +import {useQuery} from '@tanstack/react-query' +import hash from 'object-hash' + +import {API_ENDPOINT_ALIASES, API_ENDPOINT_DEPLOYMENTS} from '../constants' +import {type Sanity, type Vercel} from '../types' +import fetcher from '../utils/fetcher' + +type Options = { + enabled?: boolean +} + +const useDeployments = (deploymentTarget: Sanity.DeploymentTargetConfig, options?: Options) => { + const fetchUrl = fetcher(deploymentTarget) + + // Fetch deployments + const deployParams = new URLSearchParams() + deployParams.set('limit', String(deploymentTarget?.deployLimit)) + + const { + data: deploymentsData, + isFetching: deploymentsIsFetching, + isSuccess: deploymentsIsSuccess, + error: deploymentsError, + refetch, + } = useQuery<{deployments: Vercel.Deployment[]}>({ + queryKey: [hash(deploymentTarget)], + queryFn: () => fetchUrl(API_ENDPOINT_DEPLOYMENTS, deployParams), + enabled: options?.enabled ?? true, + refetchInterval: 20000, // ms + refetchIntervalInBackground: false, + refetchOnMount: true, + refetchOnReconnect: 'always', + refetchOnWindowFocus: false, + retry: false, + }) + + // Fetch aliases (only if deployments have been retrieved) + const aliasParams = new URLSearchParams() + aliasParams.set('limit', '20') + + const { + data: aliasesData, + isFetching: aliasesIsFetching, + isSuccess: aliasesIsSuccess, + error: aliasesError, + } = useQuery<{ + aliases: Vercel.Alias[] + pagination: { + count: number + next?: number + prev?: number + } + }>({ + queryKey: [hash(deploymentTarget), 'aliases'], + queryFn: () => fetchUrl(API_ENDPOINT_ALIASES, aliasParams), + enabled: !!deploymentsData, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + }) + + const aliases = aliasesData?.aliases + + let deploymentsWithAlias: Vercel.DeploymentWithAlias[] | undefined + + if (aliases) { + deploymentsWithAlias = deploymentsData?.deployments?.map((val: Vercel.DeploymentWithAlias) => { + const alias = aliases.find((a) => a.deploymentId === val.uid) + return Object.assign(val, {alias: alias?.alias}) + }) + } + + return { + deployments: deploymentsWithAlias, + error: aliasesError || deploymentsError, + isFetching: aliasesIsFetching || deploymentsIsFetching, + isSuccess: aliasesIsSuccess && deploymentsIsSuccess, + refetch, + } +} + +export default useDeployments diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/index.test.ts b/plugins/sanity-plugin-dashboard-widget-vercel/src/index.test.ts new file mode 100644 index 0000000000..d2f6637b22 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/index.test.ts @@ -0,0 +1,19 @@ +import {fileURLToPath} from 'node:url' + +import {expect, test} from 'vitest' +import {getPackageExportsManifest} from 'vitest-package-exports' + +test('package exports', {timeout: 30_000}, async () => { + const manifest = await getPackageExportsManifest({ + importMode: 'dist', + cwd: fileURLToPath(import.meta.url), + }) + + expect(manifest.exports).toMatchInlineSnapshot(` + { + ".": { + "vercelWidget": "function", + }, + } + `) +}) diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/index.ts b/plugins/sanity-plugin-dashboard-widget-vercel/src/index.ts new file mode 100644 index 0000000000..91c3e33e4e --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/index.ts @@ -0,0 +1,11 @@ +import type {DashboardWidget, LayoutConfig} from '@sanity/dashboard' + +import Widget from './app' + +export function vercelWidget(config: {layout?: LayoutConfig} = {}): DashboardWidget { + return { + name: 'vercel', + component: Widget, + layout: config.layout ?? {width: 'full'}, + } +} diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/deploy.ts b/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/deploy.ts new file mode 100644 index 0000000000..8e04d90e1c --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/deploy.ts @@ -0,0 +1,124 @@ +import {assign, setup, fromPromise} from 'xstate' + +import {type Vercel} from '../types' + +type Context = { + deployHook: string + disabled: boolean + feedback?: string + label?: string + error?: string +} + +type Event = {type: 'DEPLOY'} + +interface DeployActorInput { + deployHook: string +} + +export const deployMachine = setup({ + types: { + context: {} as Context, + events: {} as Event, + input: {} as DeployActorInput, + }, + actors: { + deploy: fromPromise(async ({input, signal}: {input: DeployActorInput; signal: AbortSignal}) => { + try { + if (!input.deployHook) { + throw new Error('No deployHook URL defined') + } + const res = await fetch(input.deployHook, {method: 'POST', signal}) + const data: {error?: Vercel.Error} | null = await res.json() + if (!res.ok) { + const errorMessage = data?.error?.message || res.statusText + throw errorMessage + } + } catch (err) { + if (typeof err === 'string') { + throw err + } + console.error('Unable to deploy with error:', err) + throw new Error('Please check the developer console for more information', {cause: err}) + } + }), + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QTABwDYHsCeA6AlhOmAMQAiAogAoAyA8gJoDaADALqKiqaz4Au+TADtOIAB6IATCxa4AnADYAHAHYArArUslAZjksNAGhDZEARk3yVmhSxVyHC-QBYAvq+MoMOXF6zZ8ISgSCGEwAiEAN0wAa3C-HwSAoIRA6IBjAEMBYVY2PNFuXhyRJHFENTMzXElFJSUzNTVnWx0FY1MESXVcZzUdM2k1OTUVBoV3TzR-X2mcQOCwACclzCXcDGyAMzWAW1nvPCSF1KjMLJK8grKi-kFS0AkESura5QamlpY2jvMB+QcDkqLH0wxYEw8ICSuFgAFd0uk4LByNR6Mx2IUeHdhKIns1ZN9BtYQVpRnJfl1JM55P1BnY1Ep9CpGpMoXM8MtVksUbRGNcuFiSriKs4CQNurYRgZ7BSGr1ASNupIzEo+kp3JChJgUPAyklMcV7sKEABadomRAmtQAhW2wE6VnQwjEA3Yh7lBDOSQUsxeqyaPoWYbWFSO9kHfwLV1CspPHSSHQ1ZyA1UKJzJlQ+iz+5oKewKWrfNyQ6FwhFI6NG2OINOSXDiypKAxtMyZi1dEE1Qk6L0FpTSUMl8OctaVnHVhC1+uDRvNhStin6XDaHQ9liSXTJiUa1xAA */ + id: 'deploy', + initial: 'idle', + context: ({input}) => ({ + disabled: false, + feedback: undefined, + label: undefined, + error: undefined, + deployHook: input.deployHook, + }), + states: { + idle: { + entry: assign({ + feedback: () => undefined, + label: () => 'Deploy', + }), + on: { + DEPLOY: { + target: 'deploying', + }, + }, + }, + deploying: { + entry: assign({ + disabled: () => true, + label: () => 'Deploying', + }), + exit: assign({ + disabled: () => false, + label: () => 'Deploy', + }), + invoke: { + src: 'deploy', + input: ({context}) => ({deployHook: context.deployHook}), + onDone: { + target: 'success', + }, + onError: { + target: 'error', + actions: assign({ + error: ({event}) => { + if ('error' in event) { + const {error} = event + if (typeof error === 'string') { + return error + } + if (error instanceof Error) { + return error.message + } + } + return 'Unknown error' + }, + }), + }, + }, + }, + success: { + entry: assign({ + feedback: () => 'Successfully started!', + }), + exit: assign({ + feedback: () => undefined, + }), + on: { + DEPLOY: { + target: 'deploying', + }, + }, + }, + error: { + on: { + DEPLOY: { + target: 'deploying', + }, + }, + }, + }, +}) diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/deploymentTargetList.ts b/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/deploymentTargetList.ts new file mode 100644 index 0000000000..8145d096fe --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/deploymentTargetList.ts @@ -0,0 +1,150 @@ +import type {SanityClient} from 'sanity' +import {assign, setup, fromPromise, assertEvent} from 'xstate' + +import {DEPLOYMENT_TARGET_DOCUMENT_TYPE} from '../constants' +import {type Sanity} from '../types' + +type Context = { + client: SanityClient + results: Sanity.DeploymentTarget[] // TODO: type correctly +} + +type Event = + | {type: 'CLOSE'} + | {type: 'CREATE'; deploymentTarget: Sanity.DeploymentTarget} + | {type: 'DELETE'; id: string} + | {type: 'FETCH'} + | {type: 'UPDATE'; deploymentTarget: Sanity.DeploymentTarget} + +interface Input { + client: SanityClient +} + +const sortByTargetName = (items: Sanity.DeploymentTarget[]) => { + return items.sort((a, b) => { + if (a.name > b.name) { + return 1 + } + if (a.name < b.name) { + return -1 + } + return 0 + }) +} + +export const deploymentTargetListMachine = setup({ + types: {} as { + children: {fetchData: 'fetch data'} + context: Context + events: Event + input: Input + }, + actions: { + targetCreate: assign({ + results: ({context, event}) => { + assertEvent(event, 'CREATE') + return sortByTargetName([...context.results, event.deploymentTarget]) + }, + }), + targetDelete: assign({ + results: ({context, event}) => { + assertEvent(event, 'DELETE') + return context.results.filter((target) => target._id !== event.id) + }, + }), + targetUpdate: assign({ + results: ({context, event}) => { + assertEvent(event, 'UPDATE') + const {deploymentTarget} = event + const index = context.results.findIndex((target) => target._id === deploymentTarget._id) + const updatedResults = Object.assign([], context.results, { + [index]: deploymentTarget, + }) + return sortByTargetName(updatedResults) + }, + }), + }, + guards: { + hasData: ({context}) => { + return context?.results?.length > 0 + }, + hasNoData: ({context}) => { + return context?.results?.length === 0 + }, + }, + actors: { + 'fetch data': fromPromise( + ({input, signal}: {input: {client: SanityClient}; signal: AbortSignal}) => { + return input.client + .fetch( + '*[_type == $type] | order(name asc)', + { + type: DEPLOYMENT_TARGET_DOCUMENT_TYPE, + }, + {signal}, + ) + .catch((error) => { + if (error instanceof Error && error.name === 'AbortError') { + return [] + } + console.error('Failed to fetch deployment targets', error) + throw error + }) + }, + ), + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOgAcx8ICoBiCAe0JIIDcGBrMEgMzABccAEXT90AbQAMAXUSgyDWLn64mckAA9EAZkkBOEgHYAjAA5DAFgBsFgEwnJAVkMAaEAE9Ep4yUeT-klbGesbaoYaGAL6RbmhYeISkFFQ0tGAATukM6eQANqI82ai8AsKiEjLqCkoqakiaOvpGZpY29sZOrh6IxpYkFo5h2oaOpnqOxraSFtGxGDgExCTpYOgQ7rQAwgBKAKIAggAqu1Ky9dXKqvjqWggWHSS9eoaS9ibao6aObp4IVh8kL56OzGII2PT-WYgOILRLLVbrWhCXYAGV2x1OVUUlzqoFu90kRm8tlM2gsL1eJJ+iEGBj0gVsFm0YWmVkctihMISSxWaw2AFUAApCI4nSrnbG1a71fGSUz9WzBezTFWvb7dO5MkjjMITZykwxjTnzbmkXnrEgAV3wHHwDAA7vhaJiJTUrjces9fFZGc9TLYrGqA9SEM9CcT7sznH5HHpjfFFmaEe4rTa7Y7ncYzvJJe6ZZ7DN7fYaA0GrCHvD4xs4S+yPnGYtCTYn4XySPblNgRGJneKc27cQ0EI4BiQ2T77noAzXyxrTIESGFGd4rP9DONTPHYTzk+3OwxLfxu+he9mQBcpR7h6Px4ylWyIrPfl9bIvJhZJIYA-7zFZoo27QgOB1C5RMsQHaU8UQABaYIQ2gv9G1AuFkmofAoHAnFIKHOwQz0bRtUMcd-j8WwpnuLdTVbdZMMvfMEFsPQQxMeViQmZkLC+f0oiQ5s4XNFNrVtB1sIvPMoIQCIK1BIsmXXWxtFJcZKJbAS934Ltylo8ShysfDHl9PRxjlekrC6Z9BhIBk5MkXpzFBFT+N3DsNIPI8tNdLCr20L0Wmscw5V0e5pMJYwBiI8kwrDRC5gTOEeHQXBckgbTB1uZwQw6RwrEBd9bEcQZYzsRDoiAA */ + context: ({input}) => ({ + client: input.client, + results: [], + }), + initial: 'pending', + states: { + pending: { + invoke: { + src: 'fetch data', + id: 'fetchData', + input: ({context}) => ({client: context.client}), + onDone: { + actions: assign({results: ({event}) => event.output}), + target: 'ready', + }, + onError: { + target: 'failed', + }, + }, + }, + ready: { + initial: 'unknown', + on: { + CREATE: { + actions: 'targetCreate', + }, + DELETE: { + actions: 'targetDelete', + }, + UPDATE: { + actions: 'targetUpdate', + }, + }, + states: { + unknown: { + always: [ + {target: 'withData', guard: 'hasData'}, + {target: 'withoutData', guard: 'hasNoData'}, + ], + }, + withData: { + always: [{target: 'withoutData', guard: 'hasNoData'}], + }, + withoutData: { + always: [{target: 'withData', guard: 'hasData'}], + }, + }, + }, + failed: { + type: 'final', + }, + }, +}) diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/dialog.ts b/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/dialog.ts new file mode 100644 index 0000000000..171ef98a01 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/dialog.ts @@ -0,0 +1,63 @@ +import {assertEvent, assign, setup} from 'xstate' + +import {type Sanity} from '../types' + +type Context = { + editDeploymentTarget?: Sanity.DeploymentTarget +} + +type Event = + | {type: 'CREATE'} + | {type: 'CLOSE'} + | {type: 'EDIT'; deploymentTarget: Sanity.DeploymentTarget} + +export const dialogMachine = setup({ + types: { + context: {} as Context, + events: {} as Event, + }, + actions: { + setEditDeploymentTarget: assign({ + editDeploymentTarget: ({event}) => { + assertEvent(event, 'EDIT') + return event.deploymentTarget + }, + }), + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOlwgBswBiAYQCUBRAQQBVGBtABgF1FQADgHtYuAC64h+fiAAeiAEwKArCQCcANgAcAdmUbNOhVx0BGLQBoQAT0SmAzApJcXXexoAsW5fbWaFAL4BVmhYeISk5FTUjAAiAJKs3HxIIMKiElIy8ghKqpq6+obGZpY2ivb2zq4e5moeXKZc3kEhGDgExCSQ4nQAMgDyAMqcvDLp4pLSqTn2DSQefn7uyq5aWvZWtgjmTqsuvnU6XMoK9q0goR0RJJgATmDoYjS0gyPJ4yKTWTOIc1wLJZqFZrDZbRBaUwkfZuew6SFcepuILBED4IQQOAyK7hYifDJTbKIAC0GnBCFJFxxnUilDA+O+01AOQ8CnJGxIGn29lMpjUxhcCh0VPauNIPTEDMyTLkiA8PhIxh5Cg8DS8pgM5L5UOalSMah0cP05hFYRptweT3pqQm0qJCHlVSVphVashmvKCDUAN17j0XNMqrUyhRASAA */ + context: { + editDeploymentTarget: undefined, + }, + initial: 'idle', + states: { + idle: { + entry: assign({ + editDeploymentTarget: () => undefined, + }), + on: { + CREATE: { + target: 'create', + }, + EDIT: { + actions: 'setEditDeploymentTarget', + target: 'edit', + }, + }, + }, + edit: { + on: { + CLOSE: { + target: 'idle', + }, + }, + }, + create: { + on: { + CLOSE: { + target: 'idle', + }, + }, + }, + }, +}) diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/form.ts b/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/form.ts new file mode 100644 index 0000000000..8425ed4fb3 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/form.ts @@ -0,0 +1,169 @@ +import {uuid} from '@sanity/uuid' +import type {SanityClient, SanityDocument} from 'sanity' +import {assertEvent, assign, fromPromise, setup} from 'xstate' + +import {DEPLOYMENT_TARGET_DOCUMENT_TYPE} from '../constants' +import type {Sanity} from '../types' + +type Context = { + client: SanityClient + document?: Sanity.DeploymentTarget + id?: string + formData?: Record + message: string +} + +type Event = + | {type: 'CREATE'; formData: Record} + | {type: 'UPDATE'; id: string; formData: Record} + | {type: 'DELETE'; id: string} + +interface Input { + client: SanityClient +} + +export const formMachine = setup({ + types: {} as { + children: { + createDocumentActor: 'create document' + updateDocumentActor: 'update document' + deleteDocumentActor: 'delete document' + } + context: Context + events: Event + input: Input + tags: 'busy' + }, + actions: { + setId: assign({ + id: ({event}) => { + assertEvent(event, ['UPDATE', 'DELETE']) + return event.id + }, + }), + setFormData: assign({ + formData: ({event}) => { + assertEvent(event, ['CREATE', 'UPDATE']) + return event.formData + }, + }), + setMessage: assign({ + message: ({event}) => { + if ( + 'data' in event && + event.data && + typeof event.data === 'object' && + 'details' in event.data && + event.data.details && + typeof event.data.details === 'object' && + 'description' in event.data.details + ) { + return event.data.details.description as string + } + return 'An error occurred' + }, + }), + setDocument: assign({ + document: ({event}) => { + // @ts-expect-error - fix typings later + return event.output + }, + }), + }, + actors: { + // The explicit `Promise` return types keep the inferred machine + // type portable for declaration emit (TS2883), by referencing the `SanityDocument` + // alias imported from `sanity` instead of a deep `@sanity/client` path. + 'create document': fromPromise( + ({ + input, + }: { + input: Required> + }): Promise => { + return input.client.create({ + _id: `vercel.${uuid()}`, + _type: DEPLOYMENT_TARGET_DOCUMENT_TYPE, + ...input.formData, + }) + }, + ), + 'update document': fromPromise( + ({ + input, + }: { + input: Required> + }): Promise => { + return input.client.patch(input.id).set(input.formData).commit() + }, + ), + 'delete document': fromPromise( + ({input}: {input: Required>}): Promise => { + return input.client.delete(input.id) + }, + ), + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOlwgBswBiAYQCUBRAQQBVGBtABgF1FQADgHtYuAC64h+fiAAeiAIwBmAGwkuGjQFYALAE4AHACYdWgwYA0IAJ6IjKgOwkdXFUYVa9h-Ua5KAvv5WaFh4hKTkVNQAqgAKACJsnLwywqISUjLyCMpqmtr6xqbmVrYIBgokWvlGWkpaCgZeKoHBGDgExGSUNPGMADKM7Nx8SCBp4pLSY9m56vm6hiZmljaIOipqdXpcDk1aWiYqBlqtICEd4SSYAE5g6BL4UNQQUmBk+ABuQgDW77f3MRgeJCTAAV1QYHwYmYmDEQhuI1SIkmmRmiGOShIDiUOh0SiaCgUegcDj0pTsjhI5iMewUpPsTSMZwuYS6AIeBGeYBuNwRJAEFAeADMEahrncHsDQRCoTC4QikWMJhlpqBspjsbj8YTiaTyWsEKouNT6noiX5zUpiQEgud2mzSGCBBBOU8Xm8Pt8-iRna6gSDwZDobD4YiUsqUaqshiDFicXiCea9WSKeVKiSVIclEYlIY-BUWQ7Ok6XW7ubz+YKRWLfWWAzLg-Kw0rBFGpjGEJqEzrkyTU4b6ToSHojHos7VXEc6kXQiWSBAwFRHs9XoQvb93ovl9Kg3LQ4qI230h30V241rE7r+wayro1A4dO4VHotA5fPTTnbWfPt2AV9QPJ8jcApCmIoo3OKf4NnuIYKuGozHqiapyLG8bakmRI3mmpjxk0XAuEoXAHI+DgtGc+BCIu8BjD+4TIieaLqogAC0Khpmxs6XF0kRgAxyGdk+aYKLSVR7Fweg6A4IkGFwChcY6EqAly-HRmeY7DnSY5GHGL4qFwRhpjpJpmriDjVMRLgGAp84ckCECqaezEIHoObUuZuwKH4xGPgoRnmCQpmOAcqhmPJ37Flcfrlo5TGoQgY4mgYHkeAS+iqH5hqSc4dSuCo+I6SoSjvjZUX1pAsUodkrlGO51TSd5b46JlZRebVuG7IcjRmA05FtHOVzQSpkaMVVdivs4CibHGxg6biSg4ZsJD2AccaFEVGwOKVXTQRVI0CWeNV1Z5jW+WmujDj4HjmMRnhEttpBAQilWdkdyX1V5RFNS1iAEpU1pHER9JWjogSBEAA */ + context: ({input}) => ({ + client: input.client, + formData: {}, + message: '', + }), + initial: 'idle', + states: { + idle: { + on: { + CREATE: { + actions: ['setFormData'], + target: 'creating', + }, + UPDATE: { + actions: ['setId', 'setFormData'], + target: 'updating', + }, + DELETE: { + actions: 'setId', + target: 'deleting', + }, + }, + }, + creating: { + tags: ['busy'], + invoke: { + src: 'create document', + id: 'createDocumentActor', + input: ({context}) => ({client: context.client, formData: context.formData!}), + onDone: {actions: 'setDocument', target: 'created'}, + onError: {actions: 'setMessage', target: 'error'}, + }, + }, + created: {type: 'final'}, + updating: { + tags: ['busy'], + invoke: { + src: 'update document', + id: 'updateDocumentActor', + input: ({context}) => ({ + client: context.client, + id: context.id!, + formData: context.formData!, + }), + onDone: {actions: 'setDocument', target: 'updated'}, + onError: {actions: 'setMessage', target: 'error'}, + }, + }, + updated: {type: 'final'}, + deleting: { + tags: ['busy'], + invoke: { + src: 'delete document', + id: 'deleteDocumentActor', + input: ({context}) => ({client: context.client, id: context.id!}), + onDone: {target: 'deleted'}, + onError: {actions: 'setMessage', target: 'error'}, + }, + }, + deleted: {type: 'final'}, + error: {type: 'final'}, + }, +}) diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/refresh.ts b/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/refresh.ts new file mode 100644 index 0000000000..39f49c39f8 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/machines/refresh.ts @@ -0,0 +1,47 @@ +import {setup} from 'xstate' + +type Event = {type: 'ERROR'} | {type: 'REFRESH'} | {type: 'REFRESHED'} | {type: 'RETRY'} + +const refreshMachine = setup({ + types: { + events: {} as Event, + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOlwgBswBiAJQFEAxBgZQAkBtABgF1FQADgHtYuAC64h+fiAAeiAIwAmAGwkuGrkoAsAZhVKlAVm0KAHABoQAT0QBOJSRN27XFXaMmz2twF9fVmhYeISkAE5gAGYRsCFQ1PS0tADytNx8SCDCohJSMvIIPnYkxhpKXGZ22kY6xla2CAq6CiQudgDsRnYqKroVfe3+gRg4BMQkEdFwcXRMrGz0ACLpMtniktKZBUUlRmUVVTXadTaISrqObUY97QodvWa6QyBBo6ETUTHYkLPM9OwrTJrXKbUDbJTFLjtMzmWraOx3doqeqKMyOeHdXp3BTHXTVZ6vELjMBhMJCMK-eaAwQidZ5LaIY6Q6Gw47wxHI04IGEkDFma5NbTtY7ufwBED4IQQOAyQljIirWkg-KIAC0nIa6oJIyJpHIVEVOQ2KsKShRCDsunUGLcHi8PhU2uC8o+U1iBCghrpoLkZ2uTk0XCq7SRRl0Zg19itXHhIZ85lMEaUTre40mX0gXuVDIQnmKRhZKk6PmaVXN2PUaLsZhj2hUOJU-JTupIJLJYSzxpzeacheLXFL2nNrkrfW8SKFnhDYt8QA */ + initial: 'idle', + states: { + idle: { + on: { + REFRESH: { + target: 'refreshing', + }, + }, + }, + refreshing: { + on: { + ERROR: { + target: 'error', + }, + REFRESHED: { + target: 'refreshed', + }, + }, + }, + refreshed: { + on: { + REFRESH: { + target: 'refreshing', + }, + }, + }, + error: { + on: { + REFRESH: { + target: 'refreshing', + }, + }, + }, + }, +}) + +export default refreshMachine diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/types/index.ts b/plugins/sanity-plugin-dashboard-widget-vercel/src/types/index.ts new file mode 100644 index 0000000000..ca249739f5 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/types/index.ts @@ -0,0 +1,51 @@ +import type {SanityDocument} from 'sanity' + +export declare namespace Sanity { + export type BoxDisplay = 'none' | 'block' | 'grid' | 'flex' | 'inline-block' | 'table-cell' + + export type DeploymentTargetConfig = { + deployHook: string + deployLimit: number + name: string + projectId: string + teamId?: string + token: string + } + + export type DeploymentTarget = SanityDocument & DeploymentTargetConfig +} +export declare namespace Vercel { + export type Alias = { + alias: string + deploymentId: string + } + + export type DeploymentState = 'BUILDING' | 'CANCELED' | 'ERROR' | 'QUEUED' | 'READY' + + export type Deployment = { + aliasAssigned?: number + aliasError?: any // TODO: correctly type + created: number + createdAt: number + creator: { + email: string + uid: string + username: string + } + meta?: Record + state: DeploymentState + target: string + uid: string + url: string | null // null if a deployment is still uploading + } + + export type DeploymentWithAlias = Vercel.Deployment & { + alias?: string + } + + // https://vercel.com/docs/api#api-basics/errors + export type Error = { + code: string + message: string + } +} diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/utils/fetcher.ts b/plugins/sanity-plugin-dashboard-widget-vercel/src/utils/fetcher.ts new file mode 100644 index 0000000000..b9b6aaa3c0 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/utils/fetcher.ts @@ -0,0 +1,37 @@ +import {type Sanity} from '../types' + +const fetcher = + (deploymentTarget: Sanity.DeploymentTargetConfig) => + async (url: string, extraParams?: URLSearchParams) => { + const params = new URLSearchParams() + params.set('projectId', deploymentTarget.projectId) + if (deploymentTarget.teamId) { + params.set('teamId', deploymentTarget.teamId) + } + + if (extraParams) { + for (const [k, v] of extraParams.entries()) { + params.append(k, v) + } + } + + const response = await fetch(`${url}?${params.toString()}`, { + headers: { + Authorization: `Bearer ${deploymentTarget.token}`, + }, + }) + + // Manually throw on non-OK responses for react-query + // https://react-query.tanstack.com/guides/query-functions#usage-with-fetch-and-others-clients-that-do-not-throw-by-default + if (!response.ok) { + throw new Error('Response not OK') + } + + try { + return await response.json() + } catch (err) { + throw new Error('Unable to parse response as JSON', {cause: err}) + } + } + +export default fetcher diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/utils/sanitizeFormData.ts b/plugins/sanity-plugin-dashboard-widget-vercel/src/utils/sanitizeFormData.ts new file mode 100644 index 0000000000..3c7c20c421 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/utils/sanitizeFormData.ts @@ -0,0 +1,26 @@ +// Recursively sanitize form data: +// - convert empty strings, undefined values and empty arrays to null (to correctly unset / delete fields) +// - trim whitespace on string fields + +type FormData = Record + +const sanitizeFormData = (formData: FormData): FormData => { + return Object.keys(formData).reduce((acc: FormData, key) => { + const val = formData[key] + + // TODO: refactor + if (typeof val === 'object' && val !== null && val.constructor !== Array) { + acc[key] = sanitizeFormData(val) + } else if (val === '' || typeof val === 'undefined' || val?.length === 0) { + acc[key] = null + } else if (typeof val === 'string' && val) { + acc[key] = formData[key].trim() + } else { + acc[key] = formData[key] + } + + return acc + }, {}) +} + +export default sanitizeFormData diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/src/utils/useCardColor.ts b/plugins/sanity-plugin-dashboard-widget-vercel/src/utils/useCardColor.ts new file mode 100644 index 0000000000..ecdbd7c0e2 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/src/utils/useCardColor.ts @@ -0,0 +1,6 @@ +import {useTheme_v2} from '@sanity/ui' + +export function useCardColor(): {border: string} { + const {color} = useTheme_v2() + return {border: color.border} +} diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/tsconfig.json b/plugins/sanity-plugin-dashboard-widget-vercel/tsconfig.json new file mode 100644 index 0000000000..f55220a341 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": ["@sanity/tsconfig/strictest"], + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["dist", "node_modules"] +} diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/tsdown.config.ts b/plugins/sanity-plugin-dashboard-widget-vercel/tsdown.config.ts new file mode 100644 index 0000000000..757ef5c8f1 --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/tsdown.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from '@sanity/tsdown-config' +import type {UserConfig} from 'tsdown' + +export default defineConfig({ + styledComponents: true, + reactCompiler: true, +}) satisfies Promise diff --git a/plugins/sanity-plugin-dashboard-widget-vercel/vitest.config.ts b/plugins/sanity-plugin-dashboard-widget-vercel/vitest.config.ts new file mode 100644 index 0000000000..8eda73152e --- /dev/null +++ b/plugins/sanity-plugin-dashboard-widget-vercel/vitest.config.ts @@ -0,0 +1,11 @@ +import {defineConfig} from 'vitest/config' + +export default defineConfig({ + test: { + server: { + deps: { + inline: ['vitest-package-exports'], + }, + }, + }, +}) diff --git a/plugins/sanity-plugin-documents-pane/CHANGELOG.md b/plugins/sanity-plugin-documents-pane/CHANGELOG.md new file mode 100644 index 0000000000..645513e3a9 --- /dev/null +++ b/plugins/sanity-plugin-documents-pane/CHANGELOG.md @@ -0,0 +1,296 @@ +# sanity-plugin-documents-pane + +## 4.1.15 + +### Patch Changes + +- [#1702](https://github.com/sanity-io/plugins/pull/1702) [`2a3a7ea`](https://github.com/sanity-io/plugins/commit/2a3a7eab8616981991e4a0b345ebe866a5fec8df) Thanks [@stipsan](https://github.com/stipsan)! - Update `@sanity/ui` dependency to ^3.4.3. + +- Updated dependencies [[`2a3a7ea`](https://github.com/sanity-io/plugins/commit/2a3a7eab8616981991e4a0b345ebe866a5fec8df)]: + - sanity-plugin-utils@2.0.13 + +## 4.1.14 + +### Patch Changes + +- [#1684](https://github.com/sanity-io/plugins/pull/1684) [`4ea0d1f`](https://github.com/sanity-io/plugins/commit/4ea0d1fd2eeb05b80f38e11aa17ca29390115999) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Update `@sanity/icons` dependency to the latest catalog version. + +- [#1684](https://github.com/sanity-io/plugins/pull/1684) [`4ea0d1f`](https://github.com/sanity-io/plugins/commit/4ea0d1fd2eeb05b80f38e11aa17ca29390115999) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Update `@sanity/ui` dependency to the latest catalog version. + +- Updated dependencies [[`4ea0d1f`](https://github.com/sanity-io/plugins/commit/4ea0d1fd2eeb05b80f38e11aa17ca29390115999), [`4ea0d1f`](https://github.com/sanity-io/plugins/commit/4ea0d1fd2eeb05b80f38e11aa17ca29390115999)]: + - sanity-plugin-utils@2.0.12 + +## 4.1.13 + +### Patch Changes + +- Updated dependencies [[`edb64e9`](https://github.com/sanity-io/plugins/commit/edb64e9d45d1c595ea8b9fdabd789c0bc58e9c48)]: + - sanity-plugin-utils@2.0.11 + +## 4.1.12 + +### Patch Changes + +- [#1622](https://github.com/sanity-io/plugins/pull/1622) [`6fe3c11`](https://github.com/sanity-io/plugins/commit/6fe3c11e32b8187a19fbdc333e4a8b159fe5a616) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.9 + +- Updated dependencies [[`6fe3c11`](https://github.com/sanity-io/plugins/commit/6fe3c11e32b8187a19fbdc333e4a8b159fe5a616)]: + - sanity-plugin-utils@2.0.10 + +## 4.1.11 + +### Patch Changes + +- [#1596](https://github.com/sanity-io/plugins/pull/1596) [`f06fd76`](https://github.com/sanity-io/plugins/commit/f06fd767531740a09a5755f41fa1d3d42da202ae) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.8 + +- Updated dependencies [[`f06fd76`](https://github.com/sanity-io/plugins/commit/f06fd767531740a09a5755f41fa1d3d42da202ae)]: + - sanity-plugin-utils@2.0.9 + +## 4.1.10 + +### Patch Changes + +- [#1571](https://github.com/sanity-io/plugins/pull/1571) [`52975b2`](https://github.com/sanity-io/plugins/commit/52975b2f0d4ea5086c800b2ce16190b862284a95) Thanks [@stipsan](https://github.com/stipsan)! - fix(deps): update tsdown to ^0.22.7 and @sanity/tsdown-config to ^0.14.0 + +- Updated dependencies [[`52975b2`](https://github.com/sanity-io/plugins/commit/52975b2f0d4ea5086c800b2ce16190b862284a95)]: + - sanity-plugin-utils@2.0.8 + +## 4.1.9 + +### Patch Changes + +- [#1519](https://github.com/sanity-io/plugins/pull/1519) [`a11d511`](https://github.com/sanity-io/plugins/commit/a11d511b371b332adc08197711583951eb294166) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): update dependency tsdown to ^0.22.5 + +- Updated dependencies [[`a11d511`](https://github.com/sanity-io/plugins/commit/a11d511b371b332adc08197711583951eb294166)]: + - sanity-plugin-utils@2.0.7 + +## 4.1.8 + +### Patch Changes + +- [#1491](https://github.com/sanity-io/plugins/pull/1491) [`2361892`](https://github.com/sanity-io/plugins/commit/236189294b6408c9bced43765e53cf26a11a0e66) Thanks [@stipsan](https://github.com/stipsan)! - Build with `tsdown` instead of `@sanity/pkg-utils`. Internal build-tooling change only, with no intended changes to the public API or runtime behavior. + +- Updated dependencies [[`2361892`](https://github.com/sanity-io/plugins/commit/236189294b6408c9bced43765e53cf26a11a0e66)]: + - sanity-plugin-utils@2.0.6 + +## 4.1.7 + +### Patch Changes + +- [#1481](https://github.com/sanity-io/plugins/pull/1481) [`0eae652`](https://github.com/sanity-io/plugins/commit/0eae652abea74fd63af2d334707afc8ecd4eb15a) Thanks [@stipsan](https://github.com/stipsan)! - Upgrade `@sanity/pkg-utils` to `^10.9.0`, enabling tree-shaking of unused `styled-components` in the published bundle. Tagged template literals are now transpiled to plain call expressions during build, so bundlers can drop styled components this plugin exports but the app doesn't use, reducing bundle size. + +- Updated dependencies [[`0eae652`](https://github.com/sanity-io/plugins/commit/0eae652abea74fd63af2d334707afc8ecd4eb15a)]: + - sanity-plugin-utils@2.0.5 + +## 4.1.6 + +### Patch Changes + +- [#1471](https://github.com/sanity-io/plugins/pull/1471) [`52487d2`](https://github.com/sanity-io/plugins/commit/52487d208f11fe2a4ccb523fab9386f3fbdd5880) Thanks [@stipsan](https://github.com/stipsan)! - Update `@sanity/icons` to v4 and adopt its per-icon import paths for smaller bundles and faster treeshaking + +- Updated dependencies [[`52487d2`](https://github.com/sanity-io/plugins/commit/52487d208f11fe2a4ccb523fab9386f3fbdd5880)]: + - sanity-plugin-utils@2.0.4 + +## 4.1.5 + +### Patch Changes + +- [#1304](https://github.com/sanity-io/plugins/pull/1304) [`5d2195a`](https://github.com/sanity-io/plugins/commit/5d2195a8b56b1907391a6bfb9cff9ca5448bc9dc) Thanks [@squiggler-app](https://github.com/apps/squiggler-app)! - fix(deps): Update dependency @sanity/uuid to ^3.0.3 + +## 4.1.4 + +### Patch Changes + +- [`7a37fd1`](https://github.com/sanity-io/plugins/commit/7a37fd1653681de5f892de2dea29b83e9b119ff1) Thanks [@stipsan](https://github.com/stipsan)! - use `workspace:^` for prod deps + +- Updated dependencies [[`7c1a95c`](https://github.com/sanity-io/plugins/commit/7c1a95c2213555c50bca2fde2af3590abc57c444)]: + - sanity-plugin-utils@2.0.3 + +## 4.1.3 + +### Patch Changes + +- Updated dependencies [[`c66b926`](https://github.com/sanity-io/plugins/commit/c66b9269394b2ec45c320580a39069e6fd39dd4d)]: + - sanity-plugin-utils@2.0.2 + +## 4.1.2 + +### Patch Changes + +- [#980](https://github.com/sanity-io/plugins/pull/980) [`98d148e`](https://github.com/sanity-io/plugins/commit/98d148e00ef679b422e1effe7fc53dfce9cb046c) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Update `@sanity/pkg-utils` to pick up a DTS generation bug fix. + +- Updated dependencies [[`98d148e`](https://github.com/sanity-io/plugins/commit/98d148e00ef679b422e1effe7fc53dfce9cb046c)]: + - sanity-plugin-utils@2.0.1 + +## 4.1.1 + +### Patch Changes + +- [#964](https://github.com/sanity-io/plugins/pull/964) [`4226408`](https://github.com/sanity-io/plugins/commit/4226408594d2717cf2503866f5d5216991701d38) Thanks [@stipsan](https://github.com/stipsan)! - Update `@sanity/util` dependency to v6, in line with Sanity Studio v6 + +## 4.1.0 + +### Minor Changes + +- [#949](https://github.com/sanity-io/plugins/pull/949) [`bde3e70`](https://github.com/sanity-io/plugins/commit/bde3e70bfe1438768a4f4d349a3c0d2b3313fd3d) Thanks [@pedrobonamin](https://github.com/pedrobonamin)! - Show document count in the documents pane view + +## 4.0.0 + +### Major Changes + +- [#938](https://github.com/sanity-io/plugins/pull/938) [`5f8d73c`](https://github.com/sanity-io/plugins/commit/5f8d73cb898a336013b0d6c3be8ba674de62e26a) Thanks [@pedrobonamin](https://github.com/pedrobonamin)! - Port sanity-plugin-documents-pane to the Sanity plugins monorepo + + This major release includes several breaking changes as part of the migration to the monorepo: + + - **React Compiler enabled**: The package is now built with React Compiler targeting React 19 + - **ESM-only**: CommonJS support has been removed. The package now ships only ESM + - **React 19.2+ required**: Minimum React version is now 19.2 (previously ^18.3 || ^19) + - **Sanity Studio v5+ required**: Minimum Sanity version is now v5 (Sanity v3 and v4 are no longer supported) + - **sanity-plugin-utils 2.x required**: The plugin now depends on sanity-plugin-utils v2 from the monorepo (previously ^1.7.0) + - **Node.js 20.19+ required**: Minimum Node.js version is now 20.19 (previously >=18) + +## [3.0.2](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v3.0.1...v3.0.2) (2025-12-29) + +### Bug Fixes + +- **deps:** allow studio v5 in peer deps ranges ([#78](https://github.com/sanity-io/sanity-plugin-documents-pane/issues/78)) ([ab115c8](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/ab115c8c90321ef22ebea046b8e641ccf43f26e5)) + +## [3.0.1](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v3.0.0...v3.0.1) (2025-11-04) + +### Bug Fixes + +- **deps:** update sanity-plugin-utils ([#77](https://github.com/sanity-io/sanity-plugin-documents-pane/issues/77)) ([c15c9fa](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/c15c9fadab457232bd3716e21afda01ecdb6c3f7)) + +## [3.0.0](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.4.1...v3.0.0) (2025-09-15) + +### ⚠ BREAKING CHANGES + +- **deps:** update @sanity/ui to 3.x (#76) + +### Features + +- **deps:** update @sanity/ui to 3.x ([#76](https://github.com/sanity-io/sanity-plugin-documents-pane/issues/76)) ([0a90797](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/0a90797ba0dc860a572cf98f1b2406eb2a28d932)) + +## [2.4.1](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.4.0...v2.4.1) (2025-07-10) + +### Bug Fixes + +- **deps:** allow studio v4 peer dep ranges ([2dd81af](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/2dd81af073432ba36b8962e24d2190dd9ecc5258)) + +## [2.4.0](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.3.0...v2.4.0) (2025-03-07) + +### Features + +- add react 19 to peer deps ([620f2ad](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/620f2ad86418b234c56acf128976cc0b63ca16cb)) + +## [2.3.0](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.2.2...v2.3.0) (2024-05-01) + +### Features + +- added duplicate action to doc list ([#68](https://github.com/sanity-io/sanity-plugin-documents-pane/issues/68)) ([8d65f8f](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/8d65f8faedc23a70ee192a59e56503fc5da3cc46)) + +## [2.2.2](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.2.1...v2.2.2) (2024-04-19) + +### Bug Fixes + +- update peer dependency of `sanity` to when `sanity/stucture` was introduced ([f8b9729](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/f8b9729e77b93e8d670755f7611a11b5b226d843)) + +## [2.2.1](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.2.0...v2.2.1) (2024-01-31) + +### Bug Fixes + +- update dependencies ([#66](https://github.com/sanity-io/sanity-plugin-documents-pane/issues/66)) ([322a8e0](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/322a8e00f86b4c8198a57b5477742ac9dc376daa)) + +## [2.2.0](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.1.2...v2.2.0) (2024-01-16) + +### Features + +- support perspectives ([#63](https://github.com/sanity-io/sanity-plugin-documents-pane/issues/63)) ([e2eb3bb](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/e2eb3bb791d1ab12525ae02dc357d7768af1d846)) + +## [2.1.2](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.1.1...v2.1.2) (2023-10-16) + +### Bug Fixes + +- upgrade to latest hooks and components from plugin-utils ([#55](https://github.com/sanity-io/sanity-plugin-documents-pane/issues/55)) ([1975b08](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/1975b08ea89155d6418cb3f69abf3ab02036debc)) + +## [2.1.1](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.1.0...v2.1.1) (2023-09-01) + +### Bug Fixes + +- **deps:** update dependencies (non-major) ([#13](https://github.com/sanity-io/sanity-plugin-documents-pane/issues/13)) ([4190889](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/41908893752ab2a6701537362ebf650ca55aff67)) +- **deps:** update dependency react-fast-compare to v3.2.2 ([#43](https://github.com/sanity-io/sanity-plugin-documents-pane/issues/43)) ([0d4272a](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/0d4272a5c32d295f3c6e5e6d01debff4598674ee)) +- **docs:** fixed install instruction ([e0cb7b2](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/e0cb7b209fe769feb5785a2e32c16dc5f06a116f)) + +## [2.1.0](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.0.1...v2.1.0) (2023-02-02) + +### Features + +- warn on unknown schema type ([755ef81](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/755ef81082224b4cc02acb5417987bf84e3b4d3c)) + +## [2.0.1](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.0.0...v2.0.1) (2022-11-25) + +### Bug Fixes + +- **deps:** sanity ^3.0.0 (works with rc.3) ([3c7c979](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/3c7c979afa66c4a9355180f49127a24255ac6120)) + +## [2.0.0](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v1.1.0...v2.0.0) (2022-11-17) + +### ⚠ BREAKING CHANGES + +- this version no longer works in Sanity Studio v2 +- initial studio v3 version + +### Features + +- initial Sanity Studio v3 release ([b6b7df9](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/b6b7df99d38bc10b6c9585fd57e09b89f5e58c2d)) +- initial studio v3 version ([31b37ef](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/31b37ef159f942eebf5665a574156d2ee66a1265)) + +### Bug Fixes + +- compiled for sanity 3.0.0-rc.0 ([244af05](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/244af052fe819efdad0bae777dc330cb7de8ed54)) +- **deps:** dev-preview.21 ([94a2e3e](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/94a2e3eb361776d4389993f017a20120674b6647)) +- **deps:** dev-preview.22 ([e3d4641](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/e3d4641b3f179c6243de1a4be44aab0db4a64112)) +- **deps:** pkg-utils & @sanity/plugin-kit ([6db80dd](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/6db80dda1c8821261fbc2004c55a20380d0ab48b)) + +## [2.0.0-v3-studio.5](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.0.0-v3-studio.4...v2.0.0-v3-studio.5) (2022-11-04) + +### Bug Fixes + +- **deps:** pkg-utils & @sanity/plugin-kit ([6db80dd](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/6db80dda1c8821261fbc2004c55a20380d0ab48b)) + +## [2.0.0-v3-studio.4](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.0.0-v3-studio.3...v2.0.0-v3-studio.4) (2022-11-02) + +### Bug Fixes + +- compiled for sanity 3.0.0-rc.0 ([244af05](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/244af052fe819efdad0bae777dc330cb7de8ed54)) + +## [2.0.0-v3-studio.3](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.0.0-v3-studio.2...v2.0.0-v3-studio.3) (2022-10-27) + +### Bug Fixes + +- **deps:** dev-preview.22 ([e3d4641](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/e3d4641b3f179c6243de1a4be44aab0db4a64112)) + +## [2.0.0-v3-studio.2](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v2.0.0-v3-studio.1...v2.0.0-v3-studio.2) (2022-10-07) + +### Bug Fixes + +- **deps:** dev-preview.21 ([94a2e3e](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/94a2e3eb361776d4389993f017a20120674b6647)) + +## [2.0.0-v3-studio.1](https://github.com/sanity-io/sanity-plugin-documents-pane/compare/v1.1.0...v2.0.0-v3-studio.1) (2022-10-05) + +### ⚠ BREAKING CHANGES + +- initial studio v3 version + +### Features + +- initial studio v3 version ([31b37ef](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/31b37ef159f942eebf5665a574156d2ee66a1265)) + +## 1.0.0-v3-studio.1 (2022-10-04) + +### ⚠ BREAKING CHANGES + +- initial studio v3 version + +### Features + +- initial studio v3 version ([6672199](https://github.com/sanity-io/sanity-plugin-documents-pane/commit/6672199a578abfc3636d5bc3291ab8d4c88bc27a)) diff --git a/plugins/sanity-plugin-documents-pane/LICENSE b/plugins/sanity-plugin-documents-pane/LICENSE new file mode 100644 index 0000000000..14dbfae686 --- /dev/null +++ b/plugins/sanity-plugin-documents-pane/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Sanity.io + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/sanity-plugin-documents-pane/README.md b/plugins/sanity-plugin-documents-pane/README.md new file mode 100644 index 0000000000..c88404a7a3 --- /dev/null +++ b/plugins/sanity-plugin-documents-pane/README.md @@ -0,0 +1,103 @@ +# sanity-plugin-documents-pane + +Displays the results of a GROQ query in a View Pane. With the ability to use field values in the current document as query parameters. + +![Incoming References](https://user-images.githubusercontent.com/9684022/121202594-52bc6180-c86d-11eb-897d-f418047b3d22.png) + +## Installation + +```sh +npm install --save sanity-plugin-documents-pane +``` + +or + +```sh +yarn add sanity-plugin-documents-pane +``` + +This plugin is designed to be used as a [Component inside of a View](https://www.sanity.io/docs/structure-builder-reference#c0c8284844b7). + +The example below illustrates using the current Document being used to query for all published documents that reference it. + +```js +// ./src/deskStructure.js +import DocumentsPane from 'sanity-plugin-documents-pane' + +// ...all other list items + +S.view + .component(DocumentsPane) + .options({ + query: `*[references($id)]`, + params: {id: `_id`}, + options: {perspective: 'previewDrafts'}, + }) + .title('Incoming References') +``` + +The `.options()` configuration works as follows: + +- `query` (string, required) A string defining the entire GROQ query that will select documents to list. +- `params` (object or function, optional) + - Object: a [dot-notated string](https://www.npmjs.com/package/dlv) from the document object to a field, to use as variables in the query. + - Function: a function that receives the various displayed, draft, and published versions of the document, and returns an object of query parameters. Return null if the parameters cannot be resolved. +- `useDraft` (bool, optional, default: `false`) When populating the `params` values, it will use the `published` version of the document by default. Not permitted if using a function for `params` as the function will determine which version of the document to use. +- `debug` (bool, optional, default: `false`) In case of an error or the query returning no documents, setting to `true` will display the query and params that were used. +- `initialValueTemplates` (function, optional) A function that receives the various displayed, draft, and published versions of the document, and returns a list of initial value templates. These will be used to define buttons at the top of the list so users can create new related documents. +- `options` (object, optional) An object of options passed to the listening query. Includes support for `apiVersion` and `perspective`. +- `duplicate` (bool, optional, default: `false`) Enables a duplicate action in the context of the document list of the document pane. Useful for retaining existing editing context when needing to create new incoming references. + +## Resolving query parameters with a function and providing initial value templates + +Providing a function for `params` allows us to modify values from the current document, for example to list references to a draft document. Providing a function for the `initialValueTemplates` option allows us to determine which buttons to show and what parameters will be used for the new document. + +```js +const options = { + query: `*[_type=="post" && author._ref == $id]`, + params: ({document}) => { + // references will never point to a draft ID, so extract the regular ID + const id = document.displayed._id?.replace('drafts.', '') + + // we don't have to worry about undefined parameters, + // as the plugin will handle them and show an appropriate message + return {id} + }, + initialValueTemplates: ({document}) => { + const templates = [] + + // references must point to a non-draft ID, so if using the ID in the template, + // be sure it doesn't start with `drafts.` + const id = document?.displayed?._id.replace('drafts.', '') + const name = document?.displayed?.name || 'author' + + if (id) { + templates.push({ + // the name of the schema type that should be created (required) + schemaType: 'post', + // the title that should appear on the button - we can customize it (required) + title: `New post by ${name}`, + // the name of the template that should be used (optional) + template: 'postWithAuthor', + // values for parameters that can be passed to the template referenced above (optional) + parameters: { + authorId: id, + }, + }) + + // we could push more templates if needed. + } + + // must always return a list, even if empty + return templates + }, +} +``` + +## Thanks! + +This plugin is based on [Incoming References](https://github.com/sanity-io/sanity/tree/victoria/incoming-refs-preview/packages/test-studio/src/previews/incoming-refs) originally written by Victoria Bergquist. + +## License + +MIT-licensed. See LICENSE. diff --git a/plugins/sanity-plugin-documents-pane/package.json b/plugins/sanity-plugin-documents-pane/package.json new file mode 100644 index 0000000000..98df5781e8 --- /dev/null +++ b/plugins/sanity-plugin-documents-pane/package.json @@ -0,0 +1,70 @@ +{ + "name": "sanity-plugin-documents-pane", + "version": "4.1.15", + "description": "Displays the results of a GROQ query in a View Pane", + "keywords": [ + "sanity", + "sanity-plugin" + ], + "homepage": "https://github.com/sanity-io/plugins/tree/main/plugins/sanity-plugin-documents-pane#readme", + "bugs": { + "url": "https://github.com/sanity-io/plugins/issues" + }, + "license": "MIT", + "author": "Sanity.io ", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/sanity-io/plugins.git", + "directory": "plugins/sanity-plugin-documents-pane" + }, + "files": [ + "dist" + ], + "type": "module", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "build": "tsdown", + "prepack": "turbo run build" + }, + "dependencies": { + "@sanity/icons": "catalog:", + "@sanity/ui": "catalog:", + "@sanity/util": "catalog:", + "@sanity/uuid": "catalog:", + "dlv": "^1.1.3", + "rxjs": "catalog:", + "sanity-plugin-utils": "workspace:^" + }, + "devDependencies": { + "@sanity/tsconfig": "catalog:", + "@sanity/tsdown-config": "catalog:", + "@types/dlv": "^1.1.5", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "babel-plugin-react-compiler": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "sanity": "catalog:", + "styled-components": "catalog:", + "tsdown": "catalog:" + }, + "peerDependencies": { + "react": "catalog:peer", + "react-dom": "catalog:peer", + "sanity": "catalog:peer", + "styled-components": "catalog:peer" + }, + "engines": { + "node": ">=20.19 <22 || >=22.12" + } +} diff --git a/plugins/sanity-plugin-documents-pane/src/Debug.tsx b/plugins/sanity-plugin-documents-pane/src/Debug.tsx new file mode 100644 index 0000000000..eac00e8727 --- /dev/null +++ b/plugins/sanity-plugin-documents-pane/src/Debug.tsx @@ -0,0 +1,26 @@ +import {Box, Code, Label, Stack} from '@sanity/ui' + +export default function Debug({query, params}: {query: string; params?: Record}) { + return ( + <> + + + + + + {query} + + + {params ? ( + + + + + + {JSON.stringify(params)} + + + ) : null} + + ) +} diff --git a/plugins/sanity-plugin-documents-pane/src/Documents.tsx b/plugins/sanity-plugin-documents-pane/src/Documents.tsx new file mode 100644 index 0000000000..3ddcfaba4f --- /dev/null +++ b/plugins/sanity-plugin-documents-pane/src/Documents.tsx @@ -0,0 +1,148 @@ +import {WarningOutlineIcon} from '@sanity/icons/WarningOutline' +import {Box, Button, Card, Flex, Spinner, Stack, Text} from '@sanity/ui' +import {fromString as pathFromString} from '@sanity/util/paths' +import {useCallback} from 'react' +import { + DefaultPreview, + getPublishedId, + Preview, + useSchema, + type ListenQueryOptions, + type SanityDocument, +} from 'sanity' +import {Feedback, useListeningQuery} from 'sanity-plugin-utils' +import {usePaneRouter} from 'sanity/structure' + +import Debug from './Debug' +import DuplicateDocument from './DuplicateDocument' +import NewDocument from './NewDocument' +import type {DocumentsPaneInitialValueTemplate} from './types' + +type DocumentsProps = { + query: string + params: Record + debug: boolean + initialValueTemplates: DocumentsPaneInitialValueTemplate[] + options: ListenQueryOptions + duplicate: boolean +} + +export default function Documents(props: DocumentsProps) { + const {query, params, options, debug, initialValueTemplates, duplicate} = props + const {routerPanesState, groupIndex, handleEditReference} = usePaneRouter() + const schema = useSchema() + + const { + loading, + error, + data: _data, + } = useListeningQuery(query, { + params, + initialValue: [], + options, + }) + const data = _data ?? [] + + const handleClick = useCallback( + (id: string, type: string) => { + const childParams = routerPanesState[groupIndex + 1]?.[0]?.params || {} + const {parentRefPath} = childParams + + handleEditReference({ + id, + type, + parentRefPath: parentRefPath ? pathFromString(parentRefPath) : [''], + template: {id}, + }) + }, + [routerPanesState, groupIndex, handleEditReference], + ) + + if (loading) { + return ( + + + + + + ) + } + + if (error) { + return ( + + There was an error performing this query + {debug ? : null} + + ) + } + + if (!data.length) { + return ( + <> + + + + No Documents found + {debug ? : null} + + + ) + } + + return ( + <> + + + + + + + + {data.map((doc) => { + const schemaType = schema.get(doc._type) + const originalId = doc['_originalId'] + const previewValue = typeof originalId === 'string' ? {...doc, _id: originalId} : doc + + return schemaType ? ( + + ) : ( + + } + subtitle={`Encountered type "${doc._type}" that is not defined in the schema.`} + title="Unknown schema type found" + /> + + ) + })} + + + ) +} + +function DocumentsCount({count}: {count: number}) { + const label = count === 1 ? 'document' : 'documents' + + return ( + + {count} {label} + + ) +} diff --git a/plugins/sanity-plugin-documents-pane/src/DocumentsPane.tsx b/plugins/sanity-plugin-documents-pane/src/DocumentsPane.tsx new file mode 100644 index 0000000000..c3bbd94b97 --- /dev/null +++ b/plugins/sanity-plugin-documents-pane/src/DocumentsPane.tsx @@ -0,0 +1,63 @@ +import {Stack} from '@sanity/ui' +import {Feedback} from 'sanity-plugin-utils' + +import Debug from './Debug' +import Documents from './Documents' +import resolveInitialValueTemplates from './resolveInitialValueTemplates' +import resolveParams from './resolveParams' +import type {DocumentsPaneProps} from './types' + +export default function DocumentsPane(props: DocumentsPaneProps) { + const {document} = props + const { + query, + params, + useDraft = false, + debug = false, + initialValueTemplates: initialValueTemplatesResolver, + options = {}, + duplicate = false, + } = props.options + + if (useDraft && typeof params === 'function') { + return ( + + + useDraft should not be true when supplying a function for + params + + {debug ? : null} + + ) + } + + const paramValues = resolveParams({document, params, useDraft}) + + const initialValueTemplates = resolveInitialValueTemplates({ + resolver: initialValueTemplatesResolver, + document, + }) + + if (!paramValues) { + return ( + + + Parameters for this query could not be resolved. This may mean the document does not yet + exist, or is incomplete. + + {debug ? : null} + + ) + } + + return ( + + ) +} diff --git a/plugins/sanity-plugin-documents-pane/src/DuplicateDocument.tsx b/plugins/sanity-plugin-documents-pane/src/DuplicateDocument.tsx new file mode 100644 index 0000000000..7f79148abb --- /dev/null +++ b/plugins/sanity-plugin-documents-pane/src/DuplicateDocument.tsx @@ -0,0 +1,96 @@ +import {CopyIcon} from '@sanity/icons/Copy' +import {Box, Button, Text, Tooltip} from '@sanity/ui' +import {fromString as pathFromString} from '@sanity/util/paths' +import {uuid} from '@sanity/uuid' +import {useCallback, useState, type MouseEvent} from 'react' +import {filter, firstValueFrom} from 'rxjs' +import { + useDocumentOperation, + useDocumentPairPermissions, + useDocumentStore, + useTranslation, +} from 'sanity' +import {structureLocaleNamespace} from 'sanity/structure' +import {usePaneRouter} from 'sanity/structure' + +interface DuplicateDocumentProps { + id: string + type: string +} + +export default function DuplicateDocument(props: DuplicateDocumentProps) { + const {id, type} = props + + const documentStore = useDocumentStore() + const {duplicate} = useDocumentOperation(id, type) + const {routerPanesState, groupIndex, handleEditReference} = usePaneRouter() + const [isDuplicating, setDuplicating] = useState(false) + const [permissions, isPermissionsLoading] = useDocumentPairPermissions({ + id, + type, + permission: 'duplicate', + }) + + const {t} = useTranslation(structureLocaleNamespace) + + const handle = useCallback( + async (event: MouseEvent) => { + event.stopPropagation() + const dupeId = uuid() + + setDuplicating(true) + + const duplicateSuccess = firstValueFrom( + documentStore.pair + .operationEvents(id, type) + .pipe(filter((e) => e.op === 'duplicate' && e.type === 'success')), + ) + duplicate.execute(dupeId) + + await duplicateSuccess + setDuplicating(false) + + const childParams = routerPanesState[groupIndex + 1]?.[0]?.params || {} + const {parentRefPath} = childParams + + handleEditReference({ + id: dupeId, + type, + parentRefPath: parentRefPath ? pathFromString(parentRefPath) : [''], + template: {id: dupeId}, + }) + }, + [documentStore.pair, duplicate, groupIndex, handleEditReference, id, routerPanesState, type], + ) + + if (isPermissionsLoading || !permissions?.granted) { + return null + } + + return ( + + + {t('action.duplicate.label')} + + + } + placement="left" + portal + > +