diff --git a/.claude/skills/vitnode-docs/SKILL.md b/.claude/skills/vitnode-docs/SKILL.md index 197627eb20..cf35c7e1c2 100644 --- a/.claude/skills/vitnode-docs/SKILL.md +++ b/.claude/skills/vitnode-docs/SKILL.md @@ -65,6 +65,7 @@ Keep the final verification in “Check the result” instead of repeating verif #### Code and reference schema - Give every code block a language and, for file edits, a supported file title. +- Keep title short. - Show a complete minimal file when creating one; show a labeled excerpt when changing an existing file. State where an excerpt belongs and link to required setup. - Exclude unrelated styling, route metadata, schemas, and error handling from focused excerpts. Preserve code required to reproduce the task safely and correctly. - Explain a new concept once, next to its first use. Do not narrate obvious code line by line. @@ -191,7 +192,10 @@ Capture screenshots when documenting AdminCP, forms, settings, editors, or other 6. Capture the relevant panel or region with enough navigation context to orient the reader. Add a second screenshot only if a distinct state needs explanation. 7. Inspect each saved image. Retake it if labels are unreadable, content is clipped, or the page shows loading/error states unrelated to the guide. 8. Avoid exposing personal data, tokens, cookies, private URLs, or real user records. Prefer preparing clean demo data before capture. -9. Save assets in the repository's existing documentation image location. Use descriptive kebab-case names and stable repository paths. Use PNG or WebP according to existing support; keep text sharp and file sizes reasonable. +9. Save assets in the repository's existing documentation image location. Use descriptive kebab-case names and stable repository paths. Save every new or re-captured screenshot as WebP; keep text sharp and file sizes reasonable. + - Capture at a 2x device scale factor. If the tool only writes PNG, capture to PNG, then convert it to WebP and delete the intermediate PNG. Never commit PNG screenshots. + - Convert with high quality to keep UI text sharp, for example Pillow lossy `quality=90` with `method=6`. Use lossless WebP when small text blurs. Inspect the WebP after conversion. + - When replacing an existing PNG screenshot, change the MDX import from `./name.png` to `./name.webp` and delete the old PNG. Leave no reference to the removed file. 10. Place the image beside the step it illustrates. Add useful alt text and a short caption when the state needs explanation. Keep the action and result in text as well. Never generate, draw, or fabricate an application screenshot. Do not substitute a mockup or claim a screenshot was captured when it was not. If the app, credentials, or capture tools are unavailable, finish the text and report the missing capture and its precise prerequisite. Do not commit broken image references or placeholders as completed screenshots. diff --git a/AGENTS.md b/AGENTS.md index 0f25f6a425..54bc5df57f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ npm i x # Testing -- Write and run vitest unit tests for a new feature - a capability the codebase did not have - and for every bug fix. Skip only if vitest isn't configured. +- Never write unit tests in the same session. Spawn a new session, and write the test after the feature is working. Tests are for regression, not for development. - UI component tests (`*.test.tsx`) should test DOM output, click events, and dialogs. Never test CSS classes or form UI (`AutoForm`, form fields). - Do not test an existing feature applied somewhere new. Building the widgets system earns tests; adding widget zones or widgets to another page or plugin does not, because the mechanism is already covered and only the wiring is new. - Do not test database models or schema definitions (Drizzle tables, column types, indexes, foreign keys). diff --git a/README.md b/README.md index 1633ca1e80..172a6cc3fb 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ feature. That boundary pays rent surprisingly quickly. - [Build your first plugin](https://vitnode.com/docs/guides/first-plugin) - [Plugin routes](https://vitnode.com/docs/dev/routing) - [Admin Control Panel](https://vitnode.com/docs/dev/plugins/admin) -- [Content delivery and SEO](https://vitnode.com/docs/dev/content-engine/content-delivery-and-seo) +- [Content delivery and SEO](https://vitnode.com/docs/content-engine/public-pages) - [Write documentation](https://vitnode.com/docs/dev/documentation) ## Project scope diff --git a/apps/api/migrations/20261010084604_add_content_live_editing/migration.sql b/apps/api/migrations/20261010084604_add_content_live_editing/migration.sql new file mode 100644 index 0000000000..199576a0d6 --- /dev/null +++ b/apps/api/migrations/20261010084604_add_content_live_editing/migration.sql @@ -0,0 +1,65 @@ +CREATE TABLE "core_content_documents" ( + "id" serial PRIMARY KEY, + "contentTypeId" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "field" varchar(100) NOT NULL, + "language" varchar(35) DEFAULT '' NOT NULL, + "state" bytea NOT NULL, + "baseVersion" integer, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "updatedBy" integer +); +--> statement-breakpoint +ALTER TABLE "core_content_documents" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_content_drafts" ( + "id" serial PRIMARY KEY, + "contentTypeId" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "language" varchar(32) DEFAULT '' NOT NULL, + "values" jsonb DEFAULT '{}' NOT NULL, + "baseVersion" integer DEFAULT 0 NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "updatedBy" integer +); +--> statement-breakpoint +ALTER TABLE "core_content_drafts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_content_field_locks" ( + "id" serial PRIMARY KEY, + "contentTypeId" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "field" varchar(100) NOT NULL, + "language" varchar(32) DEFAULT '' NOT NULL, + "userId" integer NOT NULL, + "acquiredAt" timestamp DEFAULT now() NOT NULL, + "expiresAt" timestamp NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_content_field_locks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "blog_posts_translations" ALTER COLUMN "content" SET DATA TYPE jsonb USING ( + CASE + WHEN "content" IS NULL THEN NULL + WHEN btrim(regexp_replace("content", '<[^>]*>', ' ', 'g')) = '' THEN '{"type":"doc","content":[]}'::jsonb + ELSE jsonb_build_object( + 'type', 'doc', + 'content', jsonb_build_array(jsonb_build_object( + 'type', 'paragraph', + 'content', jsonb_build_array(jsonb_build_object( + 'type', 'text', + 'text', btrim(regexp_replace(regexp_replace("content", '<[^>]*>', ' ', 'g'), '\s+', ' ', 'g')) + )) + )) + ) + END +);--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_documents_doc_unique" ON "core_content_documents" ("contentTypeId","itemId","field","language");--> statement-breakpoint +CREATE INDEX "core_content_documents_updated_at_idx" ON "core_content_documents" ("updatedAt");--> statement-breakpoint +CREATE INDEX "core_content_documents_updated_by_idx" ON "core_content_documents" ("updatedBy");--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_drafts_key_unique" ON "core_content_drafts" ("contentTypeId","itemId","language");--> statement-breakpoint +CREATE INDEX "core_content_drafts_updated_at_idx" ON "core_content_drafts" ("updatedAt");--> statement-breakpoint +CREATE INDEX "core_content_drafts_updated_by_idx" ON "core_content_drafts" ("updatedBy");--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_field_locks_key_unique" ON "core_content_field_locks" ("contentTypeId","itemId","field","language");--> statement-breakpoint +CREATE INDEX "core_content_field_locks_expires_at_idx" ON "core_content_field_locks" ("expiresAt");--> statement-breakpoint +CREATE INDEX "core_content_field_locks_user_id_idx" ON "core_content_field_locks" ("userId");--> statement-breakpoint +ALTER TABLE "core_content_documents" ADD CONSTRAINT "core_content_documents_updatedBy_core_users_id_fkey" FOREIGN KEY ("updatedBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "core_content_drafts" ADD CONSTRAINT "core_content_drafts_updatedBy_core_users_id_fkey" FOREIGN KEY ("updatedBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "core_content_field_locks" ADD CONSTRAINT "core_content_field_locks_userId_core_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "core_users"("id") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/apps/api/migrations/20261010084604_add_content_live_editing/snapshot.json b/apps/api/migrations/20261010084604_add_content_live_editing/snapshot.json new file mode 100644 index 0000000000..a8ec728822 --- /dev/null +++ b/apps/api/migrations/20261010084604_add_content_live_editing/snapshot.json @@ -0,0 +1,14941 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "f56f3fa4-bc19-468a-9bb0-04e398dfc3d4", + "prevIds": [ + "4ca32b27-ee74-4516-a0e6-303456070af3" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_translation_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_drafts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_field_locks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_analysis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "acquiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'automatic'", + "generated": null, + "identity": null, + "name": "altPolicy", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "periodStart", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_budget_periods_scope_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_run_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "costSource", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reconciledAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_reconcile_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "callId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_cost_adjustments_call_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "budgetPeriodId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_run_period_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actionKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_action_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "leaseExpiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotencyKey\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_translation_sources_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_doc_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_file_language_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileFingerprint", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_analysis_file_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status <> 'completed'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_state_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dedupeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"dedupeKey\" IS NOT NULL AND status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_dedupe_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_translation_sources_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_documents_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_drafts_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_field_locks_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_languageCode_core_languages_code_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_analysis_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_analysis_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_state_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_documents_pkey", + "schema": "public", + "table": "core_content_documents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_drafts_pkey", + "schema": "public", + "table": "core_content_drafts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_field_locks_pkey", + "schema": "public", + "table": "core_content_field_locks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_pkey", + "schema": "public", + "table": "core_files_alt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_analysis_pkey", + "schema": "public", + "table": "core_files_alt_analysis", + "entityType": "pks" + }, + { + "columns": [ + "fileId" + ], + "nameExplicit": false, + "name": "core_files_alt_state_pkey", + "schema": "public", + "table": "core_files_alt_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/web/content/docs/content-engine/admincp-article-list.webp b/apps/web/content/docs/content-engine/admincp-article-list.webp new file mode 100644 index 0000000000..e913bb33fd Binary files /dev/null and b/apps/web/content/docs/content-engine/admincp-article-list.webp differ diff --git a/apps/web/content/docs/content-engine/admincp-bulk-actions.webp b/apps/web/content/docs/content-engine/admincp-bulk-actions.webp new file mode 100644 index 0000000000..1124aa0635 Binary files /dev/null and b/apps/web/content/docs/content-engine/admincp-bulk-actions.webp differ diff --git a/apps/web/content/docs/content-engine/admincp-create-dialog.webp b/apps/web/content/docs/content-engine/admincp-create-dialog.webp new file mode 100644 index 0000000000..83579b9588 Binary files /dev/null and b/apps/web/content/docs/content-engine/admincp-create-dialog.webp differ diff --git a/apps/web/content/docs/content-engine/admincp.mdx b/apps/web/content/docs/content-engine/admincp.mdx new file mode 100644 index 0000000000..a6b429b80e --- /dev/null +++ b/apps/web/content/docs/content-engine/admincp.mdx @@ -0,0 +1,384 @@ +--- +title: AdminCP +description: Customize a Content Engine content type's screens in the VitNode AdminCP, including list columns, search, sorting, form sections, page mode and custom cells, fields and layouts. +icon: LayoutDashboard +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import articleList from "./admincp-article-list.webp"; +import bulkActions from "./admincp-bulk-actions.webp"; +import createDialog from "./admincp-create-dialog.webp"; + +Every content type registered in the plugin's `src/admin/content.tsx` gets an AdminCP list and a create and edit form without any UI code. Most changes are options in the definition's `admin` block. For anything the options cannot express, you swap one cell, one field or the whole form layout for your own component. + +This page uses the example plugin's `example.article`. Its screen lives at `/admin/content/example/articles`. + +## Choose list columns, search and sorting + +`admin.list` drives the list: + +```ts title="plugins/example/src/content/article.ts" +admin: { + path: "example/articles", + titleField: "title", + list: { + columns: [ + "status", + "title", + "slug", + "code", + "category", + "author", + "animation", + "publishedAt", + "updatedAt", + ], + searchableFields: ["title", "code", "excerpt"], + orderableFields: ["title", "code", "slug"], + defaultOrderBy: "updatedAt", + defaultOrder: "desc", + }, +}, +``` + + + +| Option | What it does | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `columns` | Table columns, in order. The default is `status` (with publication), every shared single-value field, then `updatedAt`. | +| `searchableFields` | Fields the search box matches: `text`, `textarea`, `slug` or `richText`. A localized field matches in any language. The default is every shared text and textarea field. When the list ends up empty, the search box is hidden. | +| `orderableFields` | Your own fields with a sortable header. `id`, `createdAt` and `updatedAt` are always sortable, and so are `status`, `publishedAt` (with publication) and `version` (with editorial), so leave them out. Localized and file fields cannot be sorted. | +| `defaultOrderBy` | Initial sort column. Must be a built-in column or one of `orderableFields`. Defaults to `updatedAt`. | +| `defaultOrder` | `"asc"` or `"desc"`. Defaults to `"desc"`. | +| `thumbnailField` | A single shared `field.file()` drawn at the start of the title cell. `titleField` must be one of the `columns`. | + +A to-many `field.user()` or `field.relation()` can be a column. People show with their role color and relations as badges, loaded once per page instead of once per row. A gallery (`field.file({ multiple: true })`), a group or a repeatable cannot. A file column shows a thumbnail and the file name, which is why `animation` above is a column but not sortable. + +Every mistake here, such as an unknown field or a gallery in `columns`, throws a `ContentEngineError` when the plugin loads, so you find it before an editor does. + +### Other `admin` options + +| Option | Default | What it does | +| ------------------ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `path` | The id with dots as slashes | URL under `/admin/content/`. Lowercase segments of letters, digits and dashes; cannot end in `create` or `edit`. | +| `titleField` | First shared text or textarea field | Names a record in toasts, dialogs and relation pickers. `null` means the type has no title. | +| `colorField` | none | A shared field with a color, shown as a swatch next to the title. The blog's categories use it. | +| `navigation` | `true` | Set it to `false` to keep the screen but drop its sidebar link. | +| `permissionModule` | The id without the plugin part | Staff permission module name, for example `article` for `example.article`. | + +## Bulk actions + +Ticking rows opens a floating bar with **Publish**, **Unpublish** and **Delete**. There is nothing to configure. Publish and Unpublish appear with `publication` and the `can_publish` permission, Delete with `can_delete`. Without any of them, the table has no checkboxes. + + + +Each action asks for confirmation and then sends one request per record to the same route the row buttons use, so events, search indexing and revisions behave as if you clicked every row. When some records fail, for example because someone else saved them meanwhile, the rest still go through. A toast says how many were skipped, and those rows stay ticked. + +## Group form fields into sections + +By default the form shows every field except `field.blocks()`, one after another, in a dialog: + + + +To pick and order fields, set `admin.form.fields`. To group them into titled cards, use `admin.form.sections` instead: + +```ts title="plugins/example/src/content/article.ts" +admin: { + form: { + sections: [ + { name: "main", fields: ["title", "slug", "excerpt", "category"] }, + { name: "media", fields: ["animation", "gallery"] }, + { name: "settings", fields: ["code", "author", "featured", "noIndex", "views"] }, + ], + }, +}, +``` + +- Sections are the field list, so a field you leave out is not in the form. Declaring both `fields` and `sections` throws. +- Each field belongs to one section, and no section may be empty. +- A section `name` is lowercase snake case, because it is part of a translation key. + +The heading and an optional description come from the plugin's locale file, at `content..form.`. Without a message, the heading is the humanized name ("Main"). + +```json title="plugins/example/src/locales/en.json" +{ + "@vitnode/example": { + "content": { + "article": { + "form": { + "main": { "title": "Article", "desc": "What readers see first." }, + "media": { "title": "Media" } + } + } + } + } +} +``` + +## Open forms on their own page + +Forms open in a dialog. A long form reads better on a page, which is what the blog does for its articles: + +```ts title="plugins/blog/src/content/post.ts" +admin: { + path: "blog/articles", + create: { mode: "page" }, + edit: { mode: "page" }, +}, +``` + +The create form moves to `/admin/content/blog/articles/create` and the edit form to `/admin/content/blog/articles/{id}/edit`. You can set the two modes independently. + +## Replace a field or a cell + +Wrap a registration in `contentTypeAdmin` to swap the component of one form field or one list column. It adds nothing at runtime; it only checks that the keys are real field names. The blog plugin does this for its category color: + +```tsx title="plugins/blog/src/admin/content.tsx" +import type { ContentFrontendPluginSource } from "@vitnode/core/lib/plugin"; + +import { contentTypeAdmin } from "@vitnode/core/lib/plugin"; + +import { CONFIG_PLUGIN } from "@/const"; +import { BlogCategoryColorCell } from "@/views/admin/category/color-cell"; +import { BlogCategoryColorField } from "@/views/admin/category/color-field"; + +import { blogCategoryNav } from "./nav"; + +export const adminContent = { + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [ + contentTypeAdmin({ + ...blogCategoryNav, + fields: { + color: { component: BlogCategoryColorField }, + }, + columns: { + color: { cell: BlogCategoryColorCell }, + }, + }), + ], +} satisfies ContentFrontendPluginSource; +``` + +A cell receives the row, typed by the definition: + +```tsx title="plugins/blog/src/views/admin/category/color-cell.tsx" +import type { ContentCellProps } from "@vitnode/core/lib/plugin"; + +import { useTranslations } from "use-intl"; + +import type { blogCategoryContentType } from "@/content/category"; + +export const BlogCategoryColorCell = ({ + row, +}: ContentCellProps) => { + const t = useTranslations("@vitnode/blog.admin.category"); + + if (!row.color) { + return {t("color.none")}; + } + + return ( +
+ + + {row.color} + +
+ ); +}; +``` + +A field component receives `ItemAutoFormComponentProps` from `@vitnode/core/components/form/auto-form` and usually wraps an [AutoForm](/docs/ui/auto-form) field with your own label or control. + +Add `skeleton` when the component is bigger than its field kind suggests, so the loading placeholder matches. The blog's `content` field renders a rich editor and sets `skeleton: "editor"`. The options are `"editor"`, `"input"`, `"list"`, `"media"`, `"switch"` and `"textarea"`. + +## Replace the whole form layout + +For a layout the options cannot express, such as a main column with a sidebar, pass `forms.layout` to `contentTypeAdmin`. A custom layout is used only in page mode, so set `create.mode` and `edit.mode` to `"page"` first. + +```tsx title="plugins/example/src/views/admin/article/form-layout.tsx" +import type { ContentFormLayoutProps } from "@vitnode/core/lib/plugin"; + +import { + ContentFormActions, + ContentFormField, + ContentFormHeader, + ContentFormLayoutGrid, + ContentFormMain, + ContentFormRemainingFields, + ContentFormSection, + ContentFormSidebar, + ContentFormStatus, +} from "@vitnode/core/content/admin-form"; + +const SIDEBAR_FIELDS = ["category", "author", "featured", "noIndex"]; + +export const ArticleFormLayout = ({ mode }: ContentFormLayoutProps) => ( + <> + + + + + + + + + + + + + {mode === "edit" ? : null} + {SIDEBAR_FIELDS.map(name => ( + + ))} + + + +); +``` + +```tsx title="plugins/example/src/admin/content.tsx" +contentTypeAdmin({ + ...exampleArticleNav, + forms: { layout: ArticleFormLayout }, // [!code ++] +}), +``` + +`forms.create.layout` and `forms.edit.layout` override the shared one per action. `ContentFormRemainingFields` renders every form field you did not place yourself. In development, the browser console warns about fields the layout never rendered and about a missing `ContentFormHeader`, which holds the title and the back link. + +## Show a matching skeleton + +A loading screen that looks nothing like the form is the visual version of a +jump scare. The Content Engine avoids it by rendering your layout twice: first in +**skeleton mode** while the record loads, then for real. In skeleton mode, +`useContentForm().skeleton` is `true` and every `` +draws a placeholder instead of the field. + +So a layout built only from `ContentFormField`, `ContentFormRemainingFields` and +the other `ContentForm*` pieces already has a matching skeleton. You only need +two extra steps when your form draws more than fields. + +### Size a field's placeholder + +Each placeholder is sized from the field's kind. When your field component is +bigger than its kind suggests, set `skeleton` next to `component`, as described +in [Replace a field or a cell](#replace-a-field-or-a-cell): + +```tsx title="plugins/blog/src/admin/content.tsx" +fields: { + content: { component: BlogArticleEditorField, skeleton: "editor" }, + excerpt: { component: ArticleExcerptField, skeleton: "textarea" }, +}, +``` + +### Draw a skeleton for a custom layout + +A layout with its own header, a large title or a cover image should draw a +skeleton with the same wrappers, gaps and widths. Return it while `skeleton` is +`true`, and use the same component as the `Suspense` fallback when the real +editor is lazy-loaded. Then the reload goes skeleton → form with nothing in +between. + +```tsx title="plugins/blog/src/views/admin/article/form-layout.tsx" +import type { ContentFormLayoutProps } from "@vitnode/core/lib/plugin"; + +import { Skeleton } from "@vitnode/core/components/ui/skeleton"; +import { useContentForm } from "@vitnode/core/content/admin-form"; +import React from "react"; + +const loadArticleEditor = async () => await import("./editor/article-editor"); + +const ArticleEditor = React.lazy(async () => + loadArticleEditor().then(module => ({ default: module.ArticleEditor })), +); + +const ArticleEditorSkeleton = () => { + const { fieldNames, markHeaderRendered, markRendered, mode, skeleton } = + useContentForm(); + + markHeaderRendered?.(); + if (!skeleton) for (const name of fieldNames) markRendered?.(name); + + React.useEffect(() => { + void loadArticleEditor(); + }, []); + + return ( +
+
+ +
+ +
+
+ {mode === "edit" ? ( + + ) : ( + + )} + + +
+
+ ); +}; + +export const BlogArticleFormLayout = ({ + contentTypeId, + itemId, +}: ContentFormLayoutProps) => { + const { skeleton } = useContentForm(); + + if (skeleton) return ; + + return ( + }> + + + ); +}; +``` + +A few things that keep it smooth: + +- **Copy the real wrappers.** Same outer `div`, same padding, same `max-w-*`, + same breakpoints. A skeleton that is 20 pixels shorter still makes the page + jump. +- **Branch on `mode`.** The create form often looks different, for example an + empty upload area instead of a cover image and other buttons in the header. +- **Call `markHeaderRendered()`** when the skeleton draws its own header, so the + development warning about a missing `ContentFormHeader` stays quiet. +- **Mark the fields as rendered in the fallback.** While the lazy editor loads, + the real form is up, so the skeleton tells it that the layout will place every + field. Without it, the console warns about missing fields for a moment. +- **Start loading the editor from the skeleton.** The `useEffect` above fetches + the editor's code while the record is still loading, so the fallback is rarely + seen at all. +- **Use `Skeleton` from `@vitnode/core/components/ui/skeleton`** and the + Tailwind spacing scale, so placeholders match the theme in light and dark mode. + +## Check the result + +Save the definition, restart `pnpm dev` and open **AdminCP → Example → Articles**. The API validates sorting and form input against the same definition, so the restart makes sure both sides read the new `admin` options. + +Next, give the screens the permissions they need in [Production checklist](/docs/content-engine/production), or read every option in the [reference](/docs/content-engine/reference). diff --git a/apps/web/content/docs/content-engine/caching.mdx b/apps/web/content/docs/content-engine/caching.mdx new file mode 100644 index 0000000000..7516668059 --- /dev/null +++ b/apps/web/content/docs/content-engine/caching.mdx @@ -0,0 +1,162 @@ +--- +title: Caching +description: Cache VitNode Content Engine public reads with TanStack Query in the app, and expire them in a separate front end with cache tags and content.revalidateOrigins. +icon: Zap +--- + +The Content Engine does not cache public reads. Every request to a [public API](/docs/content-engine/public-api) route queries Postgres, and the response carries no `Cache-Control` header: + +```bash +curl -si "http://localhost:3000/api/@vitnode/example/content/articles?first=1" | head -4 +``` + +```text +HTTP/1.1 200 +access-control-allow-credentials: true +content-type: application/json +cross-origin-opener-policy: same-origin +``` + +So a published article shows up on the next request, with nothing to expire. Add caching where it pays off: + +| Where the content is read | Cache with | Expire it with | +| --------------------------------------- | --------------------------------------- | ----------------------------------------------------- | +| A page of your VitNode app | TanStack Query, warmed in the loader | Its `staleTime`, or `invalidateQueries` after a write | +| A separate front end with its own cache | That front end's tag cache | `content.revalidateOrigins` and the cache tags below | +| Your own Hono route | [`c.get("cache")`](/docs/dev/cache/api) | `c.get("cache").delete(key)` after a write | + +## Cache a public list in the app + +Wrap the fetcher call in `queryOptions` and warm it in the route loader, exactly as in [Cache app data](/docs/dev/cache/app). Use `defineRoute` from `@vitnode/core/tanstack/plugin-routes`, because its loader context has the `queryClient`: + +```tsx title="latest-articles.tsx" +import { queryOptions } from "@tanstack/react-query"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; +import { z } from "zod"; + +const zodArticleCard = z.object({ + excerpt: z.string().nullable(), + slug: z.string(), + title: z.string(), +}); + +export const latestArticlesQuery = () => + queryOptions({ + queryKey: ["@vitnode/example", "articles", "latest"], + queryFn: async () => { + const response = await fetcher({ + plugin: "@vitnode/example", + method: "get", + module: "content/articles", + path: "/", + args: { query: { first: 5 } }, + }); + + if (response.status !== 200) { + throw new Error(`Loading articles answered ${response.status}.`); + } + + const { edges } = await response.json(); + + return z.array(zodArticleCard).parse(edges); + }, + }); + +export const route = defineRoute({ + load: async ({ context }) => { + await context.queryClient.query({ + ...latestArticlesQuery(), + staleTime: 60_000, + }); + }, +}); +``` + +Each server render starts with an empty query client, so a full page load is always fresh. The `staleTime` only decides how long a visitor's open tab reuses the list during client-side navigation. `staleTime: "static"` keeps it until the tab reloads. + +Publishing in the AdminCP refreshes the AdminCP's own list, item and search queries. It cannot reach a visitor's browser, so pick a `staleTime` you are happy to wait out. For your own write routes, call `queryClient.invalidateQueries({ queryKey: ["@vitnode/example", "articles"] })` after the write. + +## Cache tags + +When a front end caches public reads under tags, it needs to know which tags a write affects. `@vitnode/core/content` exports pure string builders for that. They read nothing and need no server: + +| Builder | Example for `example.article` | +| --------------------------------------------------- | ------------------------------------------------------- | +| `contentPublicListTag(typeId, locale?)` | `content:example.article:list` | +| `contentPublicItemTag(typeId, id, locale?)` | `content:example.article:item:3` | +| `contentPublicSlugTag(typeId, slug, locale?)` | `content:example.article:slug:hello-content-engine` | +| `contentDeliveryTag(typeId, id, locale?)` | `content:example.article:delivery:3` | +| `contentDeliveryRedirectTag(typeId, slug, locale?)` | `content:example.article:redirect:hello-content-engine` | +| `contentDeliverySitemapTag(typeId, locale?)` | `content:example.article:sitemap` | + +A locale goes after the kind, so a localized type's Polish list is `content:example.localized-article:list:pl`. + +`contentInvalidationTags` turns a description of one write into the exact tags to expire. Publishing article `3` returns all six tags above: + +```ts title="revalidate.ts" +import { contentInvalidationTags } from "@vitnode/core/content"; + +const tags = contentInvalidationTags({ + contentTypeId: "example.article", + id: 3, + slugs: ["hello-content-engine"], + wasPublic: false, + isPublic: true, + delivery: { sitemap: { contentChanged: true, indexChanged: true } }, +}); +``` + +A write to a record that was a draft before and after returns `[]`, because no public reader ever saw it. On a slug change, pass both slugs so the old URL stops resolving and the new one starts. + +## Notify a separate front end + +If another app renders your content and caches it, list its origin in the API config: + +```ts title="apps/api/src/vitnode.api.config.ts" +export const vitNodeApiConfig = buildApiConfig({ + content: { + revalidateOrigins: ["https://www.example.com"], // [!code ++] + }, +}); +``` + +VitNode then sends a `POST` to `{origin}/api/vitnode/content/revalidate` when a [scheduled](/docs/content-engine/editorial#schedule-publishing) publish or unpublish runs. Publishing by hand in the AdminCP sends nothing today, so a front end that caches must also expire entries on its own timer. + +The request carries: + +- `Authorization: Bearer `, the same secret that runs cron jobs. +- `x-vitnode-timestamp`, the send time in milliseconds. +- A JSON body with the `contentInvalidationTags` input plus `mode`, which is always `"immediate"` for a scheduled run. + +The secret travels with the request, so outside development each origin must use `https`. Plain `http` is allowed only for `localhost` and `127.0.0.1`. VitNode sends nothing while `CRON_SECRET` is unset or still the default. It tries each origin twice and does not retry a `403`, which it reads as a wrong secret. + +### Build the revalidate endpoint + +The endpoint lives in your front end, not in VitNode. Before you expire anything, check the bearer in constant time and refuse a timestamp more than 5 minutes off, so a captured request cannot be replayed later: + +```ts title="is-trusted-revalidation.ts" +import { createHash, timingSafeEqual } from "node:crypto"; + +const MAX_SKEW_MS = 5 * 60 * 1000; + +const digest = (value: string) => createHash("sha256").update(value).digest(); + +export const isTrustedRevalidation = (request: Request): boolean => { + const secret = process.env.CRON_SECRET; + if (!secret) return false; + + const bearer = /^Bearer (.+)$/.exec( + request.headers.get("authorization") ?? "", + )?.[1]; + if (!bearer || !timingSafeEqual(digest(bearer), digest(secret))) return false; + + const sentAt = Number(request.headers.get("x-vitnode-timestamp")); + + return ( + Number.isFinite(sentAt) && Math.abs(Date.now() - sentAt) <= MAX_SKEW_MS + ); +}; +``` + +Hashing both values first gives `timingSafeEqual` two buffers of the same length. Answer `403` when the check fails. Otherwise pass the body to `contentInvalidationTags`, expire those tags in your cache and answer with any `2xx`. diff --git a/apps/web/content/docs/content-engine/create-a-content-type-categories-list.webp b/apps/web/content/docs/content-engine/create-a-content-type-categories-list.webp new file mode 100644 index 0000000000..f81ded4874 Binary files /dev/null and b/apps/web/content/docs/content-engine/create-a-content-type-categories-list.webp differ diff --git a/apps/web/content/docs/content-engine/create-a-content-type-create-dialog.webp b/apps/web/content/docs/content-engine/create-a-content-type-create-dialog.webp new file mode 100644 index 0000000000..7afec238a9 Binary files /dev/null and b/apps/web/content/docs/content-engine/create-a-content-type-create-dialog.webp differ diff --git a/apps/web/content/docs/content-engine/create-a-content-type-staff-permissions.webp b/apps/web/content/docs/content-engine/create-a-content-type-staff-permissions.webp new file mode 100644 index 0000000000..4c7c710eab Binary files /dev/null and b/apps/web/content/docs/content-engine/create-a-content-type-staff-permissions.webp differ diff --git a/apps/web/content/docs/content-engine/create-a-content-type.mdx b/apps/web/content/docs/content-engine/create-a-content-type.mdx new file mode 100644 index 0000000000..8e4616411b --- /dev/null +++ b/apps/web/content/docs/content-engine/create-a-content-type.mdx @@ -0,0 +1,276 @@ +--- +title: First content type +description: Build the example plugin's category content type with the VitNode Content Engine. One TypeScript definition gives you a migrated table, an AdminCP screen, staff API routes and staff permissions. +icon: FileCode +--- + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; +import { ImgDocs } from "@/components/fumadocs/img"; +import createDialog from "./create-a-content-type-create-dialog.webp"; +import categoriesList from "./create-a-content-type-categories-list.webp"; +import staffPermissions from "./create-a-content-type-staff-permissions.webp"; + +This tutorial builds `example.category`, the content type behind **AdminCP → Example → Categories** in VitNode's example plugin. You write one definition, wire it into four small files, run one migration, and get a Postgres table, a list with a create form, staff-only API routes and four staff permissions. Categories have one field, so you spend your time on the wiring instead of naming things. + +Every snippet is copied from `plugins/example`, with unrelated content types trimmed out. Use your own plugin id wherever you see `@vitnode/example`. + +## Before you begin + +You need a plugin that your app already loads, with `src/config.api.ts`, `src/config.tsx` and a `src/const.ts` that exports the plugin id: + +```ts title="plugins/example/src/const.ts" +export const CONFIG_PLUGIN = { pluginId: "@vitnode/example" as const }; +``` + +[Create a plugin](/docs/dev/plugins/create) generates all three. You also need a running PostgreSQL database and an AdminCP account. + + + + + +### Declare the content type + +Create `src/content/category.ts`: + +```ts title="plugins/example/src/content/category.ts" +import { defineContentType, field } from "@vitnode/core/content"; + +export const categoryContentType = defineContentType({ + id: "example.category", + tableName: "example_categories", + fields: { + name: field.text({ required: true, minLength: 1, maxLength: 100 }), + }, + admin: { + path: "example/categories", + list: { + columns: ["name", "createdAt"], + orderableFields: ["name"], + }, + }, +}); +``` + +- `id` is `plugin.entity`, lowercase and dot separated. The part after the first dot (`category`) names the translations and the permissions. +- `tableName` is the Postgres table, in snake_case. +- `admin.path` sets the AdminCP URL, `/admin/content/example/categories`. +- `list.columns` may name your fields and the system columns `id`, `createdAt` and `updatedAt`. `orderableFields` makes the **Name** header sortable. + +You get a few things without asking: the list searches its text columns, the first text field becomes the record's title, and create and edit open in a dialog. Every field helper is listed in [Fields](/docs/content-engine/fields). + + + + + +### Create the model + +Create `src/database/categories.ts`. `createContentModel` turns the definition into a Drizzle table, Zod schemas and a service: + +```ts title="plugins/example/src/database/categories.ts" +import { createContentModel } from "@vitnode/core/content/server"; + +import { categoryContentType } from "@/content/category"; + +export const categoryContent = createContentModel(categoryContentType); + +export const example_categories = categoryContent.table; +``` + +Export the table. Drizzle Kit finds tables by reading the exports of your plugin's built `dist/src/database/*.js` files, so a table nobody exports never reaches a migration. + + + + + +### Add the staff API routes + +Create an `admin` module and pass the model to `buildContentAdminModule`: + +```ts title="plugins/example/src/api/modules/admin/admin.module.ts" +import { buildModule } from "@vitnode/core/api/lib/module"; +import { buildContentAdminModule } from "@vitnode/core/content/server"; + +import { CONFIG_PLUGIN } from "@/const"; +import { categoryContent } from "@/database/categories"; + +export const adminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "admin", + routes: [], + modules: [ + buildContentAdminModule({ + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [categoryContent], + }), + ], +}); +``` + +Then add the module to your API plugin. This is an excerpt of `src/config.api.ts`: + +```ts title="plugins/example/src/config.api.ts" +import { adminModule } from "@/api/modules/admin/admin.module"; // [!code ++] + +export const exampleApiPlugin = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + modules: [adminModule], // [!code highlight] + }); +``` + +The routes live under `/api/@vitnode/example/admin/content/category` and only answer staff signed in to the AdminCP. Keep the module named `admin`, because the API checks the AdminCP session on paths that contain `/admin/`. + + + + + +### Add the AdminCP screen + +The AdminCP needs two things from your plugin: a sidebar link and the screen behind it. They live in separate files on purpose. The sidebar loads on every AdminCP page, while the screen's forms load only when someone opens it. + +Create `src/admin/nav.tsx` with the sidebar link: + +```tsx title="plugins/example/src/admin/nav.tsx" +import { FolderIcon } from "lucide-react"; + +import { CONFIG_PLUGIN } from "@/const"; +import { categoryContentType } from "@/content/category"; + +export const adminNav = { + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [{ definition: categoryContentType, icon: }], +}; +``` + +Create `src/admin/content.tsx` and reuse the same list for the screen: + +```tsx title="plugins/example/src/admin/content.tsx" +export { adminNav as adminContent } from "./nav"; +``` + +That one line is enough for the generated list and forms. Later, this is the file where you swap in [custom cells, fields or form layouts](/docs/content-engine/admincp). VitNode finds both files by name, so keep the paths and the `adminNav` and `adminContent` exports. + + + + + +### Name everything + +Labels come from your plugin's `src/locales/en.json`. Content type strings live under `content.category`, and each staff permission gets a top-level key: + +```json title="plugins/example/src/locales/en.json" +{ + "@vitnode/example": { + "title": "Example", + "content": { + "category": { + "label": "{count, plural, one {Category} other {Categories}}", + "title": "Categories", + "desc": "Group articles together.", + "fields": { + "name": "Name", + "createdAt": "Created" + } + } + } + }, + "@vitnode/example:category": "Categories", + "@vitnode/example:category:can_view": "View categories", + "@vitnode/example:category:can_create": "Create categories", + "@vitnode/example:category:can_edit": "Edit categories", + "@vitnode/example:category:can_delete": "Delete categories" +} +``` + +`label` is an ICU plural, used in buttons and messages such as **Create Category**. `title` and `desc` head the list, and the plugin's own `title` names the sidebar group. A missing key falls back to a readable version of the field name, and a missing permission label shows its raw id. + + + + + +### Build and migrate + +Run this from the workspace root: + + + +```bash tab="bun" +bun run build:plugins && bun run db:migrate +``` + +```bash tab="pnpm" +pnpm build:plugins && pnpm db:migrate +``` + +```bash tab="npm" +npm run build:plugins && npm run db:migrate +``` + + + +`db:migrate` reads the built plugin, so always build first. It writes a migration to `apps/api/migrations/` and applies it. This is the part of the example plugin's migration that creates categories: + +```sql title="apps/api/migrations/20260802212938_add_example_content/migration.sql (excerpt)" +CREATE TABLE "example_categories" ( + "id" serial PRIMARY KEY NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "name" varchar(100) NOT NULL +); +ALTER TABLE "example_categories" ENABLE ROW LEVEL SECURITY; +CREATE INDEX "example_categories_created_at_idx" ON "example_categories" USING btree ("createdAt"); +CREATE INDEX "example_categories_updated_at_idx" ON "example_categories" USING btree ("updatedAt"); +``` + +You declared one field. The engine added `id`, the two timestamps and their indexes. [Database](/docs/content-engine/database) lists everything it generates. + + + + + +### Restart the dev server + +Stop `pnpm dev` and start it again. The API registers routes when it starts, so the new staff routes exist only after a restart. + + + + + +### Create a few categories + +Open **AdminCP → Example → Categories** and click **Create Category**. The dialog is generated from your fields, so it has a single **Name** input with the 1 to 100 character limit from the definition. + + + +Click **Create**. A toast says "Category has been created." and the list refreshes. Add a couple more so there is something to sort. + + + + + +## Check the result + +The list shows your categories with the **Name** and **Created** columns from `list.columns`. Click **Name** to sort, or type in the search box to filter by name. + + + +Now open **AdminCP → Staff → Administrators**, edit an administrator, choose **Restricted** and select **Example**. The **Categories** group holds the four permissions the Content Engine generated from your definition, labelled by your locale keys. Create, edit and delete each require view. Every generated route checks them, so an administrator without **Delete categories** gets a `403` from the delete route and no delete button in the list. + + + +The `advanced_article`, `localized_article` and `page` groups next to it have no labels in the example plugin. That is what a missing permission key looks like. + +## Next + +Categories are staff-only. To publish content to visitors, add drafts and a read-only API in [Public API](/docs/content-engine/public-api). diff --git a/apps/web/content/docs/content-engine/database.mdx b/apps/web/content/docs/content-engine/database.mdx new file mode 100644 index 0000000000..778bac50cd --- /dev/null +++ b/apps/web/content/docs/content-engine/database.mdx @@ -0,0 +1,140 @@ +--- +title: Database +description: The Postgres tables, columns and indexes the VitNode Content Engine generates for a content type, and how to generate, apply and evolve their migrations. +icon: Database +--- + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + +Every content type becomes ordinary Postgres tables managed by Drizzle. `createContentModel` from `@vitnode/core/content/server` builds them from the definition, and the usual VitNode migration workflow creates them. Values are stored in typed columns. Only `field.blocks()` and `field.richText()` keep a JSON document, in a `jsonb` column. + +## Generate and apply a migration + +After you add or change a content type, run this from the workspace root: + + + +```bash tab="bun" +bun run build:plugins && bun run db:migrate +``` + +```bash tab="pnpm" +pnpm build:plugins && pnpm db:migrate +``` + +```bash tab="npm" +npm run build:plugins && npm run db:migrate +``` + + + +1. `build:plugins` compiles each plugin to `dist/`. `db:migrate` reads that output and does not build, so skipping this step migrates the old schema. +2. `db:migrate` compares the exported tables of every plugin in your API config with the last snapshot and writes a new folder to `apps/api/migrations/`. +3. It applies pending migrations, then makes sure the initial data exists. + +Commit the generated migration together with the content type. Production never migrates on startup, so run migrations as a deploy step. [Database CLI](/docs/dev/cli/database) covers `vitnode db generate --name ` for a named migration, `vitnode db status` and the rest. + +## Export every table + +Drizzle Kit finds tables by reading the exports of `node_modules//dist/src/database/*.js` for each plugin. A table without an export is left out of the migration, without a warning. A content type with relations, translations and repeatable rows has several tables: + +```ts title="plugins/example/src/database/advanced-articles.ts" +import { createContentModel } from "@vitnode/core/content/server"; + +import { advancedArticleContentType } from "@/content/advanced-article"; + +import { example_categories } from "./categories"; + +export const advancedArticleContent = createContentModel( + advancedArticleContentType, + { + references: { + categories: () => example_categories.id, + }, + }, +); + +export const example_advanced_articles = advancedArticleContent.table; +export const example_advanced_articles_translations = + advancedArticleContent.translationTable; +export const example_advanced_articles_categories = + advancedArticleContent.advancedTables.junctions.categories; +export const example_advanced_articles_related_articles = + advancedArticleContent.advancedTables.junctions.relatedArticles; +export const example_advanced_articles_faq = + advancedArticleContent.advancedTables.repeatables.faq; +``` + +| Model member | Exists when | +| ------------------------------------ | --------------------------------------------------------- | +| `table` | Always | +| `translationTable` | `localization` is enabled; otherwise `null` | +| `advancedTables.junctions.` | A `relation`, `user` or `file` field has `multiple: true` | +| `advancedTables.repeatables.` | The content type has a `field.repeatable()` | + +`references` maps each `relation` field to the column it points at. `user` and `file` fields need no entry, because the engine knows `core_users` and `core_files`. See [Relations](/docs/content-engine/relations). + +## Generated columns + +You never declare these. A field with the same name throws an error. + +| Column | Type | Added when | +| ------------- | ----------------------------------------------------------- | ------------- | +| `id` | `serial` primary key | Always | +| `createdAt` | `timestamp`, defaults to `now()` | Always | +| `updatedAt` | `timestamp`, defaults to `now()`, refreshed by every update | Always | +| `status` | `varchar(32)`, `draft` or `published`, defaults to `draft` | `publication` | +| `publishedAt` | `timestamp`, nullable | `publication` | +| `version` | `integer`, defaults to `1` | `editorial` | + +Column names are camelCase in SQL, exactly as in TypeScript. Every generated table has row-level security enabled. A field group adds one column per leaf to the main table, so the `syndication` group's `indexable` field becomes `syndicationIndexable`. Column types per field are listed in [Fields](/docs/content-engine/fields). + +### Translations table + +With `localization`, `{tableName}_translations` holds the localized fields: + +| Column | Notes | +| ------------------------ | ------------------------------------------------------------------------- | +| `itemId` | References the main table. Deleting the record deletes its translations | +| `languageId` | References `core_languages.id`. A language with content cannot be deleted | +| `version` | Per-language version, always present | +| `createdAt`, `updatedAt` | Same as the main table | +| `status`, `publishedAt` | With `publication`. Each language is published on its own | + +The primary key is `(itemId, languageId)`, so a record has at most one translation per language. + +### Junction and child tables + +Field names become snake_case in table names, so `relatedArticles` on `example_advanced_articles` becomes `example_advanced_articles_related_articles`. + +- A **to-many field** gets `{tableName}_{field}` with `itemId`, `relatedItemId`, `position` and `createdAt`. The primary key is `(itemId, relatedItemId)`, so a record links to each target once. +- A **repeatable field** gets `{tableName}_{field}` with `id`, `itemId`, `position`, `createdAt`, `updatedAt` and one column per subfield. + +Deleting a record deletes its junction and repeatable rows. Both kinds keep `position` unique per record. That column holds the order an editor picks in the AdminCP. + +## Generated indexes + +The engine creates these indexes without being asked: + +- A unique index on every slug, and on every `field.text({ unique: true })`. +- An index on every single `relation`, `user` and `file` column. +- Indexes on `createdAt` and `updatedAt`. +- `(status, publishedAt)` with `publication`. +- On a translations table: `(languageId, status)` with `publication` or `(languageId)` without it, plus a unique `(languageId, slug)` for each localized slug. +- On a junction table: `relatedItemId`, and a unique `(itemId, position)`. On a repeatable table: a unique `(itemId, position)`. + +Generated names follow `{table}_{columns}_idx`, or `_key` for unique ones, such as `example_categories_created_at_idx`. Add your own with the [`indexes`](/docs/content-engine/reference) option. An index on the same columns as a generated one replaces it instead of adding a second one: + +```ts title="plugins/example/src/content/article.ts" +indexes: [{ on: ["status", "createdAt"] }], +``` + +## Change a content type later + +Changing a definition is a schema change like any other. Build, migrate and read the generated SQL before you commit it. + +- **Adding a nullable field, or one with a `defaultValue`,** is safe on a table that already has rows. +- **Adding a required field without a default** fails on a table with rows. Add it as nullable, fill it, then make it required in a second migration. +- **Renaming a field** renames its column. Check that the generated SQL says `RENAME COLUMN`, not a drop followed by an add, or the old values are lost. +- **Turning on `localization`** moves localized fields to a new translations table. The generated SQL does not copy existing values, so add the data move to the migration yourself. +- **Never edit or regenerate a migration that has already been applied.** Write a new one instead. diff --git a/apps/web/content/docs/content-engine/editorial-history.webp b/apps/web/content/docs/content-engine/editorial-history.webp new file mode 100644 index 0000000000..3c0fe13cde Binary files /dev/null and b/apps/web/content/docs/content-engine/editorial-history.webp differ diff --git a/apps/web/content/docs/content-engine/editorial-preview-link.webp b/apps/web/content/docs/content-engine/editorial-preview-link.webp new file mode 100644 index 0000000000..bc90b1662e Binary files /dev/null and b/apps/web/content/docs/content-engine/editorial-preview-link.webp differ diff --git a/apps/web/content/docs/content-engine/editorial-schedule.webp b/apps/web/content/docs/content-engine/editorial-schedule.webp new file mode 100644 index 0000000000..4c12a1d087 Binary files /dev/null and b/apps/web/content/docs/content-engine/editorial-schedule.webp differ diff --git a/apps/web/content/docs/content-engine/editorial.mdx b/apps/web/content/docs/content-engine/editorial.mdx new file mode 100644 index 0000000000..d4c96eeb2a --- /dev/null +++ b/apps/web/content/docs/content-engine/editorial.mdx @@ -0,0 +1,150 @@ +--- +title: Editorial +description: Add revision history with restore, version checks against overwrites, signed draft preview links and scheduled publishing to a Content Engine content type. +icon: Clock +--- + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; +import { ImgDocs } from "@/components/fumadocs/img"; +import history from "./editorial-history.webp"; +import previewLink from "./editorial-preview-link.webp"; +import schedule from "./editorial-schedule.webp"; + +The `editorial` block gives a content type the tools an editorial team expects: a history of every save with restore, protection against two people overwriting each other, signed links that show a draft to someone without an AdminCP account, and publishing at a chosen time. It is also what [live editing](/docs/content-engine/live-editing) builds on, which you turn on separately with `liveEditing: true`. + +This page uses `example.article` from the example plugin. It already has [drafts and publishing](/docs/content-engine/public-api) and a public API, which previews and scheduling build on. + +## Turn on editorial features + +Add `editorial` to the definition: + +```ts title="plugins/example/src/content/article.ts" +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + publication: true, + publicApi: { + path: "articles", + fields: ["title", "slug", "excerpt"], + }, + // [!code ++:5] + editorial: { + revisions: { retention: 20 }, + preview: { expiresInMinutes: 30 }, + scheduling: true, + }, + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + }, +}); +``` + +Then rebuild the plugin and migrate: + + + +```bash tab="bun" +bun run build:plugins && bun run db:migrate +``` + +```bash tab="pnpm" +pnpm build:plugins && pnpm db:migrate +``` + +```bash tab="npm" +npm run build:plugins && npm run db:migrate +``` + + + +The migration adds a `version` column to `example_articles`. Revisions, schedules, drafts and locks live in shared core tables, so nothing else lands in your schema. Editorial also adds a `can_restore` staff permission, labelled by the `@vitnode/example:article:can_restore` key in your plugin's locale file. + +`preview` needs `publicApi` and `scheduling` needs `publication`. Leaving out the partner is a type error, and the definition throws when it loads. Every option and default is in the [content type reference](/docs/content-engine/reference#editorial). + +## Revisions + +Every create, update, publish, unpublish and restore stores a snapshot in `core_content_revisions`. `revisions.retention` keeps the newest snapshots per record, 50 by default and at most 500. + +To see them, open the row's **More actions** menu in the AdminCP list and choose **History**. Each version shows who saved it and when, and **Show changes** lists the fields it changed: + + + +**Restore** puts that version's field values back as a new version, so nothing in between is lost and the publication status stays as it is. It needs `can_restore` and emits `content.example.article.restored`. Anyone with the record open in [live editing](/docs/content-engine/live-editing) gets a notice and a reloaded form. + +## Safe concurrent edits + +Each editorial write raises the record's `version` by one and succeeds only when the version still matches the one the editor loaded. If someone saved in between, the second save fails instead of quietly replacing their work: + +```json title="PUT /api/@vitnode/example/admin/content/article/2 → 409" +{ + "code": "CONTENT_VERSION_CONFLICT", + "contentTypeId": "example.article", + "itemId": 2, + "expectedVersion": 2, + "currentVersion": 3 +} +``` + +The AdminCP sends the version for you. Your own calls to the edit routes must send it too: `PUT /{id}` takes `{ "expectedVersion": 3, "values": { "title": "…" } }` and `DELETE /{id}` takes `{ "expectedVersion": 3 }`. In server code, `articleContent.editorialService` does the same check; see the [Feature this article example](/docs/content-engine/services#example-3-a-feature-this-article-button). A translation has its own version and fails with `CONTENT_TRANSLATION_VERSION_CONFLICT`. + +## Preview links + +A preview link lets a reviewer read a draft without signing in. In the AdminCP, open **More actions** and choose **Preview**. Creating a link needs `can_view`. + + + +The link is HMAC-signed, expires after `expiresInMinutes` (15 by default, 1 to 1440) and cannot be revoked early, so treat it like a password. It is pinned to the last saved version, so it shows what **Save** committed, not unsaved [live editing](/docs/content-engine/live-editing) drafts. + +The link points to the first of these that exists: + +1. `preview.pathTemplate`, such as `/preview/articles/{token}`, with exactly one `{token}`. +2. The record's own page with `?preview=`, when the type has [delivery](/docs/content-engine/public-pages). +3. The public API route `GET /api/@vitnode/example/content/articles/preview/{token}`. + +Every page still has to load the draft from that API route. A page built from [Show content on a public page](/docs/content-engine/public-pages) does not read `?preview=` on its own, so the example's link opens a 404 until you check for it in your loader: + +```ts title="Read a preview in a page loader" +const preview = await fetcher({ + plugin: "@vitnode/example", + method: "get", + module: "content/articles", + path: "/preview/{token}", + args: { params: { token: encodeURIComponent(token) } }, +}); +``` + +The route returns the record's public fields, and the same `404` for a forged, expired or unknown token. It sends `Cache-Control: private, no-store` and `X-Robots-Tag: noindex, nofollow`, so the draft stays out of caches and search results. + +Links are built from `VITNODE_WEB_URL` and `VITNODE_API_URL`. When either is not an absolute URL, the API logs a warning at startup and the **Preview** button answers `503`. + +## Schedule publishing + +Scheduling publishes or unpublishes a record at a chosen time. Open **More actions**, choose **Schedule**, pick **Publish** or **Unpublish** and a time, then press **Schedule it**. Booking or cancelling a schedule needs `can_publish`. + + + +A schedule is a queued task, drained by the `process-queue` [cron job](/docs/dev/cron) once a minute. If nothing triggers your cron endpoint, schedules are saved but never fire. When the install has no cron adapter at all, the dialog says so with a **No scheduler is running** warning. When a schedule fires, it publishes whatever the record says at that moment, not what it said when you booked it. + +A few rules keep schedules sane: + +- A time up to two minutes in the past is accepted. Anything older fails with `CONTENT_SCHEDULE_IN_PAST`. +- An unpublish cannot come before a pending publish (`CONTENT_SCHEDULE_ORDER`). +- Booking emits `content.example.article.scheduled` and cancelling emits `schedule_cancelled`. When the time comes, the usual `published` or `unpublished` event carries the `scheduleId`. + +If a separate front end caches pages, [Notify a separate front end](/docs/content-engine/caching#notify-a-separate-front-end) tells it about the change. diff --git a/apps/web/content/docs/content-engine/events.mdx b/apps/web/content/docs/content-engine/events.mdx new file mode 100644 index 0000000000..e2100951a4 --- /dev/null +++ b/apps/web/content/docs/content-engine/events.mdx @@ -0,0 +1,123 @@ +--- +title: Events +description: Every event a Content Engine content type emits, its payload and the feature that enables it, and how to subscribe with a typed event listener. +icon: Radio +--- + +Each content type emits events named `content..` after a change, such as `content.example.article.published`. Subscribe to them to send a newsletter, sync an external index or purge a CDN, without touching the code that made the change. + +Events come from the generated routes, scheduled publishing and [`contentEditorialEffects`](/docs/content-engine/services#example-3-a-feature-this-article-button). The plain `service(c)` emits nothing. A save that changes nothing emits nothing either. + +## Event reference + +Which events exist depends on what the definition enables. `example.category` declares only fields and emits three; `example.article` has publication, editorial with scheduling and delivery, and emits ten. + +| Action | Payload | Needs | +| --------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ | +| `created` | `{ contentId }` | always | +| `updated` | `{ contentId, changedFields }` | always | +| `deleted` | `{ contentId }` | always | +| `published` | `{ contentId, publishedAt, scheduledBy?, scheduleId? }` | `publication` | +| `unpublished` | `{ contentId, scheduledBy?, scheduleId? }` | `publication` | +| `restored` | `{ contentId, changedFields, version, revisionId, restoredFromRevisionId }` | `editorial` | +| `scheduled` | `{ contentId, action, actorUserId, scheduledFor, scheduleId }` | `editorial.scheduling` | +| `schedule_cancelled` | `{ contentId, action, actorUserId, scheduleId }` | `editorial.scheduling` | +| `translation_created` | `{ contentId, locale, languageId, version, revisionId? }` | `localization` | +| `translation_updated` | `{ contentId, locale, languageId, version, changedFields, revisionId? }` | `localization` | +| `translation_deleted` | `{ contentId, locale, languageId, version, revisionId? }` | `localization` | +| `translation_published` | `{ contentId, locale, languageId, version, publishedAt, revisionId? }` | `localization` + `publication` | +| `translation_unpublished` | `{ contentId, locale, languageId, version, revisionId? }` | `localization` + `publication` | +| `translation_restored` | `{ contentId, locale, languageId, version, changedFields, revisionId, restoredFromRevisionId }` | `localization` + `editorial` | +| `delivery_slug_changed` | `{ contentId, slug, previousSlug, previousPath, canonicalPath, locale }` | `delivery` | +| `delivery_redirect_created` | `{ contentId, previousSlug, previousPath, canonicalPath, locale }` | `delivery` | + +A few payload details that change what a listener does: + +- `changedFields` lists only the fields whose value actually moved, typed as the content type's own field names. On translation events it names localized fields only. +- `restored` replaces `updated` for a rollback, so a listener that cares about field changes should handle both. +- `publishedAt` is when the record first went live. It is never rewritten, and it can be missing, so guard for `undefined`. +- `scheduledBy` and `scheduleId` are set only when a schedule fired the change. `actorUserId` on `scheduled` is the same person as `scheduledBy` later. +- The delivery events arrive alongside `updated`, `restored` or a publication event, never instead of them. `locale` is `null` for a shared slug. + +The listener also receives an envelope with the actor, the time and `pluginId`. `pluginId` is always the plugin that owns the content type, even when a core queue task ran the change. Field-by-field descriptions are in [Built-in events](/docs/dev/events/built-in-events#content-engine-events). + +## Subscribe to an event + + + + +### Register the event types + +Add the content type to the global event map once per plugin. The listener's event name and payload are then type-checked, and an event the definition does not enable, such as `content.example.category.published`, does not compile. + +```ts title="plugins/example/src/api/lib/events.ts" +import type { ContentEventsFor } from "@vitnode/core/content"; + +import type { articleContentType } from "@/content/article"; +import type { categoryContentType } from "@/content/category"; + +declare module "@vitnode/core/api/models/events" { + interface VitNodeEvents + extends + ContentEventsFor, + ContentEventsFor {} +} +``` + +The example plugin imports this file from `config.api.ts` with `import "@/api/lib/events";`, so the declaration travels with the API build. + + + + +### Write the listener + +`contentEventName(id, action)` builds the name from the definition, so a renamed content type cannot leave a stale string behind: + +```ts title="plugins/example/src/api/lib/listeners.ts" +import { buildEventListener } from "@vitnode/core/api/lib/events"; +import { contentEventName } from "@vitnode/core/content"; + +import { articleContentType } from "@/content/article"; + +export const announceArticleListener = buildEventListener({ + event: contentEventName(articleContentType.id, "published"), + name: "announce-published-article", + description: "Logs every article that goes live", + handler: async (c, payload) => { + await c.get("log").debug(`Article ${payload.contentId} was published`); + }, +}); +``` + + + + +### Add it to a top-level module + +VitNode collects listeners only from the `events` array of a module passed directly to `buildApiPlugin`. A listener on a nested module is silently ignored. + +```ts title="plugins/example/src/api/modules/admin/admin.module.ts" +export const adminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "admin", + routes: [], + modules: [ + buildContentAdminModule({ + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [articleContent], + }), + ], + events: [announceArticleListener], // [!code ++] +}); +``` + +The blog plugin does the same with its `blogLegacyEventListeners`, which turn `content.blog.post.created` into the older `blog.post.created` event. + + + + +## Check the result + +Restart `pnpm dev`, publish an article in **AdminCP → Example → Articles** and look for the debug line in the API terminal. A failing listener does not undo the change. The write has already committed, and the engine logs the failure. + +Events from a scheduled publish run in a queue task that retries on failure, so the same `published` event can arrive twice. Make work that must happen once idempotent, for example keyed on `scheduleId`. See [A scheduled event may arrive twice](/docs/dev/events/built-in-events#a-scheduled-event-may-arrive-twice). diff --git a/apps/web/content/docs/content-engine/fields-article-form-basic.webp b/apps/web/content/docs/content-engine/fields-article-form-basic.webp new file mode 100644 index 0000000000..ae0e9fd875 Binary files /dev/null and b/apps/web/content/docs/content-engine/fields-article-form-basic.webp differ diff --git a/apps/web/content/docs/content-engine/fields-article-form-references.webp b/apps/web/content/docs/content-engine/fields-article-form-references.webp new file mode 100644 index 0000000000..c7559da0d9 Binary files /dev/null and b/apps/web/content/docs/content-engine/fields-article-form-references.webp differ diff --git a/apps/web/content/docs/content-engine/fields.mdx b/apps/web/content/docs/content-engine/fields.mdx new file mode 100644 index 0000000000..3b122e8ec7 --- /dev/null +++ b/apps/web/content/docs/content-engine/fields.mdx @@ -0,0 +1,314 @@ +--- +title: Fields +description: Reference for every Content Engine field helper, such as field.text, field.relation and field.blocks, with its options, defaults, Postgres column and AdminCP form control. +icon: LayoutGrid +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import formBasic from "./fields-article-form-basic.webp"; +import formReferences from "./fields-article-form-references.webp"; + +A field is one property of a content type, such as a title or a view count. You build each one with a helper from `field`, exported by `@vitnode/core/content`. The helper decides three things at once: the Postgres column, the validation of every write and the control in the AdminCP form. + +```ts title="plugins/example/src/content/article.ts" +import { defineContentType, field } from "@vitnode/core/content"; + +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + views: field.number({ integer: true, min: 0, defaultValue: 0 }), + featured: field.boolean({ defaultValue: false }), + author: field.user(), + }, +}); +``` + +The example plugin's article form shows most controls. The first half holds the plain values: + + + +The second half holds the references: a user picker, two file dropzones and a relation picker. + + + +## Field helpers + +| Helper | Postgres column | AdminCP control | Can be `localized` | +| -------------------- | -------------------------------------------- | ---------------------------------------- | ------------------ | +| `field.text()` | `varchar(maxLength)`, 255 by default | Text input | Yes | +| `field.textarea()` | `text` | Textarea | Yes | +| `field.richText()` | `jsonb` | Rich text editor | Yes | +| `field.number()` | `integer` or `double precision` | Number input with steppers | No | +| `field.boolean()` | `boolean` | Switch | No | +| `field.enum()` | `varchar(length)`, 64 by default | Select or radio buttons | No | +| `field.dateTime()` | `timestamp` | Date and time picker | No | +| `field.slug()` | `varchar(maxLength)`, 160 by default, unique | Text input | Yes | +| `field.user()` | `integer` referencing `core_users.id` | User picker | No | +| `field.file()` | `integer` referencing `core_files.id` | File dropzone | No | +| `field.relation()` | `integer` referencing the target table | Search picker | No | +| `field.group()` | One column per leaf | A fieldset with the leaf controls | Yes, as a whole | +| `field.repeatable()` | A child table | A list of rows you can add, move, remove | No | +| `field.blocks()` | `jsonb`, `NOT NULL DEFAULT '[]'` | Not in the form; edited as widgets | Yes | + +`user`, `file` and `relation` with `multiple: true` store their values in a junction table instead of a column. Localized fields live in the translation table. See [Translations](/docs/content-engine/localization). + +## Shared options + +Every helper except `slug`, `repeatable` and `blocks` accepts these: + +| Name | Type | Required | Default | Description | +| ------------- | --------- | -------- | ------- | ---------------------------------------------------------- | +| `required` | `boolean` | No | `false` | A create request must include the field | +| `nullable` | `boolean` | No | `false` | The column accepts `NULL`, and so does validation | +| `description` | `string` | No | | Text or an i18n key shown under the control in the AdminCP | + +A field that is neither required nor nullable needs a fallback, or `defineContentType` throws `Field "x" is neither required nor nullable, so it needs a default value`. A `defaultValue`, `defaultNow: true` on a date and `source` on a slug all count as a fallback. On update every field is optional. + +`required: true` on a text field still accepts an empty string. Add `minLength: 1` when you need actual text. + +## Text and textarea + +```ts +code: field.text({ required: true, maxLength: 100, unique: true }), +excerpt: field.textarea({ maxLength: 500, nullable: true }), +``` + +| Name | Type | Required | Default | Description | +| -------------- | --------- | -------- | -------------------- | ------------------------------------------------------------------------ | +| `minLength` | `number` | No | | Shortest accepted value, in characters | +| `maxLength` | `number` | No | 255 column on `text` | Longest accepted value. On `text` it is also the `varchar` length | +| `defaultValue` | `string` | No | | Written when a create leaves the field out. Becomes the column `DEFAULT` | +| `unique` | `boolean` | No | `false` | `text` only. Adds a unique index. Has no effect on a localized field | +| `localized` | `boolean` | No | `false` | One value per language | +| `ai` | object | No | | Adds an AI suggestion button, see [AI suggestions](#ai-suggestions) | + +Set `maxLength` on every `text` field. Without it, validation accepts any length, and Postgres rejects anything over 255 characters with a database error instead of a validation message. A `defaultValue` must satisfy the field's own `minLength` and `maxLength`. + +## Rich text + +```ts +content: field.richText({ localized: true, required: true }), +``` + +| Name | Type | Required | Default | Description | +| ----------- | --------- | -------- | ------- | ------------------------------------------------------- | +| `maxBytes` | `number` | No | 1 MB | Largest document, measured as UTF-8 JSON. At most 16 MB | +| `localized` | `boolean` | No | `false` | One document per language | + +The value is a ProseMirror JSON document. A rich text field has no `defaultValue`, so set `required` or `nullable`. With `required: true` a document without any text or media is refused. Documents nested deeper than 64 levels are refused too. [Rich text](/docs/content-engine/rich-text) covers rendering and the plain text and HTML helpers. + +## Number + +```ts +views: field.number({ integer: true, min: 0, defaultValue: 0 }), +``` + +| Name | Type | Required | Default | Description | +| -------------- | --------- | -------- | ------- | ------------------------------------------------------------------------- | +| `integer` | `boolean` | Yes | | `true` makes an `integer` column, `false` a `double precision` one | +| `min`, `max` | `number` | No | | Accepted range. With `integer: true` a whole number must fit between them | +| `defaultValue` | `number` | No | | Must sit inside the range, and be whole when `integer: true` | + +## Boolean + +```ts +featured: field.boolean({ defaultValue: false }), +``` + +The only option besides the shared ones is `defaultValue`. A nullable boolean, such as `noIndex: field.boolean({ nullable: true })`, can hold `true`, `false` or `null`. + +## Enum + +```ts +difficulty: field.enum({ values: ["easy", "medium", "hard"], defaultValue: "easy", display: "radio" }), +``` + +| Name | Type | Required | Default | Description | +| -------------- | ----------------------- | -------- | ---------- | -------------------------------------- | +| `values` | `string[]` | Yes | | Non-empty, without duplicates | +| `defaultValue` | one of `values` | No | | | +| `display` | `"select"` \| `"radio"` | No | `"select"` | AdminCP control | +| `length` | `number` | No | `64` | `varchar` length. Every value must fit | + +The column is a plain `varchar`, not a Postgres enum, so adding a value later needs no type migration. The AdminCP translates each value from `{pluginId}.content.{entity}.enums.{field}.{value}` when that key exists. + +## Date and time + +```ts +eventDate: field.dateTime({ defaultNow: true }), +``` + +| Name | Type | Required | Default | Description | +| ------------ | --------- | -------- | ------- | ------------------------------------------------ | +| `defaultNow` | `boolean` | No | `false` | Fills the column with the current time on insert | + +There is no `defaultValue`. Send dates as ISO 8601 strings. + +## Slug + +```ts +slug: field.slug({ source: "title" }), +``` + +| Name | Type | Required | Default | Description | +| ------------- | -------------------- | -------- | ------- | ---------------------------------------------------------- | +| `source` | name of a text field | No | | Fills the slug from that field when a create sends no slug | +| `maxLength` | `number` | No | `160` | `varchar` length and the point where long slugs are cut | +| `localized` | `boolean` | No | `false` | One slug per language | +| `description` | `string` | No | | Shown under the input | + +A slug has no `required` or `nullable` option. It is required when there is no `source`, and it is never empty. Every value is normalized, so `"Weekend Pancakes!"` becomes `weekend-pancakes`. Slugs are unique across the table, or within each language when localized. The slug is derived only on create, so renaming the title later keeps the old URL. `source` must name a `field.text()`, and a localized slug needs a localized source. + +## User + +```ts +author: field.user(), +authorId: field.user({ multiple: true, ordered: true, min: 1 }), +``` + +| Name | Type | Required | Default | Description | +| ---------- | ------------------------------------------- | -------- | --------- | ------------------------------------------ | +| `multiple` | `boolean` | No | `false` | Several people, stored in a junction table | +| `ordered` | `boolean` | No | `false` | Keeps the order editors arrange | +| `min` | `number` | No | | Fewest people. Needs `multiple` | +| `onDelete` | `"cascade"` \| `"restrict"` \| `"set null"` | No | see below | What deleting the user does | + +A single user field is nullable unless you set `required: true`. `onDelete` defaults to `"set null"` for a nullable single user, `"restrict"` for a required one and `"cascade"` for `multiple`. User fields cannot be listed in `publicApi.fields`. + +## File + +```ts +animation: field.file({ + maxBytes: 10 * 1024 * 1024, + allowedExtensions: [".gif"], + allowedMimeTypes: ["image/gif"], +}), +gallery: field.file({ multiple: true, min: 1, max: 8, maxBytes: 5 * 1024 * 1024 }), +``` + +| Name | Type | Required | Default | Description | +| ------------------- | ---------- | -------- | ------------------ | ------------------------------------------ | +| `maxBytes` | `number` | Yes | | Largest accepted file, in bytes | +| `allowedExtensions` | `string[]` | No | any | Lowercased, with a leading dot added | +| `allowedMimeTypes` | `string[]` | No | any | Lowercased | +| `multiple` | `boolean` | No | `false` | Several files in a junction table | +| `min` | `number` | No | | Fewest files, at least 1. Needs `multiple` | +| `max` | `number` | No | `20` | Most files, up to 200. Needs `multiple` | +| `ordered` | `boolean` | No | same as `multiple` | Keeps the order editors arrange | + +A single file field is nullable unless you set `required: true`, and it cannot have a default or be localized. Translate a caption or alt text in a separate localized text field instead. Uploads go through the [storage](/docs/dev/storage) adapter and land in `core_files`. A file in use cannot be deleted, because the reference is `ON DELETE RESTRICT`. + +The public API returns a file as `{ id, name, url, mimeType, size, width, height }`, never as a raw id. A write may only point at a file uploaded for this content type, uploaded by the signed-in user, or already on the record. The API refuses anything else with `CONTENT_FILE_NOT_FOUND`. + +## Relation + +```ts +category: field.relation({ required: true, onDelete: "restrict", target: () => categoryContentType }), +``` + +| Name | Type | Required | Default | Description | +| ---------- | ------------------------------------------- | ------------------ | ------------ | ---------------------------------------------------------------------------- | +| `target` | `() => ContentTypeDefinition` | Yes, unless `self` | | The linked content type | +| `self` | `true` | No | `false` | Links to the same content type. Do not combine with `target` | +| `multiple` | `boolean` | No | `false` | Uses a junction table, up to 500 targets per record | +| `ordered` | `boolean` | No | `false` | Keeps the editor's order. Needs `multiple` | +| `min` | `number` | No | | Fewest targets, 1 to 500. Needs `multiple` | +| `onDelete` | `"restrict"` \| `"cascade"` \| `"set null"` | No | `"restrict"` | What deleting the target does. `"set null"` needs a nullable single relation | + +A single relation must be `required` or `nullable`. A to-many relation can be neither. [Relations](/docs/content-engine/relations) shows the model wiring and the junction tables. + +## Group + +```ts +syndication: field.group({ + fields: { + noIndex: field.boolean({ defaultValue: false }), + priority: field.number({ integer: true, min: 0, max: 10, defaultValue: 5 }), + }, +}), +``` + +| Name | Type | Required | Default | Description | +| ----------- | --------------------- | -------- | ------- | -------------------------------------------------------------------- | +| `fields` | object of leaf fields | Yes | | `text`, `textarea`, `number`, `boolean`, `enum` or `dateTime` leaves | +| `localized` | `boolean` | No | `false` | Moves every leaf to the translation table together | + +Each leaf becomes a column named ``, such as `syndicationPriority`. Leaves cannot set `localized` themselves. An optional group needs leaves that are nullable or have a default. A nullable group needs nullable leaves. + +## Repeatable + +```ts +faq: field.repeatable({ + max: 20, + fields: { + question: field.text({ required: true, minLength: 3, maxLength: 200 }), + answer: field.textarea({ required: true }), + }, +}), +``` + +| Name | Type | Required | Default | Description | +| ------------- | --------------------- | -------- | ------- | -------------------------- | +| `fields` | object of leaf fields | Yes | | Same leaf kinds as a group | +| `min` | `number` | No | `0` | Fewest rows | +| `max` | `number` | No | `100` | Most rows, up to 1000 | +| `description` | `string` | No | | Shown in the AdminCP form | + +Rows live in a child table. A repeatable is always shared between languages. + +## Blocks + +```ts +content: field.blocks({ allowed: ["core:*", "example:callout", "example:features"], max: 50 }), +``` + +| Name | Type | Required | Default | Description | +| ------------- | ------------------- | -------- | ------- | ---------------------------------------------- | +| `allowed` | `"*"` or `string[]` | No | `"*"` | Which blocks may be placed, such as `"core:*"` | +| `min` | `number` | No | | Fewest block instances | +| `max` | `number` | No | `200` | Most block instances, up to 1000 | +| `localized` | `boolean` | No | `false` | Each language gets its own blocks | +| `description` | `string` | No | | | + +`field.widgets()` is the same helper under another name. A blocks field is never required or nullable. No blocks means an empty list. It does not appear in the AdminCP form. Editors place widgets on the page itself, as described in [The blocks() field](/docs/dev/widgets/blocks-field). + +## AI suggestions + +`text` and `textarea` fields accept an `ai` option. The form then shows an **AI** button next to the label, like the excerpt in the screenshot above. + +```ts +excerpt: field.textarea({ + ai: { + action: "@vitnode/example:article.excerpt", + mode: "suggestion", + sourceFields: ["title", "code"], + }, + maxLength: 500, + nullable: true, +}), +``` + +| Name | Type | Required | Default | Description | +| -------------- | ------------------------- | -------- | ------- | ---------------------------------------------------------------------- | +| `action` | `":"` | Yes | | A registered AI action | +| `mode` | `"suggestion"` | Yes | | The only supported mode. The editor reviews the text before it is used | +| `sourceFields` | `string[]` | Yes | | Other fields of this content type sent to the action as input | + +[AI in fields and the editor](/docs/dev/ai/fields-and-editor) shows how to write the action. + +## Searching and sorting + +Fields have no `searchable` or `sortable` option. Each surface picks its own fields: `admin.list.searchableFields` and `orderableFields` for the AdminCP list, `publicApi.searchableFields`, `orderableFields` and `filterableFields` for the public API, and `search.contentFields` for site search. The [content type reference](/docs/content-engine/reference) lists which field kinds each one accepts. diff --git a/apps/web/content/docs/content-engine/index-articles-list.webp b/apps/web/content/docs/content-engine/index-articles-list.webp new file mode 100644 index 0000000000..e913bb33fd Binary files /dev/null and b/apps/web/content/docs/content-engine/index-articles-list.webp differ diff --git a/apps/web/content/docs/content-engine/index.mdx b/apps/web/content/docs/content-engine/index.mdx new file mode 100644 index 0000000000..eff126206e --- /dev/null +++ b/apps/web/content/docs/content-engine/index.mdx @@ -0,0 +1,85 @@ +--- +title: Introduction +description: Declare a content type once in TypeScript and the VitNode Content Engine generates its Postgres table, validation, staff API, permissions and AdminCP screens. +icon: Boxes +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import articlesList from "./index-articles-list.webp"; + +The Content Engine turns one TypeScript definition into structured content for your plugin. You describe the fields of an article or a category, and VitNode generates the database table, Zod validation, staff-only API routes, staff permissions and AdminCP screens. Optional blocks add drafts, a public API, pages with SEO metadata, revisions, scheduling, live editing, translations and search. + +This is the whole definition behind the example plugin's **Categories** screen: + +```ts title="plugins/example/src/content/category.ts" +import { defineContentType, field } from "@vitnode/core/content"; + +export const categoryContentType = defineContentType({ + id: "example.category", + tableName: "example_categories", + fields: { + name: field.text({ required: true, minLength: 1, maxLength: 100 }), + }, + admin: { + path: "example/categories", + list: { + columns: ["name", "createdAt"], + orderableFields: ["name"], + }, + }, +}); +``` + +Its sibling `example.article` turns on more blocks. Same engine, more fields, a busier list: + + + +## What you get + +| From the definition | VitNode generates | +| ------------------- | -------------------------------------------------------------------------------------- | +| `fields` | A Postgres table with system columns and indexes, Zod schemas and a typed service | +| `admin` | An AdminCP list with search, sorting and bulk actions, plus create and edit forms | +| Every content type | Staff routes and the `can_view`, `can_create`, `can_edit` and `can_delete` permissions | +| `publication` | Draft and published states, a `can_publish` permission and **Publish** buttons | +| `publicApi` | Read-only public routes that return only the fields you list | +| `delivery` | Page URLs, SEO metadata, slug redirects and sitemap entries | +| `editorial` | Revisions with restore, preview links and scheduling | +| `liveEditing` | Field locks, an autosaved shared draft and presence in the AdminCP edit form | +| `localization` | A translations table and per-language fields and publishing | +| `search` | Records in the site [search](/docs/content-engine/search) index | + +Content types live in code and in your migrations, not in the database. There is no screen for creating a content type at runtime. + +## Pick a guide + +| You want to | Guide | +| -------------------------------------------------------- | ---------------------------------------------------------------- | +| Build your first content type, with its AdminCP screen | [First content type](/docs/content-engine/create-a-content-type) | +| Add drafts and a read-only public API | [Public API](/docs/content-engine/public-api) | +| Understand how public reads are cached and refreshed | [Caching](/docs/content-engine/caching) | +| Give each record its own page with SEO metadata | [Public pages](/docs/content-engine/public-pages) | +| Control titles, descriptions, noindex and the sitemap | [SEO](/docs/content-engine/seo) | +| Make records findable in the site search | [Search](/docs/content-engine/search) | +| Link records, group fields or repeat rows | [Relations](/docs/content-engine/relations) | +| Translate content into several languages | [Translations](/docs/content-engine/localization) | +| Store and render formatted text | [Rich text](/docs/content-engine/rich-text) | +| Keep revisions, share previews or schedule publishing | [Editorial](/docs/content-engine/editorial) | +| Let several editors work on one record at the same time | [Live editing](/docs/content-engine/live-editing) | +| Change list columns, form sections or field components | [AdminCP](/docs/content-engine/admincp) | +| Read and write content from your own API routes | [Services](/docs/content-engine/services) | +| React to created, updated or published records | [Events](/docs/content-engine/events) | +| Understand the generated tables and ship a schema change | [Database](/docs/content-engine/database) | +| Check a content type before it goes live | [Production](/docs/content-engine/production) | + +## Reference + +- [Options](/docs/content-engine/reference): every `defineContentType` option and how the options depend on each other. +- [Fields](/docs/content-engine/fields): every `field.*` helper with its options, column type and form control. +- [Plugin files](/docs/content-engine/plugin-files): the files and exports the app and the API read from your plugin. +- [Live editing API](/docs/content-engine/live-editing-api): the HTTP API and deployment requirements behind live editing. diff --git a/apps/web/content/docs/content-engine/live-editing-api.mdx b/apps/web/content/docs/content-engine/live-editing-api.mdx new file mode 100644 index 0000000000..f774319c1f --- /dev/null +++ b/apps/web/content/docs/content-engine/live-editing-api.mdx @@ -0,0 +1,156 @@ +--- +title: Live editing API +description: Reference for Content Engine live editing, covering deployment requirements, the field lock and draft HTTP routes, the WebSocket channel, timings, database tables and the components for custom AdminCP form layouts. +icon: Cable +--- + +The AdminCP drives [live editing](/docs/content-engine/live-editing) for you. This page is for the cases where you need the details: deploying it, calling its routes from your own code, or building a form layout that shows who is editing. Examples use `example.article`, whose staff API lives at `/api/@vitnode/example/admin/content/article`. + +## Deployment requirements + +Live editing runs on two channels. Field locks and the shared draft are plain HTTP routes, so they work everywhere. Presence and rich text co-editing travel over the [WebSocket](/docs/dev/websocket) at `/api/ws`, which only a long-lived Node API server provides, such as `apps/api` or a [self-hosted](/docs/dev/deployments/self-hosted) install. + +| Deployment | Field locks and autosave | Presence | Rich text | +| ------------------------------------------------------------------ | ---------------------------- | -------- | ----------------------------------------------- | +| Long-lived Node API server with the WebSocket | Live, pushed over the socket | Yes | Co-edited | +| No WebSocket, such as [Vercel](/docs/dev/deployments/cloud/vercel) | Polled over HTTP every 10 s | No | One editor at a time, locked like a plain field | + +A form without a socket polls `GET /{id}/locks` and `GET /{id}/draft` every 10 seconds, on top of lock renewals. Every request counts against the [rate limiter](/docs/dev/advanced/rate-limiter), which is keyed by IP address. A newsroom behind one office IP can reach the default of 80 requests a minute. A refused lock request leaves the field without autosave, so raise `rateLimiter.points` if that is you. + +With more than one API instance, set `REDIS_URL`. Instances share presence and document updates over Redis pub/sub. Without it, editors connected to different instances do not see each other. + +## HTTP routes + +The routes live under the content type's staff API module. Every route needs an AdminCP session and the type's `can_edit` permission, and answers `404` for a record that does not exist. + +| Method and path | Body | Success | Errors | +| -------------------------- | --------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| `GET /{id}/locks` | | `{ locks }`, the unexpired locks | | +| `POST /{id}/locks` | `{ field, locale, action }` | `{ lock }`, or `{ lock: null }` on release | `409` `CONTENT_FIELD_LOCKED`, `400` for an unknown field or wrong locale | +| `GET /{id}/draft` | | `{ shared, translations }` | | +| `PUT /{id}/draft` | `{ locale, values }` | `{ updatedAt }` | `409` `CONTENT_FIELD_NOT_LOCKED`, `400` `CONTENT_DRAFT_WRONG_SCOPE` or `CONTENT_DRAFT_INVALID` | +| `POST /{id}/draft/discard` | `{}` | `{ discarded: true }`, and a `reset` with reason `discarded` to the room | | + +- `action` is `acquire`, `renew` or `release`. +- `locale` is `null` for a field that is not localized and an enabled language code for a localized one. A shared field and a localized field never share a draft write. +- A draft write accepts only fields you currently hold a lock on, and validates each value with the field's own rules. It never touches the record; **Save** does that. + +Taking a lock on the excerpt: + +```http title="Request" +POST /api/@vitnode/example/admin/content/article/2/locks +Content-Type: application/json + +{ "field": "excerpt", "locale": null, "action": "acquire" } +``` + +```json title="200" +{ + "lock": { + "expiresAt": "2026-10-10T11:12:14.701Z", + "field": "excerpt", + "locale": null, + "user": { "id": 16, "name": "Anna Kowalska" } + } +} +``` + +When someone else asks for the same field, the `409` names the holder: + +```json title="409" +{ + "code": "CONTENT_FIELD_LOCKED", + "lock": { + "expiresAt": "2026-10-10T11:12:14.701Z", + "field": "excerpt", + "locale": null, + "user": { "id": 16, "name": "Anna Kowalska" } + } +} +``` + +Reading the draft returns the shared fields and one draft per language: + +```json title="GET /api/@vitnode/example/admin/content/article/2/draft" +{ + "shared": { + "baseVersion": 3, + "updatedAt": "2026-10-10T10:57:20.381Z", + "updatedBy": { "id": 16, "name": "Anna Kowalska" }, + "values": { "title": "Spring menu launches Monday at noon" } + }, + "translations": {} +} +``` + +`baseVersion` is the record or translation version the draft was written on. + +## WebSocket channel + +The socket side is one channel, `@vitnode/core_content_live`, on the shared `/api/ws` connection. Joining a record's room checks the same AdminCP session and `can_edit` as the HTTP routes. Every message carries the record as `{ contentTypeId, itemId }`, and every client message carries the `clientId` of the tab that sent it. + +| Direction | Messages | +| --------- | --------------------------------------------------------------------------------------------------------------------------- | +| Client | `join`, `focus`, `heartbeat`, `leave`, and `doc:open`, `doc:update`, `doc:awareness`, `doc:close` for rich text | +| Server | `joined`, `presence`, `locks`, `draft`, `reset`, `error`, and `doc:seed`, `doc:state-vector`, `doc:update`, `doc:awareness` | + +Rich text documents are [Yjs](https://yjs.dev) documents, one per record, field and language. Updates travel as base64. The protocol types live in `@vitnode/core/content/live/protocol` if you need them, but the AdminCP is the only client you should need. + +## Timings + +| What | Value | +| ----------------------------------------------- | ---------- | +| Lock lifetime without renewal | 60 s | +| Lock renewal while the field has focus | every 20 s | +| Autosave after the last keystroke | 1.5 s | +| Presence heartbeat | every 15 s | +| Member dropped after missed heartbeats | 45 s | +| Rich text document stored after the last change | 2 s | +| Polling without a socket | every 10 s | +| Untouched drafts and documents removed after | 30 days | + +## Where the data lives + +| Table | Holds | +| -------------------------- | ----------------------------------------------------------------- | +| `core_content_drafts` | The shared draft of plain fields, one row per record and language | +| `core_content_field_locks` | Active field locks with their holder and expiry | +| `core_content_documents` | The Yjs state of each rich text field, per record and language | + +The daily `content-editorial-cleanup` [cron job](/docs/dev/cron) removes expired locks, documents untouched for 30 days, and drafts untouched for 30 days whose record has moved on since. A stale draft whose record has not changed is kept, because it is somebody's unsaved work. When an editor's last tab leaves a record, their locks go too. + +## Use live state in a custom form layout + +A plugin that replaces the edit form with [its own layout](/docs/content-engine/admincp) still gets locks and outlines on every field it renders with `ContentFormField`. The rest of the live UI comes from `@vitnode/core/content/admin-form`: + +```tsx title="plugins/example/src/views/admin/article-layout.tsx" +import { + ContentFormField, + ContentLivePresence, + ContentLiveStatus, +} from "@vitnode/core/content/admin-form"; + +export const ArticleFormLayout = () => ( +
+
+ + +
+ + +
+); +``` + +Register it with `forms: { layout: ArticleFormLayout }` in the type's `contentTypeAdmin(...)` entry, the way the blog plugin registers `BlogArticleFormLayout`. + +| Export | What it gives you | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ContentLivePresence` | Avatars of everyone else in the record. `max` sets how many show before **+N**, 5 by default | +| `ContentLiveFieldPresence` | Avatars of the people in one `field` and `locale` | +| `ContentLiveLanguagePresence` | Who is working in one `locale` | +| `ContentLiveStatus` | The autosave state: **Saving…**, **Draft saved at…**, **Draft not saved**, **Unsaved changes** | +| `useContentLive()` | The session: `session.members`, `session.locks`, `status` (`saving`, `savedAt`, `failed`, `dirty`) and `coEditing` | +| `useContentRichTextReplace()` | `(field, locale, document) => void` that replaces a rich text field through the shared editor, so everyone sees it and it can be undone. `null` while rich text is not co-edited | + +All of them render nothing, or return `null`, outside a live form, so the same layout works for the create form. diff --git a/apps/web/content/docs/content-engine/live-editing-co-editing.webp b/apps/web/content/docs/content-engine/live-editing-co-editing.webp new file mode 100644 index 0000000000..1f40116f54 Binary files /dev/null and b/apps/web/content/docs/content-engine/live-editing-co-editing.webp differ diff --git a/apps/web/content/docs/content-engine/live-editing-field-lock.webp b/apps/web/content/docs/content-engine/live-editing-field-lock.webp new file mode 100644 index 0000000000..156f47e947 Binary files /dev/null and b/apps/web/content/docs/content-engine/live-editing-field-lock.webp differ diff --git a/apps/web/content/docs/content-engine/live-editing.mdx b/apps/web/content/docs/content-engine/live-editing.mdx new file mode 100644 index 0000000000..e3074ec0be --- /dev/null +++ b/apps/web/content/docs/content-engine/live-editing.mdx @@ -0,0 +1,91 @@ +--- +title: Live editing +description: Let several editors work on one Content Engine record at once in the AdminCP, with autosave to a shared draft, presence outlines, field locks and real-time rich text co-editing. +icon: Users +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import coEditing from "./live-editing-co-editing.webp"; +import fieldLock from "./live-editing-field-lock.webp"; + +Live editing lets a team work on the same record at the same time without overwriting each other. Changes autosave to a shared draft, everyone sees who is in which field, plain fields are edited by one person at a time, and [rich text](/docs/content-engine/rich-text) fields are typed in together. + +## Turn it on + +Add `liveEditing: true` next to [`editorial`](/docs/content-engine/editorial). Live editing needs `editorial`, because field locks and the shared draft are measured against the record version it adds. Without it, `defineContentType` throws ``liveEditing needs `editorial` ``, and TypeScript flags the option first: + +```ts title="plugins/example/src/content/article.ts" +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + editorial: true, + liveEditing: true, // [!code highlight] + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + }, +}); +``` + +A live session starts when someone opens an existing record's edit form in the AdminCP. That needs three things: + +- An AdminCP session, from signing in at `/admin`. +- The content type's `can_edit` permission. Someone who can only view the record does not join. +- A saved record. The create form is not live, because there is nothing to share yet. + +Presence and rich text co-editing also need the API's [WebSocket](/docs/dev/websocket). Without it, autosave and field locks still work over HTTP, a little slower. [Deployment requirements](/docs/content-engine/live-editing-api#deployment-requirements) lists what each setup gets. + +## What editors see + +### Presence + +The form header shows an avatar for everyone else in the record, up to five and then **+N**. Hover or focus an avatar to read where that person is, such as "editing Title". A ring in their color means they are in a field right now. + +The field they are in gets an outline in their color with a name tag. When they move to another field, the outline glides after them, and nothing on the page shifts to make room. In a localized form, the language switcher marks the languages people are working in. You never see yourself, and one person in several tabs counts once. + +### Field locks + +Clicking into a plain field, such as a title, slug or category, takes a lock on that field in that language. Everyone else sees the field dimmed and read-only inside the editor's outline, and the value updates as they type. Screen readers announce "Anna Kowalska is editing". + + + +A lock lasts 60 seconds and renews every 20 seconds while the editor stays in the field. Leaving the field releases it, and so does closing the record. A lock nobody renews, say because a laptop went to sleep, expires on its own. + +### Autosave and the shared draft + +About 1.5 seconds after you stop typing in a field you hold, the value is saved to the record's draft. The header shows **Saving…** and then **Draft saved at 14:32**, or **Draft not saved** when the write failed. Next to it, **Unsaved changes** stays until someone saves the draft into the record. + +Changed your mind? **Discard** next to **Unsaved changes** throws the draft away after you confirm, and the form goes back to the last saved version. It is a team draft, so everyone editing the record goes back too, and they get a short note saying so. + +There is one draft per record and language, shared by the whole team. When you open the record, you see the draft, not only what was last saved. **Save** commits it through the usual [version check](/docs/content-engine/editorial#safe-concurrent-edits) and creates one revision. Autosave itself never creates revisions, emits no events and leaves search and caches alone. [Preview links](/docs/content-engine/editorial#preview-links) also ignore the draft until someone saves. + +### Rich text co-editing + +Rich text fields are not locked and get no outline. Everyone in the same field and language types into one shared document and sees each other's caret with a name tag, plus a tint over the text they select. Undo only undoes your own changes. The example plugin's article has no rich text field, so this is the blog plugin's article editor: + + + +Each language has its own shared document. The first person to open a field fills it from the record. If the connection drops, the editor keeps what you type and sends it when the connection comes back. The server stores the document about two seconds after the last change, and **Save** writes its current content, everyone's edits included, to the record. + +### Restores and deletes + +Restoring a revision or deleting the record throws away its drafts, locks and shared documents. Everyone in the session gets a notice, and the form reloads with the new state. + +## Check the result + +1. Sign in at `/admin` as two staff members with `can_edit`, for example in two browser profiles. +2. Open the same record's edit form in both. +3. Click into **Title** in one window. The other window shows the outline and name tag, and the field turns read-only there. +4. Type a few words and wait two seconds. The other window shows the new value, and the header of the first says **Draft saved at…**. + +Building your own form layout, or calling the lock and draft routes yourself? See the [live editing API](/docs/content-engine/live-editing-api). diff --git a/apps/web/content/docs/content-engine/localization-language-selector.webp b/apps/web/content/docs/content-engine/localization-language-selector.webp new file mode 100644 index 0000000000..dbd669773f Binary files /dev/null and b/apps/web/content/docs/content-engine/localization-language-selector.webp differ diff --git a/apps/web/content/docs/content-engine/localization-public-page-pl.webp b/apps/web/content/docs/content-engine/localization-public-page-pl.webp new file mode 100644 index 0000000000..4d2e0ee0c7 Binary files /dev/null and b/apps/web/content/docs/content-engine/localization-public-page-pl.webp differ diff --git a/apps/web/content/docs/content-engine/localization.mdx b/apps/web/content/docs/content-engine/localization.mdx new file mode 100644 index 0000000000..2ab720ef0f --- /dev/null +++ b/apps/web/content/docs/content-engine/localization.mdx @@ -0,0 +1,195 @@ +--- +title: Translations +description: Translate Content Engine fields with localization and localized fields, edit each language per field in the AdminCP, and pick the language in the public API. +icon: Languages +--- + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; +import { ImgDocs } from "@/components/fumadocs/img"; +import languageSelector from "./localization-language-selector.webp"; +import publicPagePl from "./localization-public-page-pl.webp"; + +Localization stores chosen fields once per language, while every other field stays shared. An article's title and body get a Polish version, but whether it is featured stays one value. Translated values live in a second table, `{tableName}_translations`, with one row per record and language. This guide uses `example.localized-article` from the example plugin. + +## Before you begin + +The languages come from your app's `i18n.locales` in `vitnode.config.ts` (see [Languages & Localization](/docs/dev/i18n)) and from the languages created at install. `defaultLocale` must be one of them. A language turned off in the config stays readable in the AdminCP, but nobody can write to it and the public API does not serve it. + + + + + +### Mark the translatable fields + +Add a `localization` block and set `localized: true` on each field that changes per language: + +```ts title="plugins/example/src/content/localized-article.ts" +export const localizedArticleContentType = defineContentType({ + id: "example.localized-article", + tableName: "example_localized_articles", + // [!code ++:4] + localization: { + defaultLocale: "en", + fallback: "default", + }, + publication: true, + fields: { + // [!code highlight:8] + title: field.text({ + localized: true, + required: true, + minLength: 3, + maxLength: 200, + }), + slug: field.slug({ localized: true, source: "title" }), + body: field.textarea({ localized: true, required: true }), + featured: field.boolean({ defaultValue: false }), + }, +}); +``` + +`text`, `textarea`, `richText`, `slug`, `blocks` and whole `group` fields can be localized. Numbers, booleans, enums, dates, files, users, relations and repeatables are always shared. A slug and its `source` must both be localized or both shared, otherwise every language would get the same URL. `localized: true` without a `localization` block throws, and so does a `localization` block without a localized field. + + + + + +### Export the translations table + +```ts title="plugins/example/src/database/localized-articles.ts" +export const localizedArticleContent = createContentModel( + localizedArticleContentType, +); + +export const example_localized_articles = localizedArticleContent.table; +export const example_localized_articles_translations = // [!code ++:2] + localizedArticleContent.translationTable; +``` + +Drizzle Kit only sees exported tables, so without this line the migration has no translations table. + + + + + +### Build and migrate + + + +```bash tab="bun" +bun run build:plugins && bun run db:migrate +``` + +```bash tab="pnpm" +pnpm build:plugins && pnpm db:migrate +``` + +```bash tab="npm" +npm run build:plugins && npm run db:migrate +``` + + + +The localized columns move to `example_localized_articles_translations`, keyed by `(itemId, languageId)`. That table has its own `version`, `createdAt` and `updatedAt`, plus `status` and `publishedAt` when the type has publication. + + + The migration does not copy existing values into the translations table. If + the table already holds records, review the SQL before you apply it and add a + step that copies them. + + + + + + +## Edit languages in the AdminCP + +There are no language tabs. Each localized field carries its own language selector, so an editor can fix the Polish title without leaving the form. The field first shows your AdminCP language if it has text, then the default language, then any language that has text. Shared fields have no selector. + +This is the blog plugin's category dialog, where `name` is `field.text({ localized: true })`: + + + +Saving the form sends the shared fields and every language you typed into in one request: `POST /localized` for a new record, `PUT /{id}/localized` for an existing one. The server writes them in one transaction. A new record must include its default language. A Polish-only edit does not touch the shared row, so it does not bump the record's version or expire the English cache. + +With publication, each language has its own status. Publishing the record publishes its languages too, and you can then unpublish one language alone. A translation is public only when both the record and that translation are published. With `editorial`, each language also keeps its own revisions and preview links. + + + The example plugin registers `example.localized-article` on the API only, so + it has no AdminCP screen. Add it to the plugin's `adminContent` and `adminNav` + content types to get the generated form shown above. + + +## Read a language from the public API + +The public API serves one language per request, chosen in this order: + +1. The `?locale=` query parameter. A locale that the site does not serve returns `404`. +2. Otherwise the `Accept-Language` header. The response then carries `Vary: Accept-Language`. +3. Otherwise `defaultLocale`. + +```bash +curl -i "http://localhost:3000/api/@vitnode/example/content/localized-articles/witaj-content-engine?locale=pl" +``` + +```http +HTTP/1.1 200 +content-language: pl + +{"title":"Witaj Content Engine","slug":"witaj-content-engine","body":"Jeden rekord, dwa języki.","featured":false,"publishedAt":"2026-10-10T10:57:12.452Z","locale":"pl"} +``` + +The `locale` property says which language the body is in. When the requested translation is missing or unpublished, `fallback: "default"` serves the default language instead, so `"locale"` becomes `"en"`. With `fallback: "none"` (the default), the API returns `404`. + +In a page loader, pass the route's locale through. This is the example plugin's localized article page: + +```ts title="plugins/example/src/pages/localized-article-page.tsx" +const detail = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/localized-articles", + path: "/{slug}", + args: { + params: { slug: encodeURIComponent(slug) }, + query: { locale }, + }, +}); +``` + +Opening `/pl/localized-articles/witaj-content-engine` renders the Polish translation: + + + +Each translation can have its own slug and URL; [Public pages](/docs/content-engine/public-pages) covers localized URLs and `hreflang`. + +## Translation routes + +`buildContentAdminModule` adds staff routes for each language under the content type's admin path. They use the same staff permissions as the record: + +| Route | Does | +| ----------------------------------------------------------- | ------------------------------------------------------------ | +| `GET /{id}/translations` | Lists the record's translations | +| `GET`, `POST`, `PUT`, `DELETE /{id}/translations/{locale}` | Reads, creates, updates or deletes one language | +| `POST /{id}/translations/{locale}/publish` and `/unpublish` | Changes one language's status. Needs publication | +| `GET /{id}/translations/{locale}/revisions` | One language's history. Needs editorial | +| `POST /{id}/translations/{locale}/preview` | A preview link for one language. Needs editorial preview | +| `POST /localized`, `PUT /{id}/localized` | Saves shared fields and several languages in one transaction | + +Each translation write emits an event such as `content.example.localized-article.translation_created` or `translation_published`; see [Events](/docs/content-engine/events). + +## Options + +| Name | Type | Required | Default | Description | +| --------------- | ----------------------- | -------- | -------- | ---------------------------------------------------------------------- | +| `defaultLocale` | `string` | Yes | | A language code such as `"en"` or `"pt-BR"`. Every record starts in it | +| `fallback` | `"none"` \| `"default"` | No | `"none"` | What the public API serves when the requested language is missing | diff --git a/apps/web/content/docs/content-engine/meta.json b/apps/web/content/docs/content-engine/meta.json new file mode 100644 index 0000000000..9faf979039 --- /dev/null +++ b/apps/web/content/docs/content-engine/meta.json @@ -0,0 +1,36 @@ +{ + "title": "Content Engine", + "description": "Declare a content type once and get the table, API, AdminCP, permissions and public pages", + "icon": "Boxes", + "root": true, + "pages": [ + "---Start here---", + "index", + "create-a-content-type", + "---Publish---", + "public-api", + "public-pages", + "seo", + "search", + "caching", + "---Model content---", + "relations", + "localization", + "rich-text", + "---Editorial---", + "editorial", + "live-editing", + "---Customize---", + "admincp", + "services", + "events", + "---Ship---", + "database", + "production", + "---Reference---", + "reference", + "fields", + "plugin-files", + "live-editing-api" + ] +} diff --git a/apps/web/content/docs/content-engine/plugin-files.mdx b/apps/web/content/docs/content-engine/plugin-files.mdx new file mode 100644 index 0000000000..5ce5610764 --- /dev/null +++ b/apps/web/content/docs/content-engine/plugin-files.mdx @@ -0,0 +1,137 @@ +--- +title: Plugin files +description: Reference for the files and exports a VitNode plugin needs for Content Engine content types, which part of the app or API reads each one, and why AdminCP code stays out of config.tsx. +icon: Package +--- + +VitNode reads a plugin's content types from several small files instead of one big config. A different part of the app loads each file, so a public page never downloads the AdminCP's forms and a browser never sees your database code. [First content type](/docs/content-engine/create-a-content-type) writes each file step by step; this page is the lookup. + +## Files at a glance + +| File | Export | Read by | Holds | +| --------------------------------------- | ---------------------------------- | ------------------------------------------ | ------------------------------------------------------------------ | +| `src/content/.ts` | Any name | Every file below | The `defineContentType` definition. Safe in the browser | +| `src/database/.ts` | Every table | Drizzle Kit, from `dist/src/database/*.js` | `createContentModel` and the generated tables. Server only | +| `src/api/modules/admin/admin.module.ts` | Any name | `src/config.api.ts` | `buildContentAdminModule` inside a module named `admin` | +| `src/config.api.ts` | The API plugin, `VitNodeApiPlugin` | The API, and the app's typed `fetcher` | The admin module, and `buildContentPublicModule` for public routes | +| `src/admin/nav.tsx` | `adminNav` | The AdminCP layout, on every AdminCP page | `{ definition, icon }` per content type | +| `src/admin/content.tsx` | `adminContent` | The content screens, loaded on demand | Screen registrations, custom cells, fields and form layouts | +| `src/content.ts` | `contentTypes` | The web build and the API at startup | Every content type with `delivery` or `search` | +| `src/routes.ts` | `routes` | The web build | The pages that serve `delivery` and `search` URLs | +| `src/config.tsx` | The plugin | The app's `vitnode.config.ts` | Plugin id, `messages`, `localeFiles` and `routes` only | +| `src/locales/en.json` | JSON | The app, through `localeFiles` | Labels for content types, fields and permissions | + +A content type with an AdminCP screen needs an entry in both `admin/nav` and `admin/content`. The first adds the sidebar link, the second the screen behind it. They are separate files so the sidebar stays small on every AdminCP page, while form and editor code loads only with the screen. + +Without custom cells, fields or layouts, `admin/content` can re-export the nav list: + +```tsx title="src/admin/content.tsx" +export { adminNav as adminContent } from "./nav"; +``` + +Once you customize a screen, build `adminContent` on its own and reuse the nav entries, as the example plugin does: + +```tsx title="plugins/example/src/admin/content.tsx" +import type { ContentFrontendPluginSource } from "@vitnode/core/lib/plugin"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { exampleArticleNav, exampleCategoryNav, examplePageNav } from "./nav"; + +export const adminContent = { + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [exampleArticleNav, exampleCategoryNav, examplePageNav], +} satisfies ContentFrontendPluginSource; +``` + +## How the app finds them + +The app never imports these files by hand. Its Vite plugin resolves a fixed subpath of every plugin listed in `vitnode.config.ts` and writes the result into a generated file: + +| Subpath | Generated file | Used for | +| -------------------------- | ----------------------------- | ----------------------------------------------------- | +| `/admin/nav` | `src/admin-nav.gen.ts` | The AdminCP sidebar | +| `/admin/content` | `src/content-registry.gen.ts` | The AdminCP content screens | +| `/content` | `src/content-modules.gen.ts` | URL checks in the build and in the API | +| `/config.api` | `src/api-registry.gen.ts` | Types for `fetcher`, imported with `import type` only | +| `/routes` | `src/plugin-routes.gen.ts` | Plugin pages | + +This happens each time `vite dev` or `vite build` starts, and the dev server regenerates the files when a resolved module changes. A subpath that does not resolve is skipped without an error, because most plugins have no content types. That is also the usual reason a new screen does not show up: a wrong file path, a wrong export name, or a plugin that was not rebuilt. + +Resolution only works when the plugin id equals the npm package name and the package exports its `dist` folder. A generated plugin already has the right `exports`: + +```json title="plugins/example/package.json" +{ + "name": "@vitnode/example", + "exports": { + "./locales/*.json": "./src/locales/*.json", + "./*": { + "import": "./dist/src/*.js", + "types": "./dist/src/*.d.ts", + "default": "./dist/src/*.js" + } + } +} +``` + +Tables are the exception. Drizzle Kit reads `node_modules//dist/src/database/*.js` directly for every plugin in the API config. Export each table from those files, or it never reaches a migration. [Database](/docs/content-engine/database#export-every-table) shows a model with several tables. + +## Keep `config.tsx` small + +`config.tsx` is bundled with the document shell of every public page: + +```tsx title="plugins/example/src/config.tsx" +import { buildPlugin } from "@vitnode/core/lib/plugin"; + +import { CONFIG_PLUGIN } from "@/const"; + +import messages from "./locales"; +import { routes } from "./routes"; + +export const examplePlugin = () => + buildPlugin({ + ...CONFIG_PLUGIN, + localeFiles: { + en: "@vitnode/example/locales/en.json", + }, + messages, + routes, + }); +``` + +Do not import `admin/nav` or `admin/content` here. The app loads `admin/nav` with the AdminCP layout and `admin/content` behind a dynamic `import()`, so form code arrives only with the screen that needs it. Pulling them into `config.tsx` would put the whole editing stack, rich text editor included, into every public page. + +`localeFiles` maps each language to your locale JSON so the app can load it. Without it nothing fails, but every label from your locale file is missing. + +## `content.ts` and public URLs + +A content type with `delivery` or `search` publishes URLs. List every such content type in `src/content.ts`. The example plugin lists all of its types, which is allowed: + +```ts title="plugins/example/src/content.ts" +import { advancedArticleContentType } from "@/content/advanced-article"; +import { articleContentType } from "@/content/article"; +import { categoryContentType } from "@/content/category"; +import { localizedArticleContentType } from "@/content/localized-article"; +import { pageContentType } from "@/content/page"; + +export const contentTypes = [ + articleContentType, + advancedArticleContentType, + localizedArticleContentType, + categoryContentType, + pageContentType, +]; +``` + +Keep the module browser-safe: it imports definitions only, never `src/database`. The web build checks that one of your pages serves each URL, and the API refuses to start when a plugin publishes URLs that this module does not declare. See [Content URLs need a page](/docs/dev/i18n/localized-urls#content-urls-need-a-page). + +## Errors + +| Error or symptom | Cause | Fix | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | +| A content type has no sidebar entry or screen | `admin/nav` or `admin/content` did not resolve, or the export name is wrong | Check the file path, the `adminNav` / `adminContent` export and rebuild the plugin | +| A table is missing from the migration | The table is not exported from `src/database/*.ts`, or the plugin was not rebuilt | Export it and run `build:plugins` before `db:migrate` | +| `content-module-missing` when the API starts | A content type has `delivery` or `search`, but `/content` does not resolve | Add `src/content.ts` with `export const contentTypes` | +| `incomplete-content-types` when the API starts | `src/content.ts` leaves out a content type that publishes URLs, or declares it differently | Export the same definition the API registers in `contentTypes` | +| `content-url-without-page` in `dev` or `build` | No page route serves a `delivery` or `search` URL | Add the page to `src/routes.ts`, or change the URL | +| Labels show raw keys or field names | `localeFiles` is missing from `config.tsx`, or the key path is wrong | Add `localeFiles` and check the `content.` keys | diff --git a/apps/web/content/docs/content-engine/production-search-health.webp b/apps/web/content/docs/content-engine/production-search-health.webp new file mode 100644 index 0000000000..ee9f71fc4b Binary files /dev/null and b/apps/web/content/docs/content-engine/production-search-health.webp differ diff --git a/apps/web/content/docs/content-engine/production.mdx b/apps/web/content/docs/content-engine/production.mdx new file mode 100644 index 0000000000..271f6fec13 --- /dev/null +++ b/apps/web/content/docs/content-engine/production.mdx @@ -0,0 +1,115 @@ +--- +title: Production +description: A pre-launch checklist for VitNode Content Engine content types, covering public field allowlists, staff permissions, migrations, scheduled publishing, preview links and content health checks. +icon: ShieldCheck +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import searchHealth from "./production-search-health.webp"; + +A content type that works on your machine needs a few more checks before real editors and visitors use it. Go through this list for every content type you ship. + +## Expose only what you mean to + +- [ ] **Review `publicApi.fields`.** It is the allowlist for everything public: the public API, page metadata from `delivery` and the search index. A field not listed there never leaves the server. Keep internal notes, moderation flags and anything personal out of it. +- [ ] **Keep `publication` on.** `publicApi` and `search` both refuse to load without it, and new records start as drafts, so nothing is public until someone publishes it. +- [ ] **Treat search as public.** `search.titleField`, `descriptionField` and `contentFields` must be public fields, and `search.authorField` shows who wrote a record in search results. + +The example article lists eight of its own fields plus `publishedAt`. `code`, `views` and `author` stay private, because they are not in the list: + +```ts title="plugins/example/src/content/article.ts" +publicApi: { + path: "articles", + fields: [ + "title", + "slug", + "excerpt", + "featured", + "category", + "animation", + "gallery", + "noIndex", + "publishedAt", + ], +}, +``` + +## Grant the right permissions + +- [ ] **Give staff only what they need** in **AdminCP → Staff**. Each content type gets `can_view`, `can_create`, `can_edit` and `can_delete`, plus `can_publish` with `publication` and `can_restore` with `editorial`. +- [ ] **Guard your own admin routes.** Generated routes check their permissions themselves. A custom route next to them needs its own `adminStaffPermission`, as shown in [Services](/docs/content-engine/services#example-3-a-feature-this-article-button). + +## Ship the migration + +- [ ] Commit the migration that `pnpm build:plugins && pnpm db:migrate` generated together with the content type. +- [ ] Run `db:migrate` as a deploy step. Production never migrates on startup. +- [ ] Read the generated SQL when you change an existing content type. See [Database](/docs/content-engine/database). + +## Configure what editorial features rely on + +- [ ] **Scheduled publishing** is a queue task, and the `process-queue` [cron job](/docs/dev/cron) drains the queue every minute. Make sure something calls your cron endpoint in production. +- [ ] **Set `CRON_SECRET`** to your own random value. The API rejects the built-in default outside development, which stops cron runs and front-end revalidation calls. +- [ ] **Preview links** are built from `VITNODE_WEB_URL` and `VITNODE_API_URL`, which default to localhost. Set both to your public URLs. If either is not a valid absolute URL, the API logs a warning at startup and previews stay off. +- [ ] **Integrations send the version they read.** With `editorial`, a save with an outdated `expectedVersion` fails with `409 CONTENT_VERSION_CONFLICT` instead of overwriting someone else's work. +- [ ] **A separate front end that caches public reads** is listed in `content.revalidateOrigins` with an `https` URL. See [Caching](/docs/content-engine/caching). +- [ ] **Live editing** has its own deployment requirements, listed in the [Live editing API](/docs/content-engine/live-editing-api). + +## Check content health + +**AdminCP → Advanced → Search** shows how much of each content type is in the search index. Use **Reindex** on a collection, or **Rebuild index**, after a bulk import or a failed sync. + + + +For a monitoring probe, core has a staff-only route that reports every content type, its search drift per language and failed scheduled effects. It needs the `can_view` permission of the `system` module: + +```bash +curl -b "vitnode_auth_admin=" \ + http://localhost:8000/api/@vitnode/core/admin/debug/content/status +``` + +```json +{ + "healthy": false, + "searchHealthy": false, + "effectsHealthy": true, + "contentTypes": [ + { + "contentTypeId": "example.article", + "pluginId": "@vitnode/example", + "features": { + "editorial": true, + "localization": false, + "publicApi": true, + "publication": true, + "scheduling": true, + "search": true + }, + "search": { + "healthy": false, + "expectedTotal": 1, + "canonicalIndexedTotal": 2, + "provider": { + "name": "postgres", + "healthy": false, + "indexedTotal": 2, + "verified": true + } + }, + "schedules": { "pending": 0, "withErrors": 0, "failedEffects": 0 } + } + ] +} +``` + +This excerpt shows two indexed documents for one published article, so search is not healthy until the collection is reindexed. `failedEffects` counts scheduled publishes that committed but whose event or search update failed. In your own code, `contentEngineDiagnostics(c)` from `@vitnode/core/content/server` returns the same object. + +## Know the limits + +- Content types are defined in code and shipped with migrations. There is no AdminCP screen for creating them at runtime. +- A relation points at one content type. To link to several unrelated types, use one relation field per type. +- `field.blocks()` is not in the generated form by default. Editors change it through [widgets](/docs/dev/widgets) or a custom field component. diff --git a/apps/web/content/docs/content-engine/public-api-publish-confirm.webp b/apps/web/content/docs/content-engine/public-api-publish-confirm.webp new file mode 100644 index 0000000000..6bc1be4c84 Binary files /dev/null and b/apps/web/content/docs/content-engine/public-api-publish-confirm.webp differ diff --git a/apps/web/content/docs/content-engine/public-api-publish-row.webp b/apps/web/content/docs/content-engine/public-api-publish-row.webp new file mode 100644 index 0000000000..af9c9ea6f5 Binary files /dev/null and b/apps/web/content/docs/content-engine/public-api-publish-row.webp differ diff --git a/apps/web/content/docs/content-engine/public-api.mdx b/apps/web/content/docs/content-engine/public-api.mdx new file mode 100644 index 0000000000..1efadaa73e --- /dev/null +++ b/apps/web/content/docs/content-engine/public-api.mdx @@ -0,0 +1,256 @@ +--- +title: Public API +description: Add drafts and publishing to a VitNode Content Engine content type, expose published records through typed read-only public API routes, and read them with the fetcher. +icon: Globe +--- + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; +import { ImgDocs } from "@/components/fumadocs/img"; +import publishRow from "./public-api-publish-row.webp"; +import publishConfirm from "./public-api-publish-confirm.webp"; + +A content type is private until you opt in. This guide follows `example.article` from the example plugin. It gets a draft and a published state, and its published records become readable by anyone at `/api/@vitnode/example/content/articles`. You list exactly which fields leave the server. + +You need a content type with an AdminCP screen, like the one built in [Create a content type](/docs/content-engine/create-a-content-type). + + + + + +### Turn on publication and the public API + +Add `publication` and `publicApi` to the definition. This excerpt leaves out the article fields this guide does not use: + +```ts title="plugins/example/src/content/article.ts" +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + featured: field.boolean({ defaultValue: false }), + author: field.user(), + category: field.relation({ + required: true, + onDelete: "restrict", + target: () => categoryContentType, + }), + }, + publication: true, // [!code ++] + // [!code ++:9] + publicApi: { + path: "articles", + fields: ["title", "slug", "excerpt", "featured", "category", "publishedAt"], + searchableFields: ["title", "excerpt"], + orderableFields: ["publishedAt", "title"], + filterableFields: ["category", "featured"], + defaultOrderBy: "publishedAt", + defaultOrder: "desc", + }, +}); +``` + +- `publication` adds a `status` column (`draft` or `published`, default `draft`), a `publishedAt` timestamp, a `can_publish` staff permission and **Publish** / **Unpublish** actions in the AdminCP. +- `publicApi.fields` is an allowlist with no wildcard. It needs exactly one slug field, because the detail route finds records by slug. `status` and `field.user()` fields can never be listed, which is why `author` stays private. +- Searchable, filterable and orderable fields must also be in `fields`. `publishedAt` is always orderable and is the default sort. +- `path` is one lowercase URL segment and cannot be `admin`. + +`publicApi` without `publication` throws when the definition loads, so drafts cannot leak by accident. + + + + + +### Register the public module + +Add `buildContentPublicModule` to the plugin's API config. The example plugin passes all five content models; one without `publicApi` is skipped: + +```ts title="plugins/example/src/config.api.ts" +import { buildContentPublicModule } from "@vitnode/core/content/server"; // [!code ++] + +import { articleContent } from "@/database/articles"; // [!code ++] + +export const exampleApiPlugin = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + modules: [ + adminModule, + // [!code ++:4] + buildContentPublicModule({ + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [articleContent], + }), + ], + }); +``` + + + + + +### Label the publish permission + +Give the new permission a name next to the other article permissions. The `Status` and `Published at` columns need no strings, because the AdminCP derives them from the column names. + +```json title="plugins/example/src/locales/en.json" +{ + "@vitnode/example:article:can_publish": "Publish and unpublish articles" +} +``` + + + + + +### Build, migrate and restart + +Run this from the workspace root, then restart `pnpm dev` so the API registers the new routes: + + + +```bash tab="bun" +bun run build:plugins && bun run db:migrate +``` + +```bash tab="pnpm" +pnpm build:plugins && pnpm db:migrate +``` + +```bash tab="npm" +npm run build:plugins && npm run db:migrate +``` + + + +The migration adds `status`, `publishedAt` and an `example_articles_status_published_at_idx` index. Existing rows become drafts. [Database](/docs/content-engine/database) covers migrations in detail. + + + + + +### Publish an article + +Open **AdminCP → Example → Articles**. New articles show a **Draft** badge. Click the paper plane **Publish** button at the end of a row: + + + +Confirm with **Yes, publish it**. The badge turns into **Published** and the article appears in the public API. + + + +The **Create Article** dialog also gets **Save as draft** and **Publish** buttons, and you can publish many rows at once with [bulk actions](/docs/content-engine/admincp#bulk-actions). + + + + + +## Check the result + +Request the list. Filters are query parameters named after the field, and `orderBy` accepts the orderable fields: + +```bash +curl "http://localhost:3000/api/@vitnode/example/content/articles?featured=false&orderBy=title&order=asc&first=1" +``` + +```json +{ + "edges": [ + { + "title": "Hello Content Engine", + "slug": "hello-content-engine", + "excerpt": "A first article from the example plugin, served by the public API and rendered on its own page.", + "featured": false, + "category": { "id": 5 }, + "publishedAt": "2026-10-10T10:55:34.335Z" + } + ], + "pageInfo": { + "totalCount": 1, + "totalPages": 1, + "currentPage": null, + "pageSize": 1, + "count": 1, + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "eyJjb2x1bW4iOiJ0aXRsZSIsImlkIjozLCJ2YWx1ZSI6IkhlbGxvIENvbnRlbnQgRW5naW5lIn0", + "endCursor": "eyJjb2x1bW4iOiJ0aXRsZSIsImlkIjozLCJ2YWx1ZSI6IkhlbGxvIENvbnRlbnQgRW5naW5lIn0" + } +} +``` + +A relation comes back as `{ "id": 5 }`. A file field, such as the example's `gallery`, comes back as `{ id, name, url, mimeType, size, width, height }`, never as the raw file ID. + +Unpublish the article and request it by slug. A draft, a record scheduled for later and a slug that never existed all get the same `404`, so the API never confirms that a draft exists: + +```bash +curl -i http://localhost:3000/api/@vitnode/example/content/articles/hello-content-engine +``` + +```text +HTTP/1.1 404 +Article not found. +``` + +## Read the API from your plugin + +The [fetcher](/docs/dev/fetcher) types the routes from the plugin's API registry. The module is `content/` followed by `publicApi.path`. Put a helper like this anywhere in your plugin: + +```ts title="featured-articles.ts" +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { z } from "zod"; + +const zodArticleCard = z.object({ + excerpt: z.string().nullable(), + publishedAt: z.string().nullable(), + slug: z.string(), + title: z.string(), +}); + +export const fetchFeaturedArticles = async () => { + const response = await fetcher({ + plugin: "@vitnode/example", + method: "get", + module: "content/articles", + path: "/", + args: { query: { featured: true, first: 5 } }, + }); + + if (response.status !== 200) { + throw new Error(`Loading articles answered ${response.status}.`); + } + + const { edges } = await response.json(); + + return z.array(zodArticleCard).parse(edges); +}; +``` + +The path, the query and `pageInfo` are typed. Each record is a `Record`, so parse the fields you use with Zod, as the example's article page does. A content type without `publicApi` adds no module: `module: "content/categories"` is a compile error. An `orderBy` outside the allowlist type-checks but answers `400`. + +## Public API routes + +All routes are `GET` and live under `/api/{pluginId}/content/{publicApi.path}`. + +| Route | Returns | +| -------------------------- | --------------------------------------------------------------------------------------------------------- | +| `/` | Published records with `edges` and `pageInfo` | +| `/{slug}` | One published record, or `404` | +| `/preview/{token}` | A draft behind a signed link, only with [preview links](/docs/content-engine/editorial#preview-links) | +| `/delivery/resolve/{slug}` | Whether a URL is a record, a redirect or nothing, only with [delivery](/docs/content-engine/public-pages) | +| `/delivery/item/{id}` | Canonical URL and SEO metadata for one record, only with delivery | +| `/delivery/sitemap` | Sitemap entries, only with `delivery.sitemap` | + +The list accepts `first` or `last` (at most 50, also the default), `cursor`, `page`, `search` over `searchableFields`, `orderBy`, `order` and one parameter per filterable field. A record is public only when its `status` is `published` and its `publishedAt` is set and not later than the database clock. Every `publicApi` option is in the [content type reference](/docs/content-engine/reference#publicapi). + +Next, give each article a web page with [Public pages](/docs/content-engine/public-pages), or see how these reads are cached in [Caching](/docs/content-engine/caching). diff --git a/apps/web/content/docs/content-engine/public-pages-article.webp b/apps/web/content/docs/content-engine/public-pages-article.webp new file mode 100644 index 0000000000..055e5af89e Binary files /dev/null and b/apps/web/content/docs/content-engine/public-pages-article.webp differ diff --git a/apps/web/content/docs/content-engine/public-pages.mdx b/apps/web/content/docs/content-engine/public-pages.mdx new file mode 100644 index 0000000000..a1af3387d3 --- /dev/null +++ b/apps/web/content/docs/content-engine/public-pages.mdx @@ -0,0 +1,258 @@ +--- +title: Public pages +description: Give every published VitNode Content Engine record its own URL with delivery, a plugin page, slug redirects and localized URLs. +icon: Compass +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import articlePage from "./public-pages-article.webp"; + +Delivery turns a published record into a web page at a stable URL, such as `/articles/hello-content-engine`. The Content Engine resolves the URL, answers `404` for drafts, redirects retired slugs and builds the page's SEO metadata from fields you choose. Your plugin owns the page component, so the content type, its API and its URL stay in one plugin. + +The content type needs [publication and a public API](/docs/content-engine/public-api). This guide continues with `example.article`. + + + + + +### Turn on delivery + +Add a `delivery` block to the definition: + +```ts title="plugins/example/src/content/article.ts" +export const articleContentType = defineContentType({ + id: "example.article", + // [!code ++:10] + delivery: { + redirects: true, + seo: { + titleField: "title", + descriptionField: "excerpt", + noIndexField: "noIndex", + openGraph: { titleField: "title", descriptionField: "excerpt" }, + }, + sitemap: { changeFrequency: "weekly", priority: 0.7 }, + }, +}); +``` + +- Every SEO field must also be in `publicApi.fields`, because the page renders it publicly. That is why the example lists `noIndex` there. +- `noIndexField` names a boolean. When it is `true`, the page gets `noindex, nofollow` and the record leaves the sitemap. +- `redirects` needs `editorial: true`, which the example article already has. `sitemap` needs `publication`. +- The page URL defaults to `/{publicApi.path}/:slug`, so articles live at `/articles/:slug`. Set `delivery.path` to use another URL. It needs exactly one `:slug` and cannot start with `/admin` or `/api`. + + + + + +### Export the plugin's content types + +Export every content type with public URLs from `src/content.ts`: + +```ts title="plugins/example/src/content.ts" +import { advancedArticleContentType } from "@/content/advanced-article"; +import { articleContentType } from "@/content/article"; +import { categoryContentType } from "@/content/category"; +import { localizedArticleContentType } from "@/content/localized-article"; +import { pageContentType } from "@/content/page"; + +export const contentTypes = [ + articleContentType, + advancedArticleContentType, + localizedArticleContentType, + categoryContentType, + pageContentType, +]; +``` + +The build compares these URLs with your plugin's routes. A content type whose URL no page serves stops `dev` and `build` with a `content-url-without-page` error, so a broken canonical link never ships. [Plugin files](/docs/content-engine/plugin-files) explains how the build finds this module. + + + + + +### Claim the URL + +Add a page for the same path to the plugin's routes: + +```ts title="plugins/example/src/routes.ts" +export const routes = definePluginRoutes([ + // [!code ++:4] + page("/articles/:slug", { + component: lazy(() => import("./pages/article-page")), + messages: ["@vitnode/example.articles"], + }), +]); +``` + + + + + +### Write the page + +The loader calls two public routes in parallel. `/delivery/resolve/{slug}` says whether the URL is a record, an old slug to redirect, or nothing. `/{slug}` returns the record's public fields. + +```tsx title="plugins/example/src/pages/article-page.tsx" +import { + definePluginRoute, + type PluginRoutePageProps, +} from "@vitnode/core/routing"; +import { + contentDeliveryPage, + contentDeliveryPageHead, +} from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { ContentArticle } from "./content-article"; + +const zodArticle = z.object({ + excerpt: z.string().nullable(), + publishedAt: z.string().nullable(), + title: z.string(), +}); + +const loadArticle = async (slug: string) => { + const [resolution, detail] = await Promise.all([ + fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/articles", + path: "/delivery/resolve/{slug}", + args: { params: { slug: encodeURIComponent(slug) } }, + }), + fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/articles", + path: "/{slug}", + args: { params: { slug: encodeURIComponent(slug) } }, + }), + ]); + + if (resolution.status !== 200) { + throw new Error( + `Resolving the article "${slug}" answered ${resolution.status}.`, + ); + } + + return contentDeliveryPage({ + item: detail.status === 200 ? zodArticle.parse(await detail.json()) : null, + resolution: await resolution.json(), + }); +}; + +type ArticlePageData = Awaited>; + +export const route = definePluginRoute({ + load: async ({ params }) => await loadArticle(params.slug), + + head: ({ loaderData }) => + contentDeliveryPageHead(loaderData?.metadata, { + title: loaderData?.item.title, + }), +}); + +const ArticlePage = ({ + loaderData: { item }, +}: PluginRoutePageProps) => ( + +); + +export default ArticlePage; +``` + +- `contentDeliveryPage` returns `{ item, metadata }`. It throws a not-found for an unknown slug or a draft, and a `308` redirect for a retired slug. +- `contentDeliveryPageHead` sets the title, the meta description (HTML stripped, cut at 160 characters), `robots` for a no-index record and `hreflang` alternates for localized content. +- `load` comes before `head`, so TypeScript can infer `loaderData`. `ContentArticle` is the plugin's own layout component. + + + + + +## Check the result + +Run `pnpm build:plugins`, restart `pnpm dev` and open `http://localhost:3000/articles/hello-content-engine`. Delivery adds no columns, so there is nothing to migrate. + + + +The page head carries the article's title and excerpt: + +```bash +curl -s http://localhost:3000/articles/hello-content-engine | grep -oE '[^<]*|' +``` + +```html +Hello Content Engine - VitNode + +``` + +The resolve route shows what the loader received. `openGraph`, `canonicalPath` and `hreflang` are there too if you want to render more tags yourself: + +```bash +curl http://localhost:3000/api/@vitnode/example/content/articles/delivery/resolve/hello-content-engine +``` + +```json +{ + "type": "content", + "canonicalPath": "/articles/hello-content-engine", + "canonicalInternalPath": "/articles/hello-content-engine", + "seo": { + "title": "Hello Content Engine", + "description": "A first article from the example plugin, served by the public API and rendered on its own page." + }, + "openGraph": { + "title": "Hello Content Engine", + "description": "A first article from the example plugin, served by the public API and rendered on its own page." + }, + "robots": { "follow": true, "index": true }, + "alternates": [], + "hreflang": { "languages": {} }, + "isFallback": false, + "itemId": null, + "locale": null, + "requestedLocale": null +} +``` + +`itemId` is `null` because `id` is not in the article's `publicApi.fields`. Unpublish the article and the same URL shows the app's not-found page. + +## Keep old URLs working + +Renaming a slug breaks links people already shared. With `delivery.redirects`, the old slug is stored in `core_content_slug_history` when an editor changes it, and the resolve route answers `{ "type": "redirect", "status": 308, "location": "/articles/new-slug" }`. `contentDeliveryPage` turns that into a `308` redirect. + +## Sitemap entries + +`delivery.sitemap` lists every published URL for crawlers. [SEO](/docs/content-engine/seo#sitemap) covers the route, paging and turning the entries into XML. + +## Localized URLs + +Every URL delivery builds follows the app's [localized URL rules](/docs/dev/i18n/localized-urls): language prefixes, per-language domains and translated route segments. The blog plugin's posts are a working example. They are [localized](/docs/content-engine/localization) and live at `/blog/:slug`, and the web app translates that route for Polish: + +```ts title="apps/web/src/vitnode.config.ts" +i18n: { + routePaths: { + pl: { + '/blog/:slug': '/wpisy/:slug', + }, + }, +}, +``` + +The blog's sitemap then lists `/blog/english-title` for English and `/pl/wpisy/polish-title` for Polish, and only published translations become `hreflang` alternates. Every `delivery` option is in the [content type reference](/docs/content-engine/reference#delivery). diff --git a/apps/web/content/docs/content-engine/reference.mdx b/apps/web/content/docs/content-engine/reference.mdx new file mode 100644 index 0000000000..41a05f1b72 --- /dev/null +++ b/apps/web/content/docs/content-engine/reference.mdx @@ -0,0 +1,338 @@ +--- +title: Options +description: Every defineContentType option in the VitNode Content Engine, with its type, default, validation rules and the options it depends on. +icon: BookOpen +--- + +`defineContentType` from `@vitnode/core/content` takes one object that describes a content type and returns the resolved definition, with every default filled in. Use this page to look up an option. For a guided first build, follow [Create your first content type](/docs/content-engine/create-a-content-type). + +## Minimal example + +This is `example.article` from the example plugin, trimmed to the options most content types start with: + +```ts title="plugins/example/src/content/article.ts" +import { defineContentType, field } from "@vitnode/core/content"; + +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + }, + publication: true, + publicApi: { + path: "articles", + fields: ["title", "slug", "excerpt", "publishedAt"], + }, + admin: { + path: "example/articles", + titleField: "title", + }, +}); +``` + +## When options are checked + +Validation happens in two places, and both stop the app from starting: + +- **`defineContentType`** checks one content type when its module is imported. Anything wrong in that object throws a `ContentEngineError`. +- **Plugin registration** checks every installed content type together, for things only the whole set can show: two types sharing a table or an AdminCP path. See [Unique across content types](#unique-across-content-types). + +Every message starts with `[Content Engine] :` and names the option at fault: + +```txt +[Content Engine] example.article: publicApi needs `publication: true`. A public API without a draft state would put every row on the internet the moment it is created. +``` + +Many of these rules are type errors too, so your editor often complains before the app does. + +## Top-level options + +| Name | Type | Required | Default | Description | +| -------------- | ----------------------------------- | -------- | ------- | ------------------------------------------------------------------------------- | +| `id` | `string` | Yes | | `plugin.entity`, such as `example.article`. See [Names](#names) | +| `tableName` | `string` | Yes | | Postgres table name, such as `example_articles` | +| `fields` | `Record` | Yes | | At least one field built with a [`field.*` helper](/docs/content-engine/fields) | +| `admin` | [`admin`](#admin) | No | `{}` | AdminCP path, list, form and title | +| `publication` | `boolean` | No | off | Draft and published states | +| `publicApi` | [`publicApi`](#publicapi) | No | off | Read-only public routes | +| `delivery` | `true` \| [`delivery`](#delivery) | No | off | Public page URLs, SEO metadata, redirects and sitemap | +| `editorial` | `true` \| [`editorial`](#editorial) | No | off | Revisions, preview links and scheduling | +| `liveEditing` | `boolean` | No | `false` | [Live editing](#liveediting) in the AdminCP. Needs `editorial` | +| `localization` | [`localization`](#localization) | No | off | A translation table for `localized: true` fields | +| `search` | [`search`](#search) | No | off | Site search indexing | +| `indexes` | [`indexes`](#indexes) | No | `[]` | Extra database indexes | + +A feature block is on when it is present. `publicApi`, `localization` and `search` take an options object. `publication` and `liveEditing` are `true`, and `editorial` and `delivery` take `true` or an options object. Leave a block out, or set it to `false`, to keep it off. Write `true` or the object directly rather than a `boolean` variable, because the generated types read the literal to decide which columns and routes exist. + +### Names + +| Name | Rule | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Matches `/^[a-z0-9]+(?:\.[a-z0-9-]+)+$/`: lowercase, at least two dot-separated segments, dashes allowed after the first. At most 100 characters with `editorial` or `search` | +| `tableName` | snake_case, starts with a letter, at most 63 characters | +| Field names | camelCase, start with a lowercase letter | +| Reserved fields | `id`, `createdAt` and `updatedAt` always. `status` and `publishedAt` with `publication`. `version` with `editorial` | +| Reserved for the list route | `cursor`, `first`, `last`, `order`, `orderBy` and `search`, because they are query parameters of the generated list route. Checked at plugin registration | +| Localized fields | Cannot be named `itemId`, `languageId`, `version`, `createdAt` or `updatedAt` (translation table columns). A localized type cannot expose a public field called `locale` | + +Two names are derived from the id: + +- The **entity key** is the id without its first segment, with further dots turned into `_`. `example.article` becomes `article`. Strings live under `{pluginId}.content.{entityKey}`, for example `@vitnode/example.content.article`. +- The **permission module** is the entity key with anything outside `a-z0-9` turned into `_`, so `example.localized-article` becomes `localized_article`. Permissions are named `@vitnode/example:article:can_view`. Override it with `admin.permissionModule`. + +Every content type gets `can_view`, `can_create`, `can_edit` and `can_delete`. `publication` adds `can_publish` and `editorial` adds `can_restore`. + +## `admin` + +Controls the generated AdminCP screens. Guide: [Customize the AdminCP](/docs/content-engine/admincp). + +| Name | Type | Default | Description | +| ----------------------- | -------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `path` | `string` | The id with dots as slashes (`example/article`) | Screen URL under `/admin/content/`. Lowercase segments of letters, digits and dashes, starting with a letter, joined by `/`. The last segment cannot be `create` or `edit` | +| `permissionModule` | `string` | Derived from the id, see [Names](#names) | Middle part of the permission names. snake_case | +| `titleField` | field name \| `null` | First shared text or textarea field, then first localized one | Names a record in headings, pickers and toasts. A localized field is shown in the reader's language. `null` means no title | +| `colorField` | field name \| `null` | `null` | A shared field holding a color, shown as a swatch beside the title | +| `create.mode` | `"dialog"` \| `"page"` | `"dialog"` | Open the create form in a dialog or on its own page | +| `edit.mode` | `"dialog"` \| `"page"` | `"dialog"` | Same, for the edit form | +| `navigation` | `boolean` | `true` | `false` hides the AdminCP sidebar entry | +| `list.columns` | field names | `status` (with publication), every shared single-column field, `updatedAt` | Table columns, in order. Accepts localized fields, system columns and to-many relation or user fields | +| `list.searchableFields` | field names | Shared text and textarea fields | Fields the search box matches: text, textarea, richText or slug | +| `list.orderableFields` | field names | `[]` | Sortable shared fields. System columns are always sortable and need no entry. No localized or file fields | +| `list.defaultOrderBy` | column name | `"updatedAt"` | A system column or one of `list.orderableFields` | +| `list.defaultOrder` | `"asc"` \| `"desc"` | `"desc"` | | +| `list.thumbnailField` | field name | | A shared single `field.file()` drawn in the title cell. `titleField` must be one of `list.columns` | +| `form.fields` | field names | Every field except `field.blocks()`, in declaration order | Fields in the generated form | +| `form.sections` | `{ name: string; fields: string[] }[]` | `[]` | Groups the form into titled sections. Cannot be combined with `form.fields` | + +A section `name` is lowercase `a-z0-9_` and starts with a letter, because it is the key its heading is read from. Each field goes in one section only, and no section can be empty. + +```ts title="plugins/blog/src/content/post.ts" +admin: { + permissionModule: "posts", + path: "blog/articles", + titleField: "title", + create: { mode: "page" }, + edit: { mode: "page" }, + list: { + columns: ["title", "authorId", "status", "publishedAt", "updatedAt"], + searchableFields: ["title"], + thumbnailField: "coverImage", + }, +}, +``` + +## `publication` + +`publication: true` adds the `status` (`"draft"` or `"published"`) and `publishedAt` columns, and the `can_publish` permission. It has no other options. + +Publication on its own never makes anything public. Only [`publicApi`](#publicapi) does. Guide: [Publish content with a public API](/docs/content-engine/public-api). + +```ts title="plugins/example/src/content/article.ts" +publication: true, +``` + +## `publicApi` + +Generates read-only routes at `/api/{pluginId}/content/{path}` that return published records only. Guide: [Publish content with a public API](/docs/content-engine/public-api). + +| Name | Type | Required | Default | Description | +| ------------------ | ------------------- | -------- | --------------- | ------------------------------------------------------------------------------------------------ | +| `path` | `string` | Yes | | One URL segment matching `/^[a-z][a-z0-9-]*$/`, at most 64 characters, not `admin` | +| `fields` | field names | Yes | | The allowlist, with no wildcard. Must contain exactly one top-level slug field. No duplicates | +| `searchableFields` | field names | No | `[]` | Matched by `?search=`. Text, textarea, richText or slug fields. Rich text matches its words only | +| `filterableFields` | field names | No | `[]` | Each becomes an equality query parameter. Boolean, enum, number, relation, slug or text fields | +| `orderableFields` | field names | No | `[]` | Accepted by `?orderBy=`. `publishedAt` is always added | +| `defaultOrderBy` | field name | No | `"publishedAt"` | Must be orderable | +| `defaultOrder` | `"asc"` \| `"desc"` | No | `"desc"` | | + +What `fields` accepts: + +- Fields of kind text, textarea, richText, slug, number, boolean, enum, dateTime, file, relation and blocks. A file is returned as its public descriptor, never as the file id. +- The columns `id`, `createdAt`, `updatedAt` and `publishedAt`. +- Group and repeatable leaves one at a time, as `"seo.title"`. Naming the whole group throws, so a leaf added later stays private. +- Not `status` (every public row is published), and not `field.user()`, so an author is never published by accident. + +`searchableFields`, `filterableFields` and `orderableFields` must all be in `fields`. Repeatable leaves fit none of them. `orderableFields` also refuses file fields, to-many relations and localized fields. + +```ts title="plugins/example/src/content/article.ts" +publicApi: { + path: "articles", + fields: ["title", "slug", "excerpt", "featured", "category", "noIndex", "publishedAt"], + searchableFields: ["title", "excerpt"], + orderableFields: ["publishedAt", "title"], + filterableFields: ["category", "featured"], +}, +``` + +## `delivery` + +Gives each published record a public page: canonical URL, SEO metadata, redirects from old slugs and a sitemap. Guides: [Public pages](/docs/content-engine/public-pages) and [SEO](/docs/content-engine/seo). + +| Name | Type | Default | Description | +| -------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `path` | `string` | `/{publicApi.path}/:slug` | The page route. Exactly one `:slug`, no other parameters or `*`, not under `/admin` or `/api`, at most 512 characters | +| `redirects` | `boolean` | off | Old slugs answer `308` with the current URL | +| `seo.titleField` | text field | | Page title | +| `seo.fallbackTitleField` | text field | | Used when `titleField` is empty. Needs `titleField` | +| `seo.descriptionField` | text, textarea or richText field | | Meta description. Rich text becomes plain text, and the page trims it to 160 characters | +| `seo.fallbackDescriptionField` | text, textarea or richText field | | Used when `descriptionField` is empty. Needs `descriptionField` | +| `seo.noIndexField` | shared boolean field | | `true` marks the page `noindex` and leaves it out of the sitemap | +| `seo.openGraph.titleField` | text field | | `og:title` | +| `seo.openGraph.descriptionField` | text, textarea or richText field | | `og:description` | +| `sitemap` | `true` \| `{ changeFrequency, priority }` | off | Lists published URLs | +| `sitemap.changeFrequency` | `"always"` \| `"hourly"` \| `"daily"` \| `"weekly"` \| `"monthly"` \| `"yearly"` \| `"never"` | | | +| `sitemap.priority` | `number` | | From 0 to 1 inclusive | +| `hreflang.xDefault` | `"defaultLocale"` | | Adds an `x-default` alternate pointing at the default language | + +Every SEO field must be in `publicApi.fields`, may be a group leaf such as `"seo.title"`, and cannot be a repeatable leaf. `noIndexField` must be shared, not localized. + +```ts title="plugins/example/src/content/article.ts" +delivery: { + redirects: true, + seo: { + titleField: "title", + descriptionField: "excerpt", + noIndexField: "noIndex", + openGraph: { titleField: "title", descriptionField: "excerpt" }, + }, + sitemap: { changeFrequency: "weekly", priority: 0.7 }, +}, +``` + +## `editorial` + +Adds a `version` column, revision history and the `can_restore` permission. [`liveEditing`](#liveediting) builds on it. Guide: [Revisions, previews and scheduling](/docs/content-engine/editorial). + +| Name | Type | Default | Description | +| -------------------------- | ---------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------- | +| `revisions.retention` | `number` | `50` | Newest revisions kept per record. A whole number from 1 to 500 | +| `preview` | `true` \| `{ expiresInMinutes, pathTemplate }` | off | Signed [preview links](/docs/content-engine/editorial#preview-links) for drafts | +| `preview.expiresInMinutes` | `number` | `15` | Link lifetime. A whole number from 1 to 1440 | +| `preview.pathTemplate` | `string` | | Your own preview URL. Starts with `/`, contains `{token}` exactly once and no other placeholder, at most 512 characters | +| `scheduling` | `boolean` | off | Publish or unpublish at a chosen time | + +```ts title="plugins/example/src/content/article.ts" +editorial: { + revisions: { retention: 20 }, + preview: { expiresInMinutes: 30 }, + scheduling: true, +}, +``` + +## `localization` + +Creates a `{tableName}_translations` table for fields marked `localized: true`. Guide: [Translate content](/docs/content-engine/localization). + +| Name | Type | Default | Description | +| --------------- | ----------------------- | -------- | ----------------------------------------------------------------------------------------------------- | +| `defaultLocale` | `string` | | Required. The language every record is created in, such as `"en"` or `"pt-BR"`. At most 32 characters | +| `fallback` | `"none"` \| `"default"` | `"none"` | `"default"` serves the default language when a translation is missing | + +Only text, textarea, richText, slug, blocks and group fields can be localized. A localized slug needs a localized source field, and a shared slug needs a shared one. + +```ts title="plugins/example/src/content/localized-article.ts" +localization: { + defaultLocale: "en", + fallback: "default", +}, +``` + +## `search` + +Keeps published records in the [site search index](/docs/dev/search). Drafts never show up in public results. Guide: [Search](/docs/content-engine/search). + +| Name | Type | Required | Description | +| ------------------ | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `titleField` | field name | Yes | The result heading. A text field that is not nullable | +| `contentFields` | field names | Yes | The indexed body, at least one, no duplicates. Text, textarea, richText or slug. Repeatable leaves such as `"faq.answer"` are allowed | +| `pathTemplate` | `string` | Yes | The result URL. Starts with `/`, contains `{slug}` exactly once, at most 512 characters | +| `descriptionField` | field name | No | Shown in result excerpts. Text, textarea or richText | +| `authorField` | field name | No | A top-level `field.user()` credited in the index. It does not have to be public | + +Every field except `authorField` must be in `publicApi.fields`, or a result snippet would leak a private value. Leave the language out of `pathTemplate`, because your [i18n settings](/docs/dev/i18n) add the locale prefix. + +```ts title="plugins/example/src/content/article.ts" +search: { + titleField: "title", + descriptionField: "excerpt", + contentFields: ["title", "excerpt"], + pathTemplate: "/articles/{slug}", + authorField: "author", +}, +``` + +## `indexes` + +Extra database indexes on the base table. The engine already indexes slugs, `unique: true` text fields, foreign keys, `createdAt`, `updatedAt` and `(status, publishedAt)`. A declared index on the same columns replaces the generated one instead of adding a second. Guide: [Database and migrations](/docs/content-engine/database). + +| Name | Type | Required | Default | Description | +| -------- | ---------- | -------- | -------------------------------------------------- | --------------------------------- | +| `on` | `string[]` | Yes | | At least one column, no repeats | +| `unique` | `boolean` | No | `false` | | +| `name` | `string` | No | `{tableName}_{columns}_idx`, or `_key` when unique | snake_case, at most 63 characters | + +`on` accepts shared fields, group leaves such as `"syndication.priority"`, and system columns (`id`, `createdAt`, `updatedAt`, plus `status`, `publishedAt` and `version` when their feature is on). It refuses localized fields, `field.blocks()`, repeatable leaves, to-many relations and file collections, and whole groups. Two indexes on the same columns throw. + +```ts title="plugins/example/src/content/advanced-article.ts" +indexes: [{ on: ["syndication.priority"] }], +``` + +## `liveEditing` + +`liveEditing: true` lets several editors work on one record at once in the AdminCP edit form: field locks, an autosaved shared draft, presence and rich text co-editing. It needs `editorial`, because locks and drafts are measured against the record version. It adds the `/{id}/locks` and `/{id}/draft` staff routes. Guide: [Live editing](/docs/content-engine/live-editing). + +```ts title="plugins/example/src/content/article.ts" +editorial: { revisions: { retention: 20 } }, +liveEditing: true, +``` + +AI suggestions are not a `defineContentType` option. They are set per field with the `ai` option of `field.text()` and `field.textarea()`. See [Fields](/docs/content-engine/fields) and [AI actions](/docs/dev/ai/actions). + +## Which options need which + +Each row is a rule `defineContentType` enforces, with the start of the message it throws (after the `[Content Engine] :` prefix). + +| Option | Needs | Error | +| ------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `publicApi` | `publication` | ``publicApi needs `publication: true`.`` | +| `publicApi.fields` | Exactly one top-level slug field | `publicApi.fields must expose exactly one slug field` | +| `search` | `publication` | ``search needs `publication: true`.`` | +| `search` | `publicApi` | ``search needs `publicApi: { path, fields }`.`` | +| `search.*Field`, `search.contentFields` | The field in `publicApi.fields` | `search.titleField names "x", which is not in publicApi.fields.` | +| `delivery` | `publicApi` | ``delivery needs `publicApi: { path, fields }`.`` | +| `delivery` on a localized type | `"id"` in `publicApi.fields` | `delivery on a localized content type needs "id" in publicApi.fields.` | +| `liveEditing` | `editorial` | ``liveEditing needs `editorial`.`` | +| `delivery.redirects` | `editorial` | ``delivery.redirects needs `editorial`.`` | +| `delivery.redirects` on a localized type | A localized slug field | `delivery.redirects needs a localized slug field on a localized content type, but "slug" is shared.` | +| `delivery.hreflang` | `localization` | ``delivery.hreflang needs `localization: { defaultLocale }`.`` | +| `delivery.seo.*` | The field in `publicApi.fields` | `delivery.seo.titleField names "x", which is not in publicApi.fields.` | +| `delivery.seo.fallbackTitleField` | `delivery.seo.titleField` | ``delivery.seo.fallbackTitleField is set without `titleField`.`` | +| `delivery.seo.fallbackDescriptionField` | `delivery.seo.descriptionField` | ``delivery.seo.fallbackDescriptionField is set without `descriptionField`.`` | +| `editorial.preview` | `publicApi` | ``editorial.preview needs `publicApi: { path, fields }`.`` | +| `editorial.scheduling` | `publication` | ``editorial.scheduling needs `publication: true`.`` | +| A `localized: true` field | `localization` | ``Field "title" is `localized: true` but the content type has no `localization: { defaultLocale }` block`` | +| `localization` | At least one `localized: true` field | ``localization is enabled but no field is marked `localized: true`,`` | +| Any block with an `enabled` key | Nothing: a block is on when present | `publicApi.enabled is not an option.` | +| `publication` or `liveEditing` as an object | `true` or `false` | ``publication is `true` or `false`.`` | +| `admin.list.thumbnailField` | `admin.titleField` in `admin.list.columns` | `admin.list.thumbnailField is drawn in the title column, but admin.titleField` | +| `admin.form.sections` | No `admin.form.fields` | ``admin.form declares both `fields` and `sections`.`` | +| `admin.list.defaultOrderBy` | A system column or an `admin.list.orderableFields` entry | `admin.list.defaultOrderBy is "x", which is not in admin.list.orderableFields.` | +| `publicApi.defaultOrderBy` | An orderable field | `publicApi.defaultOrderBy is "x", which is not in publicApi.orderableFields.` | +| `publicApi.searchableFields`, `filterableFields`, `orderableFields` | The field in `publicApi.fields` | `publicApi.searchableFields includes "x", which is not in publicApi.fields.` | + +### Unique across content types + +Plugin registration checks these across every installed content type: + +| Value | Unique within | Error starts with | +| ------------------------------------- | ----------------------------- | ------------------------------------------ | +| `id` | The app | `Duplicate content type id` | +| Table names, including generated ones | The database | `Table "x" is claimed by both` | +| Index names, including generated ones | The database | `Index name "x" is used by both` | +| `admin.path` | The app | `AdminCP path "x" is claimed by both` | +| `permissionModule` | The plugin | `Permission module "x" is derived by both` | +| `publicApi.path` | The plugin | `Public path "x" is claimed by both` | +| `publicApi.path` with `delivery` | The app, among delivery types | `Delivery path "x" is claimed by both` | diff --git a/apps/web/content/docs/content-engine/relations-advanced-article-page.webp b/apps/web/content/docs/content-engine/relations-advanced-article-page.webp new file mode 100644 index 0000000000..db5f5e3eca Binary files /dev/null and b/apps/web/content/docs/content-engine/relations-advanced-article-page.webp differ diff --git a/apps/web/content/docs/content-engine/relations-category-picker.webp b/apps/web/content/docs/content-engine/relations-category-picker.webp new file mode 100644 index 0000000000..3649bb91a3 Binary files /dev/null and b/apps/web/content/docs/content-engine/relations-category-picker.webp differ diff --git a/apps/web/content/docs/content-engine/relations-many-picker.webp b/apps/web/content/docs/content-engine/relations-many-picker.webp new file mode 100644 index 0000000000..5955f69ba0 Binary files /dev/null and b/apps/web/content/docs/content-engine/relations-many-picker.webp differ diff --git a/apps/web/content/docs/content-engine/relations.mdx b/apps/web/content/docs/content-engine/relations.mdx new file mode 100644 index 0000000000..ca19a5a015 --- /dev/null +++ b/apps/web/content/docs/content-engine/relations.mdx @@ -0,0 +1,249 @@ +--- +title: Relations +description: Link Content Engine records with field.relation, group related fields with field.group and store repeatable rows with field.repeatable, all in real Postgres tables. +icon: Network +--- + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; +import { ImgDocs } from "@/components/fumadocs/img"; +import categoryPicker from "./relations-category-picker.webp"; +import manyPicker from "./relations-many-picker.webp"; +import advancedPage from "./relations-advanced-article-page.webp"; + +Relations connect one record to others, and two related helpers shape a record's own data. Everything lands in real tables, not a JSON blob: a relation is a foreign key, a to-many relation is a junction table, a group is a set of columns and a repeatable field is a child table. This guide walks through `example.advanced-article` from the example plugin, which uses all four. + +## Before you begin + +You need a working content type to link to, such as `example.category` from [Create a content type](/docs/content-engine/create-a-content-type). + + + + + +### Link to one record + +Point an article at a category with `field.relation`. `target` is a function, so two content types can refer to each other without an import cycle: + +```ts title="plugins/example/src/content/article.ts" +import { categoryContentType } from "./category"; + +fields: { + category: field.relation({ // [!code ++:5] + required: true, + onDelete: "restrict", + target: () => categoryContentType, + }), +}, +``` + +Then tell `createContentModel` which column the foreign key references: + +```ts title="plugins/example/src/database/articles.ts" +import { example_categories } from "./categories"; + +export const articleContent = createContentModel(articleContentType, { + references: { category: () => example_categories.id }, // [!code ++] +}); +``` + +`references` needs one entry per relation field. A missing or extra key is a type error. `field.user()`, `field.file()` and `self: true` relations find their table on their own and need no entry. + + + + + +### Link to many records + +Add `multiple: true`. The advanced article has two to-many relations: a set of categories and an ordered "read next" list that points back at articles: + +```ts title="plugins/example/src/content/advanced-article.ts" +fields: { + categories: field.relation({ + multiple: true, + onDelete: "restrict", + target: () => categoryContentType, + }), + relatedArticles: field.relation({ + multiple: true, + onDelete: "cascade", + ordered: true, + self: true, + }), +}, +``` + +`ordered: true` keeps the order editors arrange. Without it, the set is stored in ascending id order. `onDelete: "cascade"` on a to-many relation drops the link when the target is deleted, and `"restrict"` refuses the delete. A to-many relation cannot be `required` or `nullable`; use `min: 1` to demand at least one target. + +Each to-many field gets a junction table with `itemId`, `relatedItemId`, `position` and `createdAt`. Give `categories` its reference and export both junction tables, or Drizzle Kit leaves them out of the migration: + +```ts title="plugins/example/src/database/advanced-articles.ts" +export const advancedArticleContent = createContentModel( + advancedArticleContentType, + { references: { categories: () => example_categories.id } }, +); + +export const example_advanced_articles = advancedArticleContent.table; +export const example_advanced_articles_categories = // [!code ++:4] + advancedArticleContent.advancedTables.junctions.categories; +export const example_advanced_articles_related_articles = + advancedArticleContent.advancedTables.junctions.relatedArticles; +``` + + + + + +### Group related fields + +`field.group` keeps fields that belong together under one key: + +```ts title="plugins/example/src/content/advanced-article.ts" +syndication: field.group({ + fields: { + indexable: field.boolean({ defaultValue: true }), + noIndex: field.boolean({ defaultValue: false }), + priority: field.number({ integer: true, min: 0, max: 10, defaultValue: 5 }), + }, +}), +``` + +A group adds one column per leaf to the main table: `syndicationIndexable`, `syndicationNoIndex` and `syndicationPriority`. The API reads and writes it as `{ syndication: { indexable, noIndex, priority } }`, and other options refer to a leaf by path, such as `indexes: [{ on: ["syndication.priority"] }]`. Leaves can be `text`, `textarea`, `number`, `boolean`, `enum` or `dateTime` fields. An optional group needs leaves that are nullable or have a default. + + + + + +### Add repeatable rows + +`field.repeatable` stores an ordered list of small rows, such as FAQ entries: + +```ts title="plugins/example/src/content/advanced-article.ts" +faq: field.repeatable({ + max: 20, + fields: { + question: field.text({ required: true, minLength: 3, maxLength: 200 }), + answer: field.textarea({ required: true }), + }, +}), +``` + +The rows go to a child table, `example_advanced_articles_faq`, with `id`, `itemId`, `position`, `createdAt`, `updatedAt` and one column per leaf. Deleting the article deletes its rows. Export the child table too: + +```ts title="plugins/example/src/database/advanced-articles.ts" +export const example_advanced_articles_faq = + advancedArticleContent.advancedTables.repeatables.faq; +``` + + + + + +### Build and migrate + + + +```bash tab="bun" +bun run build:plugins && bun run db:migrate +``` + +```bash tab="pnpm" +pnpm build:plugins && pnpm db:migrate +``` + +```bash tab="npm" +npm run build:plugins && npm run db:migrate +``` + + + +The generated migration creates one table per junction and repeatable field. + + + + + +## Write related values + +A relation takes ids. A to-many relation and a repeatable take the whole list, which replaces what the record had: + +```json +{ + "categories": [2, 4], + "syndication": { "priority": 7 }, + "faq": [ + { + "id": 1, + "question": "Can I freeze the batter?", + "answer": "Yes, for up to a month." + }, + { "question": "Do I need buttermilk?", "answer": "No, regular milk works." } + ] +} +``` + +A repeatable row with an `id` updates that row, a row without one is created, and rows you leave out are deleted. The API refuses an `id` that belongs to another record with `CONTENT_REPEATABLE_UNKNOWN_CHILD`. To change one link or one row without sending the list, use the [content service](/docs/content-engine/services): `relations.categories.add()`, `.remove()`, `.set()`, `.reorder()` and `.get()`, or `repeatable.faq.create()`, `.update()`, `.delete()`, `.reorder()`, `.set()` and `.list()`. + +## Check the result + +In the AdminCP form, a single relation is a search picker. This is the article's **Category** field: + + + +A to-many relation shows each target as a chip you can remove. Here is the blog plugin's **Categories** field, a `field.relation({ multiple: true })`: + + + +A group renders as a fieldset with its leaf controls, and a repeatable as a list of rows with move up, move down and remove buttons plus an **Add** button. The example plugin gives the advanced article API routes but no AdminCP screen, so test it through the API. Its public API returns the related values like this: + +```bash +curl "http://localhost:3000/api/@vitnode/example/content/advanced-articles/weekend-pancakes?locale=en" +``` + +```json +{ + "id": 1, + "title": "Weekend pancakes", + "slug": "weekend-pancakes", + "categories": [2, 4], + "seo": { + "title": "Fluffy weekend pancakes", + "description": "Ready in 20 minutes." + }, + "syndication": { "priority": 7, "noIndex": false }, + "faq": [ + { + "id": 1, + "question": "Can I freeze the batter?", + "answer": "Yes, for up to a month." + }, + { + "id": 2, + "question": "Do I need buttermilk?", + "answer": "No, regular milk works." + } + ], + "publishedAt": "2026-10-10T10:57:42.767Z", + "locale": "en" +} +``` + +A to-many relation comes back as a list of ids, and a single relation as `{ "id": 2 }`. Only the leaves listed in `publicApi.fields`, such as `"faq.question"`, are public. The example plugin's page at `/advanced-articles/weekend-pancakes` renders the FAQ rows: + + + +Every option of these helpers is in the [Fields](/docs/content-engine/fields#relation) reference. diff --git a/apps/web/content/docs/content-engine/rich-text-editor.webp b/apps/web/content/docs/content-engine/rich-text-editor.webp new file mode 100644 index 0000000000..667cefc2f3 Binary files /dev/null and b/apps/web/content/docs/content-engine/rich-text-editor.webp differ diff --git a/apps/web/content/docs/content-engine/rich-text.mdx b/apps/web/content/docs/content-engine/rich-text.mdx new file mode 100644 index 0000000000..4b592d470b --- /dev/null +++ b/apps/web/content/docs/content-engine/rich-text.mdx @@ -0,0 +1,145 @@ +--- +title: Rich text +description: Store formatted Content Engine text with field.richText, render it on a public page with RichTextContent, and turn it into plain text or HTML. +icon: Pilcrow +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import editor from "./rich-text-editor.webp"; + +`field.richText()` is the Content Engine field for formatted text: headings, lists, links, tables, task lists and panels. Editors write it in the AdminCP with the Tiptap editor. Readers get plain HTML from `RichTextContent`, a small React component that never loads the editor, so the public page stays light. Your readers came for the article, not for an editor they will never type in. + +## Define a rich text field + +Add the field to a content type. The blog plugin's article body is the typical case: + +```ts title="plugins/blog/src/content/post.ts" +import { defineContentType, field } from "@vitnode/core/content"; + +export const blogPostContentType = defineContentType({ + id: "blog.post", + tableName: "blog_posts", + fields: { + title: field.text({ + localized: true, + required: true, + minLength: 3, + maxLength: 255, + }), + content: field.richText({ localized: true, required: true }), // [!code highlight] + }, +}); +``` + +The excerpt leaves out the blog's `localization` block, which `localized: true` needs; see [Translations](/docs/content-engine/localization). + +The column is `jsonb`, and the value is a ProseMirror document, the same JSON that Tiptap's `editor.getJSON()` returns: + +```json +{ + "type": "doc", + "content": [ + { "type": "paragraph", "content": [{ "type": "text", "text": "Hello!" }] } + ] +} +``` + +The Content Engine validates every write. The value must be a `doc`, nest at most 64 levels deep, and fit in `maxBytes` as UTF-8 JSON (1 MB by default, up to 16 MB). With `required: true`, a document without any text or media is refused, so an empty editor cannot be saved. A rich text field has no default, so set `required` or `nullable`. + +The AdminCP form shows the editor for the field, and it loads the editor only when the form has a rich text field: + + + +## Render it on a public page + +Import `RichTextContent` from `@vitnode/core/components/rich-text` and pass the document. It works during SSR and in the browser. Validate the API response with `zodRichTextDocument`: + +```tsx title="plugins/blog/src/pages/post-page.tsx" +import { RichTextContent } from "@vitnode/core/components/rich-text"; +import { zodRichTextDocument } from "@vitnode/core/content"; +import { z } from "zod"; + +const zodBlogPost = z.object({ + content: zodRichTextDocument, + title: z.string(), +}); + +const BlogPostPage = ({ item }: { item: z.infer }) => ( +
+

{item.title}

+ +
+); +``` + +The output is wrapped in `
` and uses the same classes as the editor, such as `list-disc`, `tableWrapper` and `tiptap-panel`. Import `@vitnode/core/styles/tiptap.css` in your app stylesheet to style it, as `apps/web/src/styles.css` does. + +`RichTextContent` treats the document as untrusted input: + +- Links keep only `http:`, `https:`, `mailto:`, `tel:`, relative and `#` targets. A `javascript:` or `data:` link renders as plain text. +- Images and other media keep only `http:`, `https:` and relative sources. +- A link that opens a new tab gets `rel="noopener noreferrer nofollow"`. +- Inline styles come from an allowlist: text colors, font sizes with a unit and text alignment. +- An unknown node renders its children, and an unknown mark renders plain text. + +## Replace a renderer + +Pass `nodes` or `marks` to change how one type renders. The keys are ProseMirror type names, and each renderer receives the node (or mark) and its already rendered children: + +```tsx + + node.attrs?.level === 2 ? ( +

{children}

+ ) : ( +

{children}

+ ), + }} +/> +``` + +A custom renderer is your code, so the built-in link and style allowlists do not run for it. Check anything it reads from `attrs`; `sanitizeRichTextHref` from `@vitnode/core/components/rich-text` cleans a link. + +## Get plain text or HTML + +Three helpers work on the server and in the browser without React. Import them from `@vitnode/core/content` or `@vitnode/core/components/rich-text`: + +| Helper | Returns | Use it for | +| -------------------------- | --------- | ------------------------------------------------------------------------ | +| `richTextToPlainText(doc)` | `string` | Search, excerpts, AI prompts, word counts. One line per block, no markup | +| `richTextToHtml(doc)` | `string` | Emails and feeds. Escaped, with the same allowlists as `RichTextContent` | +| `isRichTextEmpty(doc)` | `boolean` | Checking whether a document has any text or media | + +```ts +import { richTextToPlainText } from "@vitnode/core/content"; + +const words = richTextToPlainText(post.content).split(/\s+/).length; +``` + +The Content Engine already uses them. Site search indexes the words of a rich text field listed in `search.contentFields`. A rich text field named in `delivery.seo.descriptionField` or `fallbackDescriptionField` becomes a plain text description, which is how the blog fills its meta description from `content` when an article has no excerpt. The revision history compares documents as text. + +## Use the editor in your own form + +Outside the Content Engine, `AutoFormEditor` from `@vitnode/core/components/form/fields/editor` stores an HTML string by default. Add `format="json"` to store a ProseMirror document instead: + +```tsx + +``` + +The [Editor](/docs/ui/editor) page covers the component itself. + +## Check the result + +Open **AdminCP → Blog → Articles → Create Article**, write a heading and a list in **Content** and save. The public article shows the same structure, rendered by `RichTextContent` without loading Tiptap, ProseMirror or Yjs. + +Several editors can type into the same rich text field at once; [Live editing](/docs/content-engine/live-editing) covers co-editing and presence. diff --git a/apps/web/content/docs/content-engine/search-admincp-collection.webp b/apps/web/content/docs/content-engine/search-admincp-collection.webp new file mode 100644 index 0000000000..f80c016f38 Binary files /dev/null and b/apps/web/content/docs/content-engine/search-admincp-collection.webp differ diff --git a/apps/web/content/docs/content-engine/search-results.webp b/apps/web/content/docs/content-engine/search-results.webp new file mode 100644 index 0000000000..bed8d602df Binary files /dev/null and b/apps/web/content/docs/content-engine/search-results.webp differ diff --git a/apps/web/content/docs/content-engine/search.mdx b/apps/web/content/docs/content-engine/search.mdx new file mode 100644 index 0000000000..7caf0b1385 --- /dev/null +++ b/apps/web/content/docs/content-engine/search.mdx @@ -0,0 +1,146 @@ +--- +title: Search +description: Add a Content Engine content type to the VitNode site search, keep the search index in sync with published records, and rebuild it from the AdminCP. +icon: Search +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import searchAdmincpCollection from "./search-admincp-collection.webp"; +import searchResults from "./search-results.webp"; + +Add a `search` block to a content type and its published records show up on the site search page at `/search`. VitNode writes a search document after every save, publish and delete, so you never call the search engine yourself. This guide turns search on for `example.article` from the example plugin. + +## Before you begin + +The content type needs [publication and a public API](/docs/content-engine/public-api), because a search result is a link to a public page and its text has to be public already. Without them, the definition throws when it loads: ``search needs `publication: true`.`` or ``search needs `publicApi: { path, fields }`.`` + +It also needs a page that serves the result URL. [Public pages](/docs/content-engine/public-pages) shows how to add one. + + + + + +### Add the search block + +Add `search` to the definition. This excerpt leaves out the fields and blocks search does not read: + +```ts title="plugins/example/src/content/article.ts" +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + author: field.user(), + }, + publication: true, + publicApi: { + path: "articles", + fields: ["title", "slug", "excerpt", "publishedAt"], + }, + // [!code ++:7] + search: { + titleField: "title", + descriptionField: "excerpt", + contentFields: ["title", "excerpt"], + pathTemplate: "/articles/{slug}", + authorField: "author", + }, +}); +``` + +- `titleField` is the result heading. It must be a `field.text()` that is not nullable. +- `descriptionField` is optional and leads the result excerpt. Text, textarea or rich text. +- `contentFields` is the searchable body. It takes at least one field and no duplicates. Text, textarea, rich text or slug fields, plus repeatable leaves such as `"faq.answer"`. Rich text is indexed as its words, never as JSON. The title always ranks higher than the body, so listing it here as well only repeats it in the excerpt. +- `pathTemplate` is the result URL. It starts with `/` and contains `{slug}` exactly once. Leave the language out, because your i18n settings add it (see [Localized content types](#localized-content-types)). +- `authorField` is optional and credits a top-level `field.user()` on [member timelines](/docs/dev/search#member-timelines). + +Every field except `authorField` must be in `publicApi.fields`. Otherwise a result snippet would leak a private value, so the definition throws instead: `search.titleField names "x", which is not in publicApi.fields.` `authorField` may stay private, which is why `author` is not in the public list above. All options are in the [reference](/docs/content-engine/reference#search). + + + + + +### Index the records you already have + +New writes are indexed from now on, but records saved before the `search` block existed are not. Open **AdminCP → Advanced → Search** (`/admin/core/advanced/search`), type `example.article` into **Search collections…**, and click **Reindex** on that row. **Rebuild index** at the top rebuilds every collection. + + + +Both buttons need the **Rebuild and clear the search index** staff permission (`system` → `can_manage_search`). A rebuild clears the collection and refills it in pages of 200 records. It is a queued job, so it only runs while [cron](/docs/dev/cron) is running. + +**Items** compares indexed documents with records in the table, drafts included. Content Engine collections are all labelled **Content** for now, so filter by the content type id to find yours. + + + + + +## What gets indexed and when + +Every record is in the index, but only public ones show up on `/search`. A draft is stored as a private document without a URL. With `authorField`, it appears on its author's own timeline and on the AdminCP user page, but never in public results. + +| Write | Search index | +| ------------------------------------------------------------------------ | ----------------------------- | +| Create, edit, publish, unpublish, restore or delete in the AdminCP | Updated right after the write | +| A scheduled publish or unpublish | Updated when the job runs | +| Your own route using the editorial service and `contentEditorialEffects` | Updated | +| The plain `service` from `model.service(c)` | Not updated | + +An edit rewrites the document only when an indexed field changed: the title, description, content fields, slug or author. Unpublishing keeps the document but makes it private, so the record drops out of `/search` at once. Deleting the record removes its document. + +The plain service writes rows and nothing else. To change records from your own code and keep search in sync, follow [a "Feature this article" button](/docs/content-engine/services#example-3-a-feature-this-article-button). + +If the search engine fails, the save still succeeds. The failure is logged with a `[content-search]` prefix and listed under **Recent sync failures** on the AdminCP Search screen. Reindex the collection to repair it. + +## Localized content types + +A [localized](/docs/content-engine/localization) content type such as `example.localized-article` gets one document per translation, with the language stored on it. A translation is public only when both the record and that translation are published. Deleting a translation removes only that language's document. Deleting the record removes all of them. + +`/search` shows documents in the visitor's language. Documents of content types that are not localized match every language. With `localization.fallback: "default"`, a visitor whose language has no published translation sees the default language's document instead. + +Each document gets the URL of its own language. `pathTemplate` stays the English route, and VitNode adds the prefix, domain and translated segments from your [localized URL settings](/docs/dev/i18n/localized-urls). The table in [Result URLs for Content Engine types](/docs/dev/search#result-urls-for-content-engine-types) shows what each setting produces. URLs are stored at indexing time, so reindex after changing `pathTemplate` or those settings. + +## Check the result + +1. Publish an article in **AdminCP → Example → Articles**. +2. Open `/search` and type a whole word or two from its title or excerpt. The result links to `/articles/{slug}`. + + + +The same results come from the public search API. `types` limits them to one content type: + +```bash +curl "http://localhost:3000/api/@vitnode/core/search?search=hello&types=example.article" +``` + +```json +{ + "edges": [ + { + "pluginId": "@vitnode/example", + "itemType": "example.article", + "itemId": 3, + "languageCode": "", + "title": "Hello Content Engine", + "content": "Hello Content Engine A first article from the example plugin, served by the public API and rendered on its own page.", + "url": "/articles/hello-content-engine", + "isPublic": true + } + ] +} +``` + +Unpublish the article and run the request again. This time `edges` is empty. If a record you published before adding `search` is missing, reindex the collection. + +The index runs on Postgres full-text search by default. To switch engines, or to index data that is not a content type, see [Search & Discovery](/docs/dev/search) and [Elasticsearch](/docs/dev/search-elasticsearch). diff --git a/apps/web/content/docs/content-engine/seo-blog-sheet.webp b/apps/web/content/docs/content-engine/seo-blog-sheet.webp new file mode 100644 index 0000000000..335c0b21ad Binary files /dev/null and b/apps/web/content/docs/content-engine/seo-blog-sheet.webp differ diff --git a/apps/web/content/docs/content-engine/seo.mdx b/apps/web/content/docs/content-engine/seo.mdx new file mode 100644 index 0000000000..bc933238df --- /dev/null +++ b/apps/web/content/docs/content-engine/seo.mdx @@ -0,0 +1,250 @@ +--- +title: SEO +description: Set the page title, meta description, noindex, hreflang alternates and sitemap entries of VitNode Content Engine record pages with delivery.seo and delivery.sitemap. +icon: SearchCheck +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import blogSeoSheet from "./seo-blog-sheet.webp"; + +Once a content type has [public pages](/docs/content-engine/public-pages), `delivery.seo` decides which fields fill each page's ``, meta description and `robots` tag, and `delivery.sitemap` lists the pages for crawlers. You name fields; the Content Engine reads them from every published record, so editors never fill in a separate SEO form unless you give them one. + +The examples use `example.article` from the example plugin and `blog.post` from the blog plugin, which is localized. + +## Pick the SEO fields + +`example.article` uses its title and excerpt, plus a boolean that hides a record from search engines: + +```ts title="plugins/example/src/content/article.ts" +fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + noIndex: field.boolean({ nullable: true }), +}, + +publicApi: { + path: "articles", + fields: ["title", "slug", "excerpt", "noIndex", "publishedAt"], +}, + +delivery: { + redirects: true, + seo: { + titleField: "title", + descriptionField: "excerpt", + noIndexField: "noIndex", + openGraph: { titleField: "title", descriptionField: "excerpt" }, + }, + sitemap: { changeFrequency: "weekly", priority: 0.7 }, +}, +``` + +| Option | Field kinds | Used for | +| -------------------------------- | ------------------------ | ------------------------------------------------------------ | +| `titleField` | text | Page title | +| `fallbackTitleField` | text | Page title when `titleField` is empty. Needs `titleField` | +| `descriptionField` | text, textarea, richText | Meta description | +| `fallbackDescriptionField` | text, textarea, richText | Description when `descriptionField` is empty | +| `noIndexField` | boolean, not localized | `robots` tag and sitemap, see [noindex](#hide-a-record-from-search-engines) | +| `openGraph.titleField` | text | Open Graph title, falls back to the page title | +| `openGraph.descriptionField` | text, textarea, richText | Open Graph description, falls back to the page description | + +Every field you name must also be in `publicApi.fields`, because the page shows it to anyone. Leave one out and the definition throws at startup with `... which is not in publicApi.fields`. A field inside a group works (`"seo.title"`). A field inside a repeatable does not, since a page has one title, not a list of them. + +Values are trimmed, and an empty one counts as missing, so the fallback kicks in. The blog uses that to describe posts that have no excerpt: + +```ts title="plugins/blog/src/content/post.ts" +delivery: { + seo: { + titleField: "title", + descriptionField: "excerpt", + fallbackDescriptionField: "content", + }, +}, +``` + +`content` is rich text. The Content Engine turns it into plain text, and the page cuts it to 160 characters at a word boundary and adds `…`. No markup ever lands in a meta tag. + +The blog plugin's own article editor checks the fields this rule reads in its **SEO** sheet. The sheet belongs to the blog plugin, not to every Content Engine form. Here the post has no excerpt, so the **Excerpt** row is marked as missing, and search results would fall back to the content. + +<ImgDocs + withoutBackground + className="p-4 *:max-w-sm" + src={blogSeoSheet} + alt="Blog plugin SEO sheet with a Ready to publish bar at 4 of 7, a Title row with a length bar at 37 of 60, a missing Excerpt with a Write with AI button, a URL row and a Social image row" +/> + +## What reaches the page head + +The page's `head` calls `contentDeliveryPageHead` with the resolve route's answer, as shown in [Write the page](/docs/content-engine/public-pages#write-the-page). This is what the HTML gets today: + +| Tag | Rendered | +| ------------------------------------------- | ------------------------------------------- | +| `<title>` | Yes, followed by the site title (`- VitNode`) | +| `<meta name="description">` | Yes, at most 160 characters | +| `<meta name="robots">` | Only for a no-index record | +| `<link rel="canonical">` and `hreflang` links | Only for a localized content type | +| `og:title`, `og:description` | No | + +<Callout type="warn"> + The resolve route returns `openGraph` and `canonicalPath` for every record, but nothing turns them into tags yet. A nonlocalized type such as `example.article` gets no canonical link, and no content type gets Open Graph tags. A plugin page cannot add them either, because its `head` only accepts `title`, `description`, `robots` and `alternates`. Keep `openGraph` in your definition so the data is ready, and read it from the resolve route if you render pages outside VitNode. +</Callout> + +## Hide a record from search engines + +`noIndexField` names one boolean that drives two things together: + +- the page gets `<meta content="noindex, nofollow" name="robots"/>`; +- the record leaves the [sitemap](#sitemap). + +Only `true` hides a record. `null` and `false` both mean "index it", which is why `example.article` can make the field nullable without emptying its sitemap. The resolve route reports the decision as `"robots": { "follow": true, "index": false }`. + +The field must be shared, not localized. A record has one indexing decision, and a per-language value would let the Polish page say `noindex` while the sitemap lists the English one. The definition refuses a localized `noIndexField` for that reason. + +## Localized alternates and hreflang + +For a [localized](/docs/content-engine/localization) content type, the page head links every published translation of the record. The blog post `english-title` is published in English and Polish, so `/blog/english-title` gets: + +```html +<link href="http://localhost:3000/blog/english-title" rel="canonical"/> +<link href="http://localhost:3000/blog/english-title" hrefLang="en" rel="alternate"/> +<link href="http://localhost:3000/pl/wpisy/polish-title" hrefLang="pl" rel="alternate"/> +<link href="http://localhost:3000/blog/english-title" hrefLang="x-default" rel="alternate"/> +``` + +- The canonical link is the page's own language. +- Each URL follows the app's [localized URL rules](/docs/dev/i18n/localized-urls), which is how the Polish post ends up at `/pl/wpisy/...`. +- Links are absolute, built from `VITNODE_WEB_URL` (`http://localhost:3000` when unset) unless a language has its own domain. +- A translation that is not published is not an alternate. +- Delivery on a localized type needs `"id"` in `publicApi.fields`, because alternates are looked up by record id. The definition throws without it. + +`hreflang: { xDefault: "defaultLocale" }` adds `hreflang.xDefault` to the resolve route's answer, pointing at the record's default-language URL when that translation is published. It needs `localization`. The page head does not read this option. It adds its `x-default` link whenever the app's default language is among the alternates, with or without it, so the option only matters when you read the resolve route yourself. + +```ts title="plugins/blog/src/content/post.ts" +delivery: { + hreflang: { xDefault: "defaultLocale" }, +}, +``` + +## Sitemap + +`delivery.sitemap` adds a public route that lists every published URL of the content type, oldest record first. Pass `true` or `{ changeFrequency, priority }`. It needs `publication: true`, since a sitemap lists only what readers can reach. + +```bash +curl "http://localhost:3000/api/@vitnode/example/content/articles/delivery/sitemap?cursor=1&limit=1" +``` + +```json +{ + "entries": [ + { + "changeFrequency": "weekly", + "itemId": 3, + "lastModified": "2026-10-10T11:06:30.518Z", + "locale": null, + "path": "/articles/hello-content-engine", + "priority": 0.7 + } + ], + "nextCursor": null +} +``` + +- A page holds 1,000 entries by default and up to 50,000 with `?limit=`. `cursor` is the `itemId` to continue after, so pass `nextCursor` back as `?cursor=` until it is `null`. +- `lastModified` is the record's `updatedAt`, or the translation's when that is newer. +- Records whose `noIndexField` is `true` are left out. +- A localized type returns one language per request. Ask for another with `?locale=`: + +```bash +curl "http://localhost:3000/api/@vitnode/blog/content/blog/delivery/sitemap?locale=pl&limit=1" +``` + +```json +{ + "entries": [ + { + "changeFrequency": "weekly", + "itemId": 4, + "lastModified": "2026-08-12T18:00:43.308Z", + "locale": "pl", + "path": "/pl/wpisy/polish-title", + "priority": 0.7 + } + ], + "nextCursor": 4 +} +``` + +The app's `/sitemap.xml` does not read these routes for you, so add the entries where you build it. `contentSitemapXml` from `@vitnode/core/content` writes the XML: + +```ts +import { + contentSitemapXml, + isContentSitemapChangeFrequency, +} from "@vitnode/core/content"; + +const response = await fetch( + "http://localhost:3000/api/@vitnode/example/content/articles/delivery/sitemap", +); +const { entries } = await response.json(); + +const xml = contentSitemapXml({ + origin: "https://example.com", + entries: entries.map(entry => ({ + ...entry, + changeFrequency: isContentSitemapChangeFrequency(entry.changeFrequency) + ? entry.changeFrequency + : null, + lastModified: new Date(entry.lastModified), + })), +}); +``` + +With the example plugin's two published articles, `xml` is: + +```xml +<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url> + <loc>https://example.com/articles/jbbjk-1</loc> + <lastmod>2026-08-30T09:18:25.398Z</lastmod> + <changefreq>weekly</changefreq> + <priority>0.7</priority> + </url> + <url> + <loc>https://example.com/articles/hello-content-engine</loc> + <lastmod>2026-10-10T11:06:30.518Z</lastmod> + <changefreq>weekly</changefreq> + <priority>0.7</priority> + </url> +</urlset> +``` + +`contentSitemapIndexXml` writes a `<sitemapindex>` once one file is not enough, for example one file per content type and language. + +## Retired slugs + +With `delivery.redirects`, an old slug answers a permanent `308` redirect to the record's current URL, so links and search results that still use the old address land on the new page. See [Keep old URLs working](/docs/content-engine/public-pages#keep-old-urls-working). + +## Check the result + +Print the SEO tags of an article and of a localized blog post: + +```bash +curl -s http://localhost:3000/articles/hello-content-engine | grep -oE '<title>[^<]*|]*name="(description|robots)"[^>]*>|]*rel="(canonical|alternate)"[^>]*>' +``` + +```html +Hello Content Engine - VitNode + +``` + +Run the same command for `http://localhost:3000/blog/english-title` and you get its title, description and the four links from [Localized alternates](#localized-alternates-and-hreflang). The SVG logo's `Logo VitNode` may show up too; it is not page metadata. + +Then ask the resolve route what the page was given, `openGraph` and `robots` included: + +```bash +curl http://localhost:3000/api/@vitnode/example/content/articles/delivery/resolve/hello-content-engine +``` + +Set `noIndex` on a test article, publish it, and run both commands again. The page gains the `robots` tag, and the [sitemap](#sitemap) route stops listing it. diff --git a/apps/web/content/docs/content-engine/services.mdx b/apps/web/content/docs/content-engine/services.mdx new file mode 100644 index 0000000000..76fbb45355 --- /dev/null +++ b/apps/web/content/docs/content-engine/services.mdx @@ -0,0 +1,368 @@ +--- +title: Services +description: Read and write Content Engine records from your own Hono routes, cron jobs and queue tasks with the typed service, the public service and the editorial service, and reuse the generated Zod schemas. +icon: Server +--- + +The Content Engine already generates routes for creating, reading, updating, deleting and publishing records. A **service** is what you use when you need something those routes don't do, such as "the three newest articles in one category" or "create an article from a cron job". It is a typed object with methods like `findMany`, `create` and `publish`, and you get it from the model `createContentModel` returns. + +Every example on this page uses the example plugin's article model: + +```ts title="plugins/example/src/database/articles.ts" +import { createContentModel } from "@vitnode/core/content/server"; + +import { articleContentType } from "@/content/article"; + +import { example_categories } from "./categories"; + +export const articleContent = createContentModel(articleContentType, { + references: { category: () => example_categories.id }, +}); +``` + +## The short version + +Inside any Hono handler, call a service with the request context `c`: + +```ts +const article = await articleContent.service(c).findById(1); +``` + +`article` is fully typed: `article.title` is a `string`, `article.featured` is a `boolean`, and a typo is a compile error. There are three services, and the only question is which one fits your task: + +| You want to | Use | +| ------------------------------------------------------------ | ------------------------------------------ | +| Show content to visitors: published records, public fields | `articleContent.publicService(c)` | +| Read or write anything, in a staff route, cron job or script | `articleContent.service(c)` | +| Save a change the way the AdminCP does, with history | `articleContent.editorialService(c, opts)` | + +The three sections below build one real example for each. + +## Example 1: the newest articles in a category + +**Goal:** a public endpoint, `GET /api/@vitnode/example/articles/by-category/1`, that returns the three newest published articles in category `1`. Drafts must never appear, and neither may private fields. + +`publicService` guarantees exactly that. It returns what the [public API](/docs/content-engine/public-api) would, so you cannot leak a draft by accident. + + + + +### Write the route + +```ts title="plugins/example/src/api/modules/articles/by-category.route.ts" +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; +import { articleContent } from "@/database/articles"; + +export const articlesByCategoryRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/by-category/{categoryId}", + description: "Published articles in one category, newest first", + request: { + params: z.object({ categoryId: z.coerce.number().int().positive() }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.array( + z.object({ + publishedAt: z.date().nullable(), + slug: z.string(), + title: z.string(), + }), + ), + }, + }, + description: "Up to three articles", + }, + }, + }, + handler: async c => { + const { publicService } = articleContent; + if (!publicService) throw new Error("Articles have no public API."); + + const { categoryId } = c.req.valid("param"); + const { edges } = await publicService(c).findMany({ + filters: { category: categoryId }, + orderBy: { column: "publishedAt", order: "desc" }, + query: { first: "3" }, + }); + + return c.json( + edges.map(({ publishedAt, slug, title }) => ({ + publishedAt, + slug, + title, + })), + 200, + ); + }, +}); +``` + +The handler does three things: + +1. **Gets the service.** `publicService` is `undefined` for a content type without `publicApi`, so the `if` makes that explicit once. +2. **Asks for records.** `filters` keeps only category `1`, `orderBy` puts the newest first and `query.first` limits it to three. `query` takes the same strings as the URL of a list route, which is why `first` is `"3"` and not `3`. +3. **Shapes the answer.** `edges` is the list of records. The route picks three fields so the response stays small. + +`filters` only accepts fields listed in `publicApi.filterableFields`, and `orderBy.column` only fields in `publicApi.orderableFields`. Asking for anything else is a type error. + + + + +### Register it + +Put the route in a module and add the module to your API plugin: + +```ts title="plugins/example/src/api/modules/articles/articles.module.ts" +import { buildModule } from "@vitnode/core/api/lib/module"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { articlesByCategoryRoute } from "./by-category.route"; + +export const articlesModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "articles", + routes: [articlesByCategoryRoute], +}); +``` + +```ts title="plugins/example/src/config.api.ts" +modules: [adminModule, articlesModule], +``` + + + + +### Call it + +```bash +curl http://localhost:8000/api/@vitnode/example/articles/by-category/1 +``` + +```json +[ + { + "publishedAt": "2026-08-03T08:47:00.000Z", + "slug": "jbbjk-1", + "title": "old article, example" + } +] +``` + +A category with no published articles answers `[]`. A draft in category `1` is not in the list, even though it exists. + + + + +Need one record instead of a list? `publicService(c).findBySlug("jbbjk-1")` and `findById(1)` return one published record, or `null`. + +## Example 2: create and publish an article from a job + +**Goal:** a [cron job](/docs/dev/cron) that publishes a "Weekly digest" article every Monday at 8:00. + +There is no visitor and no staff member here, so you want the plain `service`. It sees every record and every field, including drafts, and it checks no permissions. That makes it the right tool for jobs and scripts, and the wrong one for a public route. + +```ts title="plugins/example/src/api/cron/weekly-digest.cron.ts" +import { buildCron } from "@vitnode/core/api/lib/cron"; + +import { articleContent } from "@/database/articles"; + +export const weeklyDigestCron = buildCron({ + name: "weekly-digest", + description: "Publish the weekly digest article", + schedule: "0 8 * * 1", + handler: async c => { + const service = articleContent.service(c); + + const article = await service.create({ + title: "Weekly digest", + code: `digest-${Date.now()}`, + category: 1, + gallery: [21], + }); + + await service.publish(article.id); + }, +}); +``` + +Add it to a module's `cronJobs`, for example `cronJobs: [weeklyDigestCron]` in the `articles` module from Example 1. + +`create` checks the values against the same rules as the AdminCP form: `title` is required and at least 3 characters, `gallery` needs at least one image, and so on. Values that break a rule throw a `ZodError` before anything is written. `slug` is filled from the title automatically. + +To make several writes succeed or fail together, run them in one transaction by passing `{ tx }` as the last argument: + +```ts +await c.get("db").transaction(async tx => { + const article = await service.create( + { title: "Weekly digest", code: "digest-41", category: 1, gallery: [21] }, + { tx }, + ); + await service.publish(article.id, { tx }); +}); +``` + + + The plain service is quiet. It emits no [events](/docs/content-engine/events), + writes no revisions and does not update the search index. If a listener or + the site search should notice the change, use the editorial service from + Example 3. + + +## Example 3: a "Feature this article" button + +**Goal:** a staff endpoint, `POST /articles/1/feature`, that marks an article as featured, exactly as if someone had ticked **Featured** in the AdminCP and pressed **Save**. That means a revision in the history, the `updated` event, a search index update and protection against overwriting someone else's save. + +That is the `editorialService` (available with [`editorial`](/docs/content-engine/editorial)). It needs one extra input, `expectedVersion`: the version of the article the button's page loaded. If somebody saved the article in the meantime, the versions differ and the request fails with `409` instead of silently overwriting their work. + +```ts title="plugins/example/src/api/modules/admin/routes/feature.route.ts" +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { + contentEditorialEffects, + resolveContentActor, + withHttpErrors, +} from "@vitnode/core/content/server"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; +import { articleContent } from "@/database/articles"; + +export const featureArticleRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "article", permission: "can_edit" }, + route: { + method: "post", + path: "/articles/{id}/feature", + description: "Mark an article as featured", + request: { + params: z.object({ id: z.coerce.number().int().positive() }), + body: { + required: true, + content: { + "application/json": { + schema: z.object({ expectedVersion: z.number().int().positive() }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: articleContent.schemas.select }, + }, + description: "The featured article", + }, + 404: { description: "Article not found" }, + 409: { description: "Someone saved the article first" }, + }, + }, + handler: async c => { + const { editorialService } = articleContent; + if (!editorialService) throw new Error("Articles have no editorial."); + + const { id } = c.req.valid("param"); + const { expectedVersion } = c.req.valid("json"); + const pluginId = CONFIG_PLUGIN.pluginId; + + const outcome = await withHttpErrors( + "update", + async () => + await editorialService(c, { pluginId }).update( + id, + { featured: true }, + { actor: resolveContentActor(c), expectedVersion }, + ), + { + contentTypeId: articleContent.definition.id, + itemId: id, + structured: true, + }, + ); + if (!outcome) throw new HTTPException(404); + + await contentEditorialEffects(c, articleContent.definition, outcome, { + model: articleContent, + pluginId, + }); + + return c.json(outcome.row, 200); + }, +}); +``` + +Reading the handler top to bottom: + +1. **`adminStaffPermission`** lets in only staff who may edit articles, the same check as the AdminCP's own edit route. Put the route in your plugin's `admin` module. +2. **`editorialService(...).update`** saves `featured: true` and writes a revision. `resolveContentActor(c)` puts the signed-in staff member's name on that revision. +3. **`withHttpErrors`** turns a version mismatch into a `409` response the AdminCP understands, instead of a crash. +4. **`contentEditorialEffects`** does everything that should happen after a save: it emits `content.example.article.updated`, updates the search index and handles URL changes. Skip it and the save is still stored, but nothing else hears about it. + +The route lives in the `admin` module, so it answers at `POST /api/@vitnode/example/admin/articles/1/feature` and only to a signed-in AdminCP session. The body carries the version the page loaded: + +```json +{ "expectedVersion": 3 } +``` + +If someone saved the article after version 3 was loaded, the answer is a `409`: + +```json +{ + "code": "CONTENT_VERSION_CONFLICT", + "contentTypeId": "example.article", + "currentVersion": 4, + "expectedVersion": 3, + "itemId": 1 +} +``` + +Load the article again, take the new version, and retry. A job without a signed-in user passes `CONTENT_SYSTEM_ACTOR` instead of `resolveContentActor(c)`. + +## Reference + +### Service methods + +`publicService` only reads: `findMany({ filters, orderBy, query })`, `findById(id)` and `findBySlug(slug)`, each limited to published records and public fields. `editorialService` only writes: `create`, `update`, `delete`, `publish`, `unpublish`, `restore`, plus `relations` and `repeatable`. `service` has everything below: + +| Method | Returns | +| ---------------------------------------------- | -------------------------------------------------------------------------------- | +| `findMany({ filters, orderBy, query, where })` | `{ edges, pageInfo }`, drafts included. `where` takes a Drizzle `SQL` condition | +| `findById(id)` | The row, or `null` | +| `findRowById(id)` | The row with display labels for relations and users, or `null` | +| `findDetail(id)` | The row with relation, repeatable and file values, or `null` | +| `create(values)` | The new row | +| `update(id, values)` | `{ row, changedFields }`, or `null` when the id does not exist | +| `delete(id)` | The deleted row, or `null` | +| `publish(id)`, `unpublish(id)` | `{ changed, publishedAt, row }`, or `null`. Only with `publication` | +| `advanced(id)`, `advancedFields(id, fields)` | Values of to-many relations, repeatables and galleries | +| `options(field, search?, ids?)` | `{ value, label, color? }[]` for a relation or user picker | +| `relations.` | `add`, `remove`, `set`, `reorder`, `get` for a to-many relation | +| `repeatable.` | `create`, `update`, `delete`, `reorder`, `set`, `list` for a repeatable | + +On `service`, every write and every single-record read accepts `{ tx }` as its last argument. On `editorialService`, every write takes an options object with `actor`; `update`, `delete` and `restore` also take `expectedVersion`, which `publish` and `unpublish` treat as optional. A [localized](/docs/content-engine/localization) content type also has `localizedService` for writing translations. + +### Schemas + +`articleContent.schemas` holds the Zod schemas the generated routes validate with. Reuse them so a custom route accepts and returns exactly what the built-in ones do, as Example 3 does with `select`: + +| Schema | Validates | +| ------------------------------------ | -------------------------------------------------------------- | +| `create` | A create payload, with required fields and defaults | +| `update` | A partial update payload, never empty | +| `updateEnvelope` | `{ expectedVersion, values }` for an editorial update | +| `select`, `selectObject` | A full row (`selectObject` stays a `ZodObject` you can extend) | +| `publicSelect`, `publicSelectObject` | A row as the public API returns it | +| `filters`, `publicFilters` | Staff and public list filters | +| `order`, `publicOrder` | The `orderBy` allowlist plus direction | +| `params`, `publicParams` | `{ id }` and `{ slug }` path parameters | +| `form` | The AdminCP form values | + +To react to writes instead of making them, see [Events](/docs/content-engine/events). diff --git a/apps/web/content/docs/dev/ai/fields-and-editor.mdx b/apps/web/content/docs/dev/ai/fields-and-editor.mdx index ab4c7c068e..82b56dc569 100644 --- a/apps/web/content/docs/dev/ai/fields-and-editor.mdx +++ b/apps/web/content/docs/dev/ai/fields-and-editor.mdx @@ -393,7 +393,7 @@ It is a good template for structured, advisory AI in your own plugin. -Content Engine content types tag and expire their public reads for you. See [Content Engine caching](/docs/dev/content-engine/public-api-and-caching) instead of wiring that up by hand. +Content Engine public reads are not cached on the server. To cache them in your app or a separate front end, see [Content Engine caching](/docs/content-engine/caching). diff --git a/apps/web/content/docs/dev/configuration.mdx b/apps/web/content/docs/dev/configuration.mdx index 8bab78e084..492e492bad 100644 --- a/apps/web/content/docs/dev/configuration.mdx +++ b/apps/web/content/docs/dev/configuration.mdx @@ -144,7 +144,7 @@ with the route that renders it, not with the config that the document shell imports. Spreading `admin/content` into the factory undoes exactly that: the whole editing stack rides along with every public page. See [Plugin routes](/docs/dev/routing) and -[Plugin frontend modules](/docs/dev/content-engine/plugin-registration). +[Plugin frontend modules](/docs/content-engine/plugin-files). The `messages` a factory carries is the plugin's own locale barrel, which loads diff --git a/apps/web/content/docs/dev/content-engine/admincp.mdx b/apps/web/content/docs/dev/content-engine/admincp.mdx deleted file mode 100644 index 4940245090..0000000000 --- a/apps/web/content/docs/dev/content-engine/admincp.mdx +++ /dev/null @@ -1,199 +0,0 @@ ---- -title: Customize the AdminCP -description: Change how a Content Engine content type looks in the VitNode AdminCP. Pick list columns, search and sorting, group form fields into sections, open forms on a page, and replace cells, fields or the whole form layout. -icon: LayoutDashboard ---- - -import { ImgDocs } from '@/components/fumadocs/img' -import bulkPublish from './recipes-admin-bulk-publish.png' - -Every content type registered in `src/admin/content.tsx` gets an AdminCP list and a create and edit form without any UI code. Most changes are options in the definition's `admin` block. For anything the options cannot express, you replace a single cell, a single field or the whole form layout with your own component. - -## Choose list columns, search and sorting - -```ts title="plugins/example/src/content/recipe.ts" -admin: { - path: "example/recipes", - titleField: "title", - list: { - columns: ["status", "title", "difficulty", "cookingTime", "updatedAt"], - searchableFields: ["title", "summary"], - orderableFields: ["title", "cookingTime"], - defaultOrderBy: "cookingTime", - defaultOrder: "asc", - }, -}, -``` - -- **`columns`** sets the table columns in order. Without it, you get `status` (with publication), every shared column and `updatedAt`. -- **`searchableFields`** takes text, textarea or slug fields. A localized field matches in any language, so searching "szakszuka" finds a recipe whose Polish title contains it. Without the option, the search box covers shared text and textarea fields, and it disappears when there are none. -- **`orderableFields`** makes headers sortable. List only fields you declared: `id`, `createdAt`, `updatedAt`, `status`, `publishedAt` and `version` are always sortable, and listing them throws. -- **`titleField`** names a record in dialogs, toasts and the edit page heading. - -To-many `field.user()` and `field.relation()` fields can be columns too. People show with their role color, relations as badges, and both load once per page instead of once per row. Galleries (`field.file({ multiple: true })`) cannot be columns. - -### Thumbnails - -Point `list.thumbnailField` at a single `field.file()` to draw its image at the start of the title cell. Records without an image get a placeholder so titles stay aligned. The title field must be one of the `columns`. - -```ts -list: { - columns: ["title", "status"], - thumbnailField: "coverImage", // [!code ++] -}, -``` - -## Bulk actions - -Ticking rows opens a bar with **Publish**, **Unpublish** and **Delete**. There is nothing to configure. Publish and Unpublish appear with `publication` and the `can_publish` permission, Delete with `can_delete`. Without any of them, the table has no checkboxes. - - - -Each action asks for confirmation and then calls the same route as the row buttons, record by record, so events, search indexing and revisions behave as if you clicked each row. When some records cannot be changed, for example because someone edited them meanwhile, the rest still go through. A toast says how many were skipped, and those rows stay ticked. - -## Group form fields into sections - -```ts title="plugins/example/src/content/recipe.ts" -admin: { - form: { - sections: [ - { name: "main", fields: ["title", "slug", "summary"] }, - { name: "details", fields: ["cookingTime", "difficulty", "vegetarian"] }, - ], - }, -}, -``` - -Only the fields you list appear in the form, and each field may appear in one section. Section names are lowercase snake_case. Without sections, `form.fields` picks the fields; the default is every field except `blocks`. You cannot use both. - -Section headings come from your plugin's locale file: - -```json title="plugins/example/src/locales/en.json" -{ - "@vitnode/example": { - "content": { - "recipe": { - "form": { - "main": { "title": "Recipe", "desc": "What people see first." }, - "details": { "title": "Details" } - } - } - } - } -} -``` - -## Open forms on their own page - -Forms open in a dialog by default. Long forms read better on a page: - -```ts -admin: { - create: { mode: "page" }, - edit: { mode: "page" }, -}, -``` - -The create form moves to `/admin/content/example/recipes/create` and the edit form to `/admin/content/example/recipes/{id}/edit`. - -## Replace a field or a cell - -Wrap a registration in `contentTypeAdmin` to swap the component of one field or one list column. `contentTypeAdmin` only adds types: it checks that the keys are real field names. - -```tsx title="plugins/example/src/admin/content.tsx" -import type { ContentFrontendPluginSource } from "@vitnode/core/lib/plugin"; - -import { contentTypeAdmin } from "@vitnode/core/lib/plugin"; - -import { CONFIG_PLUGIN } from "@/const"; -import { DifficultyCell } from "@/views/admin/recipes/difficulty-cell"; -import { RecipeSummaryField } from "@/views/admin/recipes/summary-field"; - -import { recipeNav } from "./nav"; - -export const adminContent = { - pluginId: CONFIG_PLUGIN.pluginId, - contentTypes: [ - contentTypeAdmin({ - ...recipeNav, - fields: { - summary: { component: RecipeSummaryField, skeleton: "textarea" }, - }, - columns: { - difficulty: { cell: DifficultyCell }, - }, - }), - ], -} satisfies ContentFrontendPluginSource; -``` - -A field component receives `ItemAutoFormComponentProps` from `@vitnode/core/components/form/auto-form` and usually wraps an AutoForm field, such as `AutoFormTextarea` with your own label and description. A cell receives the row, typed by the definition: - -```tsx title="plugins/example/src/views/admin/recipes/difficulty-cell.tsx" -import type { ContentCellProps } from "@vitnode/core/lib/plugin"; - -import type { recipeContentType } from "@/content/recipe"; - -export const DifficultyCell = ({ - row, -}: ContentCellProps) => ( - {row.difficulty} -); -``` - -`skeleton` sets the loading placeholder for a field whose component is bigger than its kind suggests. The blog's `content` field is a `textarea` that renders a rich text editor, so it sets `skeleton: "editor"`. The options are `"editor"`, `"input"`, `"list"`, `"media"`, `"switch"` and `"textarea"`. - -## Replace the whole form layout - -For a layout the options cannot express, such as a main column with a publishing sidebar, pass `forms.layout`. Custom layouts render only in page mode, so set `create.mode` and `edit.mode` to `"page"` first. - -```tsx title="plugins/example/src/views/admin/recipes/form-layout.tsx" -import type { ContentFormLayoutProps } from "@vitnode/core/lib/plugin"; - -import { - ContentFormActions, - ContentFormField, - ContentFormHeader, - ContentFormLayoutGrid, - ContentFormMain, - ContentFormSection, - ContentFormSidebar, - ContentFormStatus, -} from "@vitnode/core/content/admin-form"; - -export const RecipeFormLayout = ({ mode }: ContentFormLayoutProps) => ( - <> - - - - - - - - - - - - - - - {mode === "edit" ? : null} - - - - - - -); -``` - -Register it with `forms: { layout: RecipeFormLayout }` in `contentTypeAdmin`, or use `forms.create.layout` and `forms.edit.layout` for different layouts per action. `ContentFormRemainingFields` renders every field the layout has not placed yet. In development, the console warns about fields a layout forgot. - -## Check the result - -Run `pnpm build:plugins`, restart `pnpm dev` and open **AdminCP → Example → Recipes**. The API validates sorting and form input against the definition, so a restart makes sure both sides see the same `admin` options. diff --git a/apps/web/content/docs/dev/content-engine/content-delivery-and-seo.mdx b/apps/web/content/docs/dev/content-engine/content-delivery-and-seo.mdx deleted file mode 100644 index c412c407d2..0000000000 --- a/apps/web/content/docs/dev/content-engine/content-delivery-and-seo.mdx +++ /dev/null @@ -1,286 +0,0 @@ ---- -title: Show content on a public page -description: Give every published Content Engine record its own URL. Enable delivery, add a plugin page, and get the title, meta description, canonical link, slug redirects and sitemap entries from one definition. -icon: Compass ---- - -import { ImgDocs } from '@/components/fumadocs/img' -import recipePage from './recipe-public-page.png' - -Delivery turns a published record into a web page at a stable URL, such as `/recipes/shakshuka-for-two`. The Content Engine resolves the URL, answers `404` for drafts, and builds the page's SEO metadata from fields you choose. Your plugin owns the page itself, so the content type, its public API and its URL stay together. - -## Before you begin - -The content type needs [publication and a public API](/docs/dev/content-engine/public-api-and-caching). This guide continues the recipes example. - - - - - -### Turn on delivery - -Add a `delivery` block. Each SEO field must also be listed in `publicApi.fields`, because the page's metadata is public: - -```ts title="plugins/example/src/content/recipe.ts" -export const recipeContentType = defineContentType({ - // id, tableName, fields, publication, publicApi as before - delivery: { // [!code ++:5] - enabled: true, - seo: { titleField: "title", descriptionField: "summary" }, - sitemap: { enabled: true, changeFrequency: "weekly" }, - }, -}); -``` - -The page URL defaults to `/{publicApi.path}/:slug`, so recipes live at `/recipes/:slug`. Set `delivery.path` when the website URL should differ from the API path. - - - - - -### List the plugin's public content types - -Create `src/content.ts` and export every content type that has delivery or search: - -```ts title="plugins/example/src/content.ts" -import { recipeContentType } from "@/content/recipe"; - -export const contentTypes = [recipeContentType]; -``` - -The build compares these URLs with your plugin's pages. A content type whose URL no page serves stops `dev` and `build` with `content-url-without-page`, and the API refuses to start when a plugin publishes URLs this module does not declare. - - - - - -### Claim the URL - -Add a page for the same path in your plugin's routes: - -```ts title="plugins/example/src/routes.ts" -export const routes = definePluginRoutes([ - page("/recipes/:slug", { // [!code ++:4] - component: lazy(() => import("./pages/recipe-page")), - messages: ["@vitnode/example.recipes"], - }), -]); -``` - - - - - -### Write the page - -The loader asks two routes at once: `/delivery/resolve/{slug}` says whether the URL is a record, an old slug to redirect or nothing, and `/{slug}` returns the record's public fields. - -```tsx title="plugins/example/src/pages/recipe-page.tsx" -import type { PluginRoutePageProps } from "@vitnode/core/routing"; - -import { definePluginRoute } from "@vitnode/core/routing"; -import { - contentDeliveryPage, - contentDeliveryPageHead, -} from "@vitnode/core/tanstack/content"; -import { fetcher } from "@vitnode/core/tanstack/fetcher"; -import { useTranslations } from "use-intl"; -import { z } from "zod"; - -const zodRecipe = z.object({ - cookingTime: z.number(), - difficulty: z.enum(["easy", "medium", "hard"]), - summary: z.string().nullable(), - title: z.string(), - vegetarian: z.boolean(), -}); - -const loadRecipe = async (slug: string) => { - const [resolution, detail] = await Promise.all([ - fetcher({ - plugin: "@vitnode/example", - method: "get", - module: "content/recipes", - path: "/delivery/resolve/{slug}", - args: { params: { slug: encodeURIComponent(slug) } }, - }), - fetcher({ - plugin: "@vitnode/example", - method: "get", - module: "content/recipes", - path: "/{slug}", - args: { params: { slug: encodeURIComponent(slug) } }, - }), - ]); - - return contentDeliveryPage({ - item: detail.status === 200 ? zodRecipe.parse(await detail.json()) : null, - resolution: await resolution.json(), - }); -}; - -export const route = definePluginRoute({ - load: async ({ params }) => await loadRecipe(params.slug), - head: ({ loaderData }) => - contentDeliveryPageHead(loaderData?.metadata, { - title: loaderData?.item.title, - }), -}); - -const RecipePage = ({ - loaderData: { item }, -}: PluginRoutePageProps>>) => { - const t = useTranslations("@vitnode/example.recipes"); - - return ( -
-
-

- {item.title} -

- {item.summary ? ( -

- {item.summary} -

- ) : null} -
- -
-
-
{t("cookingTime")}
-
- {t("minutes", { count: item.cookingTime })} -
-
-
-
{t("difficulty")}
-
- {t(`level.${item.difficulty}`)} -
-
-
-
{t("vegetarian")}
-
- {item.vegetarian ? t("yes") : t("no")} -
-
-
-
- ); -}; - -export default RecipePage; -``` - -- `contentDeliveryPage` returns `{ item, metadata }`. It throws a `404` for an unknown slug or a draft, and a redirect for a retired slug. -- `contentDeliveryPageHead` fills the title, meta description, `robots` and the canonical and alternate links. -- `load` comes before `head` so TypeScript can infer `loaderData`. - -
- - - -### Add the page strings - -The route loads the `@vitnode/example.recipes` namespace you named in `messages`: - -```json title="plugins/example/src/locales/en.json" -{ - "@vitnode/example": { - "recipes": { - "cookingTime": "Cooking time", - "minutes": "{count, plural, one {# minute} other {# minutes}}", - "difficulty": "Difficulty", - "level": { "easy": "Easy", "medium": "Medium", "hard": "Hard" }, - "vegetarian": "Vegetarian", - "yes": "Yes", - "no": "No" - } - } -} -``` - - - -
- -## Check the result - -Run `pnpm build:plugins`, restart `pnpm dev` and open `http://localhost:3000/recipes/shakshuka-for-two`. Delivery adds no database columns, so there is nothing to migrate. - - - -View the page source: the `` is `Shakshuka for two - VitNode` and the meta description is the recipe's summary. A draft such as `/recipes/beef-wellington` returns the app's not-found page. - -The resolve route shows what the page received: - -```bash -curl http://localhost:3000/api/@vitnode/example/content/recipes/delivery/resolve/shakshuka-for-two -``` - -```json -{ - "type": "content", - "canonicalPath": "/recipes/shakshuka-for-two", - "canonicalInternalPath": "/recipes/shakshuka-for-two", - "seo": { - "title": "Shakshuka for two", - "description": "Eggs poached in a spicy tomato and pepper sauce." - }, - "robots": null, - "alternates": [], - "isFallback": false -} -``` - -The real response has a few more keys (`hreflang`, `openGraph`, `locale` and others) that matter for localized content. - -## Keep old URLs working - -Renaming a slug breaks links people already shared. Turn on `delivery.redirects` and the old slug answers with a `308` redirect to the new URL: - -```ts title="plugins/example/src/content/recipe.ts" -editorial: { enabled: true }, // [!code ++] -delivery: { - enabled: true, - redirects: { enabled: true }, // [!code ++] - seo: { titleField: "title", descriptionField: "summary" }, -}, -``` - -Redirects need [`editorial`](/docs/dev/content-engine/publication-and-editorial), which adds a `version` column, so run the migration again. Old slugs are stored in `core_content_slug_history`. - -## Sitemap entries - -With `sitemap.enabled`, the public API lists every published URL at `GET /api/@vitnode/example/content/recipes/delivery/sitemap`: - -```json -{ - "entries": [ - { - "itemId": 2, - "path": "/recipes/shakshuka-for-two", - "lastModified": "2026-10-05T19:34:57.871Z", - "changeFrequency": "weekly", - "priority": null, - "locale": null - } - ], - "nextCursor": null -} -``` - -Pages hold 1,000 entries by default (`?limit=` up to 50,000), and `nextCursor` fetches the next page. Records whose `seo.noIndexField` is `true` are left out. Your app's own `sitemap.xml` does not read this endpoint for you, so add the entries where you build it. - -## Public URLs and languages - -`delivery.path` is the page's English route, with exactly one `:slug`. It cannot start with `/admin` or `/api`. The API path does not change when you set it. - -Every URL delivery builds follows the app's [localized URL rules](/docs/dev/i18n/localized-urls): language prefixes, per-language domains and translated route segments. With [localization](/docs/dev/content-engine/localization) and a localized slug, and an app that translates `/recipes` in its `routePaths`, the Polish recipe can live at `/pl/przepisy/szakszuka-dla-dwojga` while the English one stays at `/recipes/shakshuka-for-two`. Only published translations become alternate links. - -Every `delivery` option, with its default and requirements, is in the [content type reference](/docs/dev/content-engine/reference#delivery). diff --git a/apps/web/content/docs/dev/content-engine/database-and-migrations.mdx b/apps/web/content/docs/dev/content-engine/database-and-migrations.mdx deleted file mode 100644 index 7e4efd52ea..0000000000 --- a/apps/web/content/docs/dev/content-engine/database-and-migrations.mdx +++ /dev/null @@ -1,102 +0,0 @@ ---- -title: Database and migrations -description: How the VitNode Content Engine maps a content type to Postgres tables, which columns and indexes it adds for you, and how to generate and apply migrations. -icon: Database ---- - -Every content type becomes ordinary Postgres tables managed by Drizzle. `createContentModel` from `@vitnode/core/content/server` builds them from the definition, and the usual migration workflow creates them in your database. Nothing is stored as an untyped JSON document, except `field.blocks()`. - -## Generate and apply a migration - -After you add or change a content type, run this from the workspace root: - -```bash -pnpm build:plugins && pnpm db:migrate -``` - -1. `build:plugins` compiles your plugin to `dist/`. `db:migrate` does not build, so it would otherwise read the old schema. -2. `db:migrate` runs `drizzle-kit generate`, which compares the exported tables of every configured plugin with the last snapshot and writes a new folder to `apps/api/migrations/`. -3. It applies pending migrations and seeds initial data. - -Commit the generated migration with the content type. Production never migrates on startup: run `db:migrate` as a deploy step. More about migrations, including `vitnode migrate --generate` for generation only, is in [Database](/docs/dev/database). - -## Export every table - -Drizzle Kit discovers tables by reading the exports of `dist/src/database/*.js` in each plugin. A table without an export is left out of the migration without any warning: - -```ts title="plugins/example/src/database/recipes.ts" -export const recipeContent = createContentModel(recipeContentType, { - references: { category: () => example_categories.id }, -}); - -export const example_recipes = recipeContent.table; -export const example_recipes_translations = recipeContent.translationTable; -export const example_recipes_tags = recipeContent.advancedTables.junctions.tags; -export const example_recipes_ingredients = - recipeContent.advancedTables.repeatables.ingredients; -``` - -| Model member | Exists when | -| ---------------------------------- | --------------------------------------------------- | -| `table` | always | -| `translationTable` | `localization` is enabled, otherwise `null` | -| `advancedTables.junctions.<field>` | a `user`, `file` or `relation` field has `multiple: true` | -| `advancedTables.repeatables.<field>` | a `field.repeatable()` exists | - -## Generated columns - -You never declare these; declaring a field with the same name throws. - -| Column | Type | Added when | -| ------------- | --------------------------------------- | --------------------------- | -| `id` | `serial` primary key | always | -| `createdAt` | `timestamp`, defaults to `now()` | always | -| `updatedAt` | `timestamp`, defaults to `now()`, refreshed on every update | always | -| `status` | `varchar(32)`, `draft` or `published`, defaults to `draft` | `publication` | -| `publishedAt` | `timestamp`, nullable | `publication` | -| `version` | `integer`, defaults to `1` | `editorial` | - -Column names are camelCase in SQL, exactly as in TypeScript. Every generated table has row-level security enabled. - -### Translations table - -With `localization`, `{tableName}_translations` holds the localized fields: - -| Column | Notes | -| ------------------------ | ------------------------------------------------------------- | -| `itemId` | References the main table; deleting the record deletes its translations | -| `languageId` | References `core_languages.id` | -| `version` | Per-language version, always present | -| `createdAt`, `updatedAt` | | -| `status`, `publishedAt` | With `publication`; each language is published on its own | - -The primary key is `(itemId, languageId)`, so a record has at most one translation per language. - -### Junction and child tables - -- A **to-many field** gets `{tableName}_{field}` with `itemId`, `relatedItemId`, `position` and `createdAt`, keyed by `(itemId, relatedItemId)`. -- A **repeatable field** gets `{tableName}_{field}` with `id`, `itemId`, `position`, `createdAt`, `updatedAt` and one column per subfield. Deleting the record deletes its rows. -- A **group** adds one camelCase column per leaf to the main table, such as `nutritionCalories`. - -Field names are converted to snake_case in table names, so `relatedArticles` becomes `{tableName}_related_articles`. - -## Generated indexes - -The engine creates these indexes without being asked: - -- A unique index on every slug, and on every `field.text({ unique: true })`. -- An index on every single `relation`, `user` and `file` column. -- Indexes on `createdAt` and `updatedAt`. -- `(status, publishedAt)` with `publication`. -- On a translations table: `(languageId, status)` or `(languageId)`, plus a unique `(languageId, slug)` for each localized slug. - -Add your own with the [`indexes`](/docs/dev/content-engine/reference#indexes) option. An index on the same columns as a generated one replaces it. - -## Changing a content type later - -Changing a definition is a schema change like any other. Build, migrate and review the generated SQL before you commit it: - -- Adding a nullable field, or one with a `defaultValue`, is safe on a table with data. -- Adding a required field without a default fails on a table that already has rows. Add it as nullable first, fill it, then make it required. -- Turning on `localization` moves fields to a new table, and the generated SQL does not copy existing values. -- Never edit or regenerate a migration that has already been applied. Write a new one instead. diff --git a/apps/web/content/docs/dev/content-engine/defining-a-content-type.mdx b/apps/web/content/docs/dev/content-engine/defining-a-content-type.mdx deleted file mode 100644 index 4f4a698623..0000000000 --- a/apps/web/content/docs/dev/content-engine/defining-a-content-type.mdx +++ /dev/null @@ -1,320 +0,0 @@ ---- -title: Create your first content type -description: Build a recipes content type with the VitNode Content Engine. Declare fields once, run one migration, and get a Postgres table, AdminCP screens, CRUD routes and staff permissions. -icon: FileCode ---- - -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -import { ImgDocs } from '@/components/fumadocs/img' -import emptyList from './recipes-admin-empty.png' -import createDialog from './recipe-create-dialog.png' -import recipeList from './recipes-admin-list.png' -import staffPermissions from './staff-permissions-recipes.png' - -In this tutorial you add a **recipes** section to a plugin. You describe a recipe once with `defineContentType`, and the Content Engine turns that description into a Postgres table, an AdminCP list with a create form, staff-only CRUD routes and four staff permissions. It takes about fifteen minutes, most of it spent naming dishes. - -The code uses `@vitnode/example` as the plugin id, because the screenshots come from VitNode's example plugin. Use your own package name everywhere you see it. - -## Before you begin - -You need a plugin that your app already loads, with `src/config.api.ts` and `src/config.tsx`. [Create a plugin](/docs/dev/plugins/create) generates exactly that. You also need a running PostgreSQL database and an AdminCP account. - -<Steps> - -<Step> - -### Declare the content type - -Create `src/content/recipe.ts` in your plugin: - -```ts title="plugins/example/src/content/recipe.ts" -import { defineContentType, field } from "@vitnode/core/content"; - -export const recipeContentType = defineContentType({ - id: "example.recipe", - tableName: "example_recipes", - fields: { - title: field.text({ required: true, minLength: 3, maxLength: 120 }), - slug: field.slug({ source: "title" }), - summary: field.textarea({ maxLength: 300, nullable: true }), - cookingTime: field.number({ integer: true, min: 1, required: true }), - difficulty: field.enum({ - values: ["easy", "medium", "hard"], - defaultValue: "easy", - }), - vegetarian: field.boolean({ defaultValue: false }), - }, - admin: { - path: "example/recipes", - titleField: "title", - list: { - columns: ["title", "difficulty", "cookingTime", "vegetarian", "updatedAt"], - searchableFields: ["title", "summary"], - orderableFields: ["title", "cookingTime"], - }, - }, -}); -``` - -- `id` is `plugin.entity`: lowercase, dot separated. The part after the first dot (`recipe`) names the translations and the permissions. -- `tableName` is the Postgres table, in snake_case. -- `slug` with `source: "title"` fills itself from the title when a record is created without one. It never changes on later edits, so renaming a recipe does not move its URL. -- `admin.path` sets the AdminCP URL: `/admin/content/example/recipes`. - -A field that is neither `required` nor `nullable` needs a `defaultValue`, otherwise `defineContentType` throws. Every field helper is listed in [Fields](/docs/dev/content-engine/fields). - -</Step> - -<Step> - -### Create the database model - -Create `src/database/recipes.ts`. `createContentModel` turns the definition into a Drizzle table, Zod schemas and a service: - -```ts title="plugins/example/src/database/recipes.ts" -import { createContentModel } from "@vitnode/core/content/server"; - -import { recipeContentType } from "@/content/recipe"; - -export const recipeContent = createContentModel(recipeContentType); - -export const example_recipes = recipeContent.table; -``` - -Export the table. Drizzle Kit finds tables by reading the exports of your plugin's built `dist/src/database/*.js` files, so a table without an export never reaches a migration. - -</Step> - -<Step> - -### Add the staff CRUD routes - -Create an admin module and add the content type to it with `buildContentAdminModule`: - -```ts title="plugins/example/src/api/modules/admin/admin.module.ts" -import { buildModule } from "@vitnode/core/api/lib/module"; -import { buildContentAdminModule } from "@vitnode/core/content/server"; - -import { CONFIG_PLUGIN } from "@/const"; -import { recipeContent } from "@/database/recipes"; - -export const adminModule = buildModule({ - pluginId: CONFIG_PLUGIN.pluginId, - name: "admin", - routes: [], - modules: [ - buildContentAdminModule({ - pluginId: CONFIG_PLUGIN.pluginId, - contentTypes: [recipeContent], - }), - ], -}); -``` - -Then register the module in your API plugin: - -```ts title="plugins/example/src/config.api.ts" -import { adminModule } from "@/api/modules/admin/admin.module"; // [!code ++] - -export const exampleApiPlugin = () => - buildApiPlugin({ - pluginId: CONFIG_PLUGIN.pluginId, - modules: [helloModule, adminModule], // [!code highlight] - }); -``` - -The routes live under `/api/@vitnode/example/admin/content/recipe` and only answer staff signed in to the AdminCP. Keep the module named `admin`: the AdminCP session is only checked on paths that contain `/admin/`. - -</Step> - -<Step> - -### Add the AdminCP screen - -Two small files connect the content type to the AdminCP. `src/admin/nav.tsx` adds the sidebar entry: - -```tsx title="plugins/example/src/admin/nav.tsx" -import type { AdminNavPluginSource } from "@vitnode/core/lib/plugin"; - -import { SoupIcon } from "lucide-react"; - -import { CONFIG_PLUGIN } from "@/const"; -import { recipeContentType } from "@/content/recipe"; - -export const recipeNav = { - definition: recipeContentType, - icon: <SoupIcon />, -}; - -export const adminNav = { - pluginId: CONFIG_PLUGIN.pluginId, - contentTypes: [recipeNav], -} satisfies AdminNavPluginSource; -``` - -`src/admin/content.tsx` registers the screen itself: - -```tsx title="plugins/example/src/admin/content.tsx" -import type { ContentFrontendPluginSource } from "@vitnode/core/lib/plugin"; - -import { CONFIG_PLUGIN } from "@/const"; - -import { recipeNav } from "./nav"; - -export const adminContent = { - pluginId: CONFIG_PLUGIN.pluginId, - contentTypes: [recipeNav], -} satisfies ContentFrontendPluginSource; -``` - -Keep both files and both export names (`adminNav`, `adminContent`). The app's build finds them through your package's `./*` export, which a generated plugin already has. The sidebar loads `admin/nav` on every AdminCP page, while `admin/content` loads only when someone opens a content screen. Do not import either file from `config.tsx`. [Plugin files](/docs/dev/content-engine/plugin-registration) explains why. - -</Step> - -<Step> - -### Name everything - -Labels come from your plugin's `src/locales/en.json`. Content type strings live under `content.recipe`, and each staff permission gets a top-level key: - -```json title="plugins/example/src/locales/en.json" -{ - "@vitnode/example": { - "title": "Example", - "content": { - "recipe": { - "label": "{count, plural, one {Recipe} other {Recipes}}", - "title": "Recipes", - "desc": "Dishes your community can cook tonight.", - "fields": { - "title": "Title", - "slug": "Slug", - "summary": "Summary", - "cookingTime": "Cooking time (minutes)", - "difficulty": "Difficulty", - "vegetarian": "Vegetarian", - "updatedAt": "Updated" - }, - "enums": { - "difficulty": { "easy": "Easy", "medium": "Medium", "hard": "Hard" } - } - } - } - }, - "@vitnode/example:recipe": "Recipes", - "@vitnode/example:recipe:can_view": "View recipes", - "@vitnode/example:recipe:can_create": "Create recipes", - "@vitnode/example:recipe:can_edit": "Edit recipes", - "@vitnode/example:recipe:can_delete": "Delete recipes" -} -``` - -`label` is an ICU plural, so languages with more than two plural forms translate it correctly. `title` is the sidebar group of your plugin. Every key is optional: a missing one falls back to a readable version of the field or value name, and a missing permission label shows its raw id. - -</Step> - -<Step> - -### Build and migrate - -Run this from the workspace root: - -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Build plugins and migrate"> - -```bash tab="bun" -bun run build:plugins && bun run db:migrate -``` - -```bash tab="pnpm" -pnpm build:plugins && pnpm db:migrate -``` - -```bash tab="npm" -npm run build:plugins && npm run db:migrate -``` - -</Tabs> - -`db:migrate` does not build anything, so always run `build:plugins` first. It generates a migration in `apps/api/migrations/` and applies it. For this content type, the generated SQL is: - -```sql title="apps/api/migrations/<timestamp>_<name>/migration.sql (breakpoints removed)" -CREATE TABLE "example_recipes" ( - "id" serial PRIMARY KEY, - "createdAt" timestamp DEFAULT now() NOT NULL, - "updatedAt" timestamp DEFAULT now() NOT NULL, - "title" varchar(120) NOT NULL, - "slug" varchar(160) NOT NULL, - "summary" text, - "cookingTime" integer NOT NULL, - "difficulty" varchar(64) DEFAULT 'easy' NOT NULL, - "vegetarian" boolean DEFAULT false NOT NULL -); -ALTER TABLE "example_recipes" ENABLE ROW LEVEL SECURITY; -CREATE UNIQUE INDEX "example_recipes_slug_key" ON "example_recipes" ("slug"); -CREATE INDEX "example_recipes_created_at_idx" ON "example_recipes" ("createdAt"); -CREATE INDEX "example_recipes_updated_at_idx" ON "example_recipes" ("updatedAt"); -``` - -You did not declare `id`, `createdAt`, `updatedAt` or the slug index. The Content Engine adds them to every content type. - -</Step> - -<Step> - -### Restart the dev server - -Stop `pnpm dev` and start it again. The app generates its list of plugin AdminCP screens when it starts, and the API registers new routes only at startup. - -Open **AdminCP → Example → Recipes**. You get an empty list with every column you asked for: - -<ImgDocs - withoutBackground - className="*:max-w-full" - src={emptyList} - alt="AdminCP sidebar with a new Recipes entry under Example, and an empty Recipes list with Title, Difficulty, Cooking time, Vegetarian and Updated columns" -/> - -</Step> - -<Step> - -### Create a few recipes - -Click **Create Recipe**. The form is generated from your fields: a text input for the title, a textarea for the summary, a number input, a select for the enum and a switch for the boolean. Leave **Slug** empty to have it filled from the title. - -<ImgDocs - src={createDialog} - alt="Create Recipe dialog with Title, Slug, Summary, Cooking time, Difficulty and Vegetarian fields, and Cancel and Create buttons" -/> - -Click **Create**. A toast says "Recipe has been created." and the list refreshes. Add a few more so there is something to sort. - -</Step> - -</Steps> - -## Check the result - -The list now shows your recipes. Click the **Title** or **Cooking time** header to sort, or type in the search box to match titles and summaries. - -<ImgDocs - withoutBackground - className="*:max-w-full" - src={recipeList} - alt="Recipes list with four rows: Beef wellington, Mushroom risotto, Shakshuka for two and Midnight ramen, showing difficulty badges, cooking times and vegetarian checkmarks" -/> - -Finally, open **AdminCP → Staff → Administrators** and edit a restricted administrator. Your plugin now has a **Recipes** group with the four permissions the Content Engine generated. Every generated route checks them, so an administrator without **Delete recipes** gets a `403` from the delete route. - -<ImgDocs - src={staffPermissions} - alt="Staff permission editor for the Example plugin with a Recipes group listing View, Create, Edit and Delete recipes" -/> - -The `advanced_article` and `localized_article` groups next to it have no permission labels in the example plugin. That is what a missing key looks like. - -## Next steps - -- [Publish recipes with a public API](/docs/dev/content-engine/public-api-and-caching) adds drafts, a **Publish** button and read-only endpoints. -- [Show content on a public page](/docs/dev/content-engine/content-delivery-and-seo) gives each recipe its own URL with SEO metadata. -- [Customize the AdminCP](/docs/dev/content-engine/admincp) changes columns, form sections and field components. diff --git a/apps/web/content/docs/dev/content-engine/fields.mdx b/apps/web/content/docs/dev/content-engine/fields.mdx deleted file mode 100644 index 5bee8c2ab4..0000000000 --- a/apps/web/content/docs/dev/content-engine/fields.mdx +++ /dev/null @@ -1,151 +0,0 @@ ---- -title: Fields -description: Every field helper of the VitNode Content Engine, with its options, Postgres column, validation and AdminCP form control. -icon: LayoutGrid ---- - -A field is one property of a content type, such as a title or a cooking time. Build each one with a helper from `field`, exported by `@vitnode/core/content`. The helper decides the Postgres column, the Zod validation and the control in the AdminCP form. - -```ts -import { defineContentType, field } from "@vitnode/core/content"; - -fields: { - title: field.text({ required: true, maxLength: 120 }), - cookingTime: field.number({ integer: true, min: 1, required: true }), - vegetarian: field.boolean({ defaultValue: false }), -} -``` - -## Field helpers - -| Helper | Postgres column | AdminCP control | Can be `localized` | -| ------------------- | ---------------------------------------- | --------------------------------- | ------------------ | -| `field.text()` | `varchar(maxLength)`, 255 by default | Text input | Yes | -| `field.textarea()` | `text` | Textarea | Yes | -| `field.number()` | `integer` or `double precision` | Number input with steppers | No | -| `field.boolean()` | `boolean` | Switch | No | -| `field.enum()` | `varchar(length)`, 64 by default | Select, or radio buttons | No | -| `field.dateTime()` | `timestamp` | Date and time picker | No | -| `field.slug()` | `varchar(maxLength)`, 160 by default, unique | Text input | Yes | -| `field.user()` | `integer` referencing `core_users.id` | User picker | No | -| `field.file()` | `integer` referencing `core_files.id` | File upload | No | -| `field.relation()` | `integer` referencing the target table | Record picker | No | -| `field.group()` | one column per leaf | A card with the leaf fields | Yes, as a whole | -| `field.repeatable()`| a child table | A list of rows you can reorder | No | -| `field.blocks()` | `jsonb` | None; see [Widgets](/docs/dev/widgets) | Yes | - -`user`, `file` and `relation` with `multiple: true` use a junction table instead of a column. [Relations and advanced fields](/docs/dev/content-engine/relations-and-advanced-modeling) covers them, together with groups and repeatables. - -## Required, nullable and default values - -Most helpers accept `required`, `nullable` and `description`. These three decide whether a row can be inserted: - -| Option | Effect | -| ---------------- | --------------------------------------------------------------------------------------- | -| `required: true` | The create request must include the field. An empty string still passes unless you set `minLength` | -| `nullable: true` | The column accepts `NULL`, and so does validation | -| `defaultValue` | Used when a create request leaves the field out. Also becomes the column's `DEFAULT` | - -A field that is neither required nor nullable needs a fallback, otherwise `defineContentType` throws: `Field "x" is neither required nor nullable, so it needs a default value`. A `defaultValue`, `defaultNow` on a date, or `source` on a slug all count. On update, every field is optional. - -A single `field.user()` and a single `field.file()` are nullable unless you say otherwise. Every other field starts as `nullable: false`. - -`description` is shown under the input in the AdminCP form. - -## Text and textarea - -| Option | Type | Default | Description | -| -------------- | --------- | ------- | --------------------------------------------------------------- | -| `minLength` | `number` | | Shortest accepted value | -| `maxLength` | `number` | 255 for `text` | Longest accepted value; for `text` also the `varchar` length | -| `defaultValue` | `string` | | | -| `unique` | `boolean` | `false` | `text` only: adds a unique index. Ignored on a localized field | -| `localized` | `boolean` | `false` | Stores the value per language. Needs [`localization`](/docs/dev/content-engine/localization) | - -Set `maxLength` on every `text` field. Without it, validation accepts any length and Postgres rejects values over 255 characters with an error. - -## Number - -| Option | Type | Default | Description | -| -------------- | --------- | ------- | --------------------------------------------------------------- | -| `integer` | `boolean` | | Required. `true` makes an `integer` column, `false` a `double precision` one | -| `min`, `max` | `number` | | Accepted range | -| `defaultValue` | `number` | | Must be inside the range, and whole when `integer: true` | - -## Boolean - -`field.boolean({ defaultValue: false })`. The only option besides the shared ones is `defaultValue`. - -## Enum - -| Option | Type | Default | Description | -| -------------- | ------------------------ | ---------- | ------------------------------------------------- | -| `values` | `string[]` | | Required. Non-empty, without duplicates | -| `defaultValue` | one of `values` | | | -| `display` | `"select"` \| `"radio"` | `"select"` | AdminCP control | -| `length` | `number` | `64` | `varchar` length; every value must fit | - -The column is a plain `varchar`, not a Postgres enum, so adding a value later needs no type migration. Translate the values at `{pluginId}.content.{entity}.enums.{field}.{value}`. - -## Date and time - -`field.dateTime({ defaultNow: true })` fills the column with the current time on insert. There is no `defaultValue`. The API accepts ISO 8601 strings and returns dates. - -## Slug - -| Option | Type | Default | Description | -| ----------- | --------- | ------- | ------------------------------------------------------------------- | -| `source` | text field name | | Fills the slug from that field when a record is created without one | -| `maxLength` | `number` | `160` | `varchar` length and the point where long slugs are cut | -| `localized` | `boolean` | `false` | One slug per language | - -A slug is never empty and always unique: across the table, or within each language when it is localized. Whatever you send is normalized: `"Shakshuka for Two!"` becomes `shakshuka-for-two`. The slug is filled only on create; renaming the source later keeps the old slug. Without `source`, the slug is required. `source` must point to a `field.text()`, not a textarea. - -## User - -| Option | Type | Default | Description | -| ---------- | --------------------------------------- | --------------- | --------------------------------------------------- | -| `multiple` | `boolean` | `false` | Several people, stored in a junction table | -| `ordered` | `boolean` | `false` | Keeps the order editors arrange | -| `min` | `number` | | Fewest people, with `multiple` only | -| `onDelete` | `"cascade"` \| `"restrict"` \| `"set null"` | see below | What happens when the user is deleted | - -`onDelete` defaults to `"set null"` for a nullable single user, `"restrict"` for a required one and `"cascade"` for `multiple`. User fields cannot be part of `publicApi.fields`. - -## File - -```ts -coverImage: field.file({ - maxBytes: 5 * 1024 * 1024, - allowedExtensions: [".jpg", ".png", ".webp"], - allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"], -}), -gallery: field.file({ multiple: true, min: 1, max: 8, maxBytes: 5 * 1024 * 1024 }), -``` - -| Option | Type | Default | Description | -| ------------------- | ---------- | -------------- | ------------------------------------------------- | -| `maxBytes` | `number` | | Required. Largest accepted file | -| `allowedExtensions` | `string[]` | any | Normalized to lowercase with a leading dot | -| `allowedMimeTypes` | `string[]` | any | | -| `multiple` | `boolean` | `false` | Several files in a junction table | -| `min`, `max` | `number` | `max`: 20 | With `multiple` only; `max` up to 200 | -| `ordered` | `boolean` | same as `multiple` | Keeps the order editors arrange | - -Files are uploaded through the [storage](/docs/dev/storage) adapter and recorded in `core_files`. The column uses `ON DELETE RESTRICT`, so a file in use cannot be deleted. In the public API a file is returned as `{ id, name, url, mimeType, size, width, height }`, never as a raw id. - -A write can only point a file field at a file it is allowed to use: one uploaded through this content type's own upload route, one the signed-in user uploaded themselves, or one the record already holds. Anything else - say, someone else's avatar found by guessing ids - is refused with `CONTENT_FILE_NOT_FOUND`, the same answer as an id that does not exist. Server code running without a user (a job, a seed) is trusted to pick its own ids. - -## Relation - -`field.relation({ target: () => categoryContentType, required: true })` links to another content type. Set `required` or `nullable`: a single relation without either throws. All options are in [Relations and advanced fields](/docs/dev/content-engine/relations-and-advanced-modeling#relation-options). - -## Searching and sorting fields - -Fields have no `searchable` or `sortable` option. Each surface chooses its own: - -- `admin.list.searchableFields` and `admin.list.orderableFields` for the AdminCP list. -- `publicApi.searchableFields`, `orderableFields` and `filterableFields` for the public API. -- `search.contentFields` for the site search index. - -The [content type reference](/docs/dev/content-engine/reference) lists which field kinds each option accepts. diff --git a/apps/web/content/docs/dev/content-engine/index.mdx b/apps/web/content/docs/dev/content-engine/index.mdx deleted file mode 100644 index 9336cd1df1..0000000000 --- a/apps/web/content/docs/dev/content-engine/index.mdx +++ /dev/null @@ -1,69 +0,0 @@ ---- -title: Content Engine -description: Declare a content type once in TypeScript and VitNode generates the Postgres table, validation, staff CRUD routes, permissions, AdminCP screens, public API and SEO pages. -icon: Boxes ---- - -import { ImgDocs } from '@/components/fumadocs/img' -import recipeList from './recipes-admin-published.png' - -The Content Engine builds structured content for a plugin from one TypeScript definition. You describe the fields of a recipe, an article or a product, and VitNode generates everything around them: the database table, Zod validation, staff-only API routes, staff permissions and AdminCP screens. Opt-in blocks add drafts, a public API, web pages with SEO metadata, revisions, scheduling, translations and search. - -```ts title="plugins/example/src/content/recipe.ts" -import { defineContentType, field } from "@vitnode/core/content"; - -export const recipeContentType = defineContentType({ - id: "example.recipe", - tableName: "example_recipes", - fields: { - title: field.text({ required: true, maxLength: 120 }), - slug: field.slug({ source: "title" }), - cookingTime: field.number({ integer: true, min: 1, required: true }), - }, - publication: { enabled: true }, -}); -``` - -<ImgDocs - withoutBackground - className="*:max-w-full" - src={recipeList} - alt="AdminCP Recipes list generated by the Content Engine, with status badges, sortable columns, and publish, edit and delete actions" -/> - -## What you get - -| From the definition | VitNode generates | -| -------------------- | ----------------------------------------------------------------------------------------------------- | -| `fields` | A Postgres table with system columns and indexes, Zod schemas, and a typed service | -| `admin` | An AdminCP list with search, sorting and bulk actions, plus create and edit forms | -| Every content type | Staff routes and `can_view`, `can_create`, `can_edit` and `can_delete` permissions | -| `publication` | Draft and published states, `can_publish`, and Publish buttons | -| `publicApi` | Read-only public routes that return only the fields you list | -| `delivery` | Page URL resolution, SEO metadata, slug redirects and sitemap entries | -| `editorial` | Revision history with restore, signed preview links and scheduled publishing | -| `localization` | A translations table, per-language fields and per-language publishing | -| `search` | Records in the site [search](/docs/dev/search) index | - -Content types live in code and in your migrations, not in the database. There is no screen for creating a content type at runtime. - -## Pick a guide - -| You want to | Guide | -| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Build a content type from scratch, with AdminCP screens | [Create your first content type](/docs/dev/content-engine/defining-a-content-type) | -| Add drafts and a public, read-only API | [Publish content with a public API](/docs/dev/content-engine/public-api-and-caching) | -| Give each record its own page with SEO metadata | [Show content on a public page](/docs/dev/content-engine/content-delivery-and-seo) | -| Link records together, group fields or repeat rows | [Relations and advanced fields](/docs/dev/content-engine/relations-and-advanced-modeling) | -| Translate content into several languages | [Translate content](/docs/dev/content-engine/localization) | -| Keep revisions, share draft previews or schedule publishing | [Revisions, previews and scheduling](/docs/dev/content-engine/publication-and-editorial) | -| Change list columns, form sections or field components | [Customize the AdminCP](/docs/dev/content-engine/admincp) | -| Use content in your own routes, or react to changes with events | [Services, schemas and events](/docs/dev/content-engine/services-and-api) | -| Prepare a content type for production | [Production checklist](/docs/dev/content-engine/production-and-security) | - -## Reference - -- [Content type reference](/docs/dev/content-engine/reference): every `defineContentType` option and which options depend on others. -- [Fields](/docs/dev/content-engine/fields): every `field.*` helper with its options, column type and form control. -- [Database and migrations](/docs/dev/content-engine/database-and-migrations): generated tables, columns and indexes. -- [Plugin files](/docs/dev/content-engine/plugin-registration): the files and exports the build reads. diff --git a/apps/web/content/docs/dev/content-engine/localization.mdx b/apps/web/content/docs/dev/content-engine/localization.mdx deleted file mode 100644 index 247a6114cf..0000000000 --- a/apps/web/content/docs/dev/content-engine/localization.mdx +++ /dev/null @@ -1,112 +0,0 @@ ---- -title: Translate content -description: Store Content Engine fields in several languages. Mark fields as localized, get a translations table, edit each language in the AdminCP and serve the right language from the public API. -icon: Languages ---- - -Localization stores chosen fields once per language, while everything else stays shared. A recipe's title and summary get a Polish version, but its cooking time stays one number. Translated values live in a second table, `{tableName}_translations`, with one row per record and language. - -## Before you begin - -List every language in `i18n.locales` of your app's `vitnode.config.ts`, as described in [Internationalization](/docs/dev/i18n). Only those languages can hold translations or be requested from the API. - -<Steps> - -<Step> - -### Mark the translatable fields - -Add a `localization` block and set `localized: true` on the fields that change per language: - -```ts title="plugins/example/src/content/recipe.ts" -export const recipeContentType = defineContentType({ - id: "example.recipe", - tableName: "example_recipes", - localization: { // [!code ++:4] - enabled: true, - defaultLocale: "en", - fallback: "default", - }, - fields: { - title: field.text({ required: true, maxLength: 120, localized: true }), // [!code highlight] - slug: field.slug({ source: "title", localized: true }), // [!code highlight] - summary: field.textarea({ maxLength: 300, nullable: true, localized: true }), // [!code highlight] - cookingTime: field.number({ integer: true, min: 1, required: true }), - }, -}); -``` - -`text`, `textarea`, `slug`, `blocks` and whole `group` fields can be localized. Numbers, booleans, enums, dates, files, users and relations are always shared. A slug and its `source` must both be localized or both shared. - -</Step> - -<Step> - -### Export the translations table - -```ts title="plugins/example/src/database/recipes.ts" -export const recipeContent = createContentModel(recipeContentType); - -export const example_recipes = recipeContent.table; -export const example_recipes_translations = recipeContent.translationTable; // [!code ++] -``` - -</Step> - -<Step> - -### Build and migrate - -Run `pnpm build:plugins && pnpm db:migrate` and restart `pnpm dev`. The localized columns move to `example_recipes_translations`, keyed by `(itemId, languageId)`. That table has its own `version`, `createdAt` and `updatedAt`, plus `status` and `publishedAt` when the type has publication. - -The generated migration does not copy existing values into the translations table. If the table already holds records, review the SQL before you apply it and add a step that copies them. - -</Step> - -</Steps> - -## Check the result - -Open a recipe in the AdminCP. Each localized field has its own language selector, so you can translate the title without leaving the form. A value that is empty in your AdminCP language shows the default language instead, then any language that has text. - -With publication, every language has its own status. A translation is public only when both the record and that translation are published. With `editorial`, each language also keeps its own revisions. - -## Pick the language in the public API - -The public API chooses one language per request: - -1. The `?locale=` query parameter, such as `?locale=pl`. An unknown locale returns `404`. -2. Otherwise the `Accept-Language` header. -3. Otherwise `defaultLocale`. - -```bash -curl "http://localhost:3000/api/@vitnode/example/content/recipes/szakszuka-dla-dwojga?locale=pl" -``` - -The response carries a `Content-Language` header. When a translation is missing, `fallback: "default"` serves the default language instead and `fallback: "none"` (the default) returns `404`. - -In a page loader, pass the route's locale through: `args: { params: { slug }, query: { locale: context.locale } }`. Each translation can have its own slug and URL; see [Public URLs and languages](/docs/dev/content-engine/content-delivery-and-seo#public-urls-and-languages). - -## Translation routes - -`buildContentAdminModule` adds staff routes for each language under the content type's admin path: - -| Route | Does | -| ------------------------------------------------------- | -------------------------------------------- | -| `GET /{id}/translations` | Lists the record's translations | -| `GET`, `POST`, `PUT`, `DELETE /{id}/translations/{locale}` | Reads, creates, updates or deletes one language | -| `POST /{id}/translations/{locale}/publish` and `/unpublish` | Changes one language's status (with publication) | -| `GET /{id}/translations/{locale}/revisions` | One language's history (with editorial) | -| `POST /localized`, `PUT /{id}/localized` | Saves shared fields and every language in one transaction | - -They use the same permissions as the record. Changes emit `content.{id}.translation_created`, `translation_updated` and the other [translation events](/docs/dev/content-engine/services-and-api#events). - -## Options - -| Name | Type | Default | Description | -| --------------- | ----------------------- | -------- | --------------------------------------------------------------- | -| `enabled` | `true` | | Required | -| `defaultLocale` | `string` | | Required. A language code such as `"en"` or `"pt-BR"` | -| `fallback` | `"none"` \| `"default"` | `"none"` | What the public API serves when the requested language is missing | - -A localized content type needs at least one localized field, and `localized: true` without a `localization` block throws. diff --git a/apps/web/content/docs/dev/content-engine/meta.json b/apps/web/content/docs/dev/content-engine/meta.json deleted file mode 100644 index 478d4978a6..0000000000 --- a/apps/web/content/docs/dev/content-engine/meta.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "title": "Content Engine", - "description": "Declare a content type once, get the table, API, AdminCP, permissions and events", - "icon": "Boxes", - "pages": [ - "index", - "---Build---", - "defining-a-content-type", - "public-api-and-caching", - "content-delivery-and-seo", - "---Model your content---", - "relations-and-advanced-modeling", - "localization", - "publication-and-editorial", - "---Customize---", - "admincp", - "services-and-api", - "production-and-security", - "---Reference---", - "reference", - "fields", - "database-and-migrations", - "plugin-registration" - ] -} diff --git a/apps/web/content/docs/dev/content-engine/plugin-registration.mdx b/apps/web/content/docs/dev/content-engine/plugin-registration.mdx deleted file mode 100644 index 8da45da742..0000000000 --- a/apps/web/content/docs/dev/content-engine/plugin-registration.mdx +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: Plugin files -description: The files and exports a VitNode plugin needs for Content Engine content types, which part of the app reads each one, and why AdminCP code stays out of config.tsx. -icon: Package ---- - -A plugin's content types are read from several small files instead of one big config. Each file is loaded by a different part of the app, so a public page never downloads the AdminCP's form and editor code. This page lists those files; [Create your first content type](/docs/dev/content-engine/defining-a-content-type) writes each one step by step. - -## Files at a glance - -| File | Export | Read by | Holds | -| ---------------------------- | --------------- | ------------------------------------------- | ------------------------------------------------------------- | -| `src/content/<type>.ts` | any name | everything below | The `defineContentType` definition. Safe in the browser | -| `src/database/<type>.ts` | the tables | Drizzle Kit, from `dist/src/database/*.js` | `createContentModel` and every generated table | -| `src/config.api.ts` | the API plugin | the API | `buildContentPublicModule`, and the admin module with `buildContentAdminModule` | -| `src/admin/nav.tsx` | `adminNav` | the AdminCP sidebar, on every AdminCP page | `{ definition, icon }` per content type | -| `src/admin/content.tsx` | `adminContent` | the content screens, loaded on demand | Screen registrations, custom cells, fields and layouts | -| `src/content.ts` | `contentTypes` | the web build and the API at startup | Every content type with `delivery` or `search` | -| `src/config.tsx` | the plugin | the app's `vitnode.config.ts` | Plugin id, messages, `localeFiles` and routes only | - -A content type needs an entry in both `admin/nav` and `admin/content`: the first adds the sidebar link, the second the screen behind it. - -## How the app finds them - -The app's Vite plugin resolves `<pluginId>/admin/nav`, `<pluginId>/admin/content`, `<pluginId>/content` and `<pluginId>/routes` for every plugin in `vitnode.config.ts`, and writes the results into generated files such as `src/admin-nav.gen.ts` and `src/content-registry.gen.ts`. It does this each time `vite dev` or `vite build` starts, so restart the dev server after adding one of these files. A subpath that does not resolve is skipped without an error, which is the usual reason a new screen does not show up. - -This only works when the plugin id equals the npm package name, and the package exports its `dist` folder. A generated plugin already has the right `exports`: - -```json title="plugins/example/package.json" -{ - "name": "@vitnode/example", - "exports": { - "./locales/*.json": "./src/locales/*.json", - "./*": { - "import": "./dist/src/*.js", - "types": "./dist/src/*.d.ts", - "default": "./dist/src/*.js" - } - } -} -``` - -Tables are the exception: Drizzle Kit reads `node_modules/<pluginId>/dist/src/database/*.js` directly, for every plugin in the API config. Export each table from those files, or it never reaches a migration. - -## Keep `config.tsx` small - -`config.tsx` is bundled with the document shell of every public page: - -```tsx title="plugins/example/src/config.tsx" -import { buildPlugin } from "@vitnode/core/lib/plugin"; - -import { CONFIG_PLUGIN } from "@/const"; - -import messages from "./locales"; -import { routes } from "./routes"; - -export const examplePlugin = () => - buildPlugin({ - pluginId: CONFIG_PLUGIN.pluginId, - localeFiles: { - en: "@vitnode/example/locales/en.json", - }, - messages, - routes, - }); -``` - -Do not import `admin/nav` or `admin/content` here. The build imports `admin/nav` with the AdminCP shell and `admin/content` behind a dynamic `import()`, so the form code arrives only with the screen that needs it. Spreading them into `buildPlugin` would put the whole editing stack, rich text editor included, into every public page. `localeFiles` is what lets the app load your plugin's translations. Without it nothing fails, but every label from your locale file is missing. - -## `content.ts` and public URLs - -A content type with `delivery` or `search` publishes URLs. List those content types in `src/content.ts`: - -```ts title="plugins/example/src/content.ts" -import { recipeContentType } from "@/content/recipe"; - -export const contentTypes = [recipeContentType]; -``` - -The web build checks that a plugin page serves each `delivery.path` and `search.pathTemplate`, and stops with `content-url-without-page` otherwise. The API loads the same module at startup and refuses to start when the plugin publishes a URL the module does not declare, or declares differently. See [Content URLs need a page](/docs/dev/i18n/localized-urls#content-urls-need-a-page). diff --git a/apps/web/content/docs/dev/content-engine/production-and-security.mdx b/apps/web/content/docs/dev/content-engine/production-and-security.mdx deleted file mode 100644 index 8a6f7094a3..0000000000 --- a/apps/web/content/docs/dev/content-engine/production-and-security.mdx +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: Production checklist -description: What to check before a VitNode Content Engine content type goes live, from public field allowlists and staff permissions to migrations, scheduled publishing, previews and health diagnostics. -icon: ShieldCheck ---- - -A content type that works on your machine needs a few more checks before real editors and visitors use it. Go through this list for each content type you ship. - -## Expose only what you mean to - -- **Review `publicApi.fields`.** It is the only thing that decides what the public API, delivery metadata and search index can see. Keep internal notes, moderation flags and anything personal out of it. -- **Keep `publication` on.** A public API requires it, and new records start as drafts, so nothing appears before someone publishes it. -- **Remember search is public.** `search.contentFields` must be public fields, and an `authorField` makes authorship visible in search results. - -```ts title="plugins/example/src/content/recipe.ts" -fields: { - title: field.text({ required: true, maxLength: 120 }), - slug: field.slug({ source: "title" }), - internalNotes: field.textarea({ nullable: true }), -}, -publicApi: { - enabled: true, - path: "recipes", - fields: ["title", "slug"], -}, -``` - -`internalNotes` never leaves the server, because it is not in `fields`. - -## Grant the right permissions - -Generated routes check `can_view`, `can_create`, `can_edit` and `can_delete`, plus `can_publish` with publication and `can_restore` with editorial. Give moderators only what they need in **AdminCP → Staff**. Any custom admin route you add next to the generated ones must check staff permissions too, because the generated checks do not cover it. - -## Ship the migration - -- Commit the migration generated by `pnpm build:plugins && pnpm db:migrate`. -- Run `db:migrate` as a deploy step. Production does not migrate on startup. -- Review migrations that change existing content types; see [Changing a content type later](/docs/dev/content-engine/database-and-migrations#changing-a-content-type-later). - -## Configure what editorial features rely on - -- **Scheduled publishing** runs through the queue, which the `process-queue` [cron job](/docs/dev/cron) drains every minute. Make sure something triggers your cron endpoint in production, and set `CRON_SECRET` to your own value: the built-in default is rejected outside development. -- **Preview links** are built from `VITNODE_WEB_URL` and `VITNODE_API_URL`. If either is missing or invalid, the API logs a warning at startup and previews stay disabled. -- **Concurrent edits** are safe with `editorial`: a save with an outdated `expectedVersion` fails with `409 CONTENT_VERSION_CONFLICT` instead of overwriting someone else's work. Integrations that write content should send the version they read. - -## Check content health - -`contentEngineDiagnostics(c)` from `@vitnode/core/content/server` reports the state of every registered content type. Use it in a staff-only route or a monitoring probe: - -```ts -import { contentEngineDiagnostics } from "@vitnode/core/content/server"; - -const diagnostics = await contentEngineDiagnostics(c); - -diagnostics.healthy; -diagnostics.searchHealthy; -diagnostics.effectsHealthy; -diagnostics.contentTypes; -``` - -| Field | Means | -| ---------------- | ------------------------------------------------------------------------------------- | -| `healthy` | Everything below is fine | -| `searchHealthy` | The search index matches the content | -| `effectsHealthy` | No scheduled publish or other background effect has failed | -| `contentTypes` | One entry per content type: plugin, enabled features, pending and failed schedules, and search drift | - -## Know the limits - -- Content types are defined in code and shipped with migrations. There is no AdminCP screen for creating content types at runtime. -- A relation points at one content type. To link to several unrelated types, use separate relation fields. -- `field.blocks()` has no generated form control; edit it with [widgets](/docs/dev/widgets) or a custom field component. diff --git a/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx b/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx deleted file mode 100644 index 11990a7a3e..0000000000 --- a/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx +++ /dev/null @@ -1,282 +0,0 @@ ---- -title: Publish content with a public API -description: Add drafts and publishing to a Content Engine content type, then expose published records through typed, read-only public API routes with search, filters and sorting. -icon: Globe ---- - -import { ImgDocs } from '@/components/fumadocs/img' -import bulkPublish from './recipes-admin-bulk-publish.png' -import published from './recipes-admin-published.png' - -A content type is private until you opt in. This guide adds a draft and published state to the recipes from [Create your first content type](/docs/dev/content-engine/defining-a-content-type), then exposes the published ones at `/api/@vitnode/example/content/recipes`. Anyone can read that API without signing in, so you list exactly which fields it returns. - -<Steps> - -<Step> - -### Turn on publication and the public API - -Add `publication` and `publicApi` to the definition, and show the new `status` column in the list: - -```ts title="plugins/example/src/content/recipe.ts" -export const recipeContentType = defineContentType({ - id: "example.recipe", - tableName: "example_recipes", - fields: { - // same fields as before - }, - publication: { enabled: true }, // [!code ++] - publicApi: { // [!code ++:16] - enabled: true, - path: "recipes", - fields: [ - "title", - "slug", - "summary", - "cookingTime", - "difficulty", - "vegetarian", - "publishedAt", - ], - searchableFields: ["title", "summary"], - filterableFields: ["difficulty", "vegetarian"], - orderableFields: ["cookingTime"], - }, - admin: { - path: "example/recipes", - titleField: "title", - list: { - columns: ["status", "title", "difficulty", "cookingTime", "vegetarian", "updatedAt"], // [!code highlight] - searchableFields: ["title", "summary"], - orderableFields: ["title", "cookingTime"], - }, - }, -}); -``` - -- `publication` adds a `status` column (`draft` or `published`) and a `publishedAt` timestamp. New records start as drafts. -- `publicApi.fields` is an allowlist. A field you leave out, such as an internal note, never leaves the server. It must contain exactly one slug field, because the detail route looks records up by slug. `status` and `field.user()` fields cannot be public. -- `path` is a single lowercase URL segment. It cannot be `admin`. - -A public API without `publication` throws, so drafts can never leak by accident. - -</Step> - -<Step> - -### Register the public module - -Add `buildContentPublicModule` to your API plugin. A content type without `publicApi` is skipped, so you can pass all of them: - -```ts title="plugins/example/src/config.api.ts" -import { buildContentPublicModule } from "@vitnode/core/content/server"; // [!code ++] - -import { recipeContent } from "@/database/recipes"; // [!code ++] - -export const exampleApiPlugin = () => - buildApiPlugin({ - pluginId: CONFIG_PLUGIN.pluginId, - modules: [ - helloModule, - adminModule, - buildContentPublicModule({ // [!code ++:4] - pluginId: CONFIG_PLUGIN.pluginId, - contentTypes: [recipeContent], - }), - ], - }); -``` - -</Step> - -<Step> - -### Label the new permission and columns - -Publication adds a `can_publish` permission. Add a label for it and for the two new columns next to your existing keys: - -```json title="plugins/example/src/locales/en.json" -{ - "@vitnode/example": { - "content": { - "recipe": { - "fields": { - "status": "Status", - "publishedAt": "Published" - } - } - } - }, - "@vitnode/example:recipe:can_publish": "Publish and unpublish recipes" -} -``` - -</Step> - -<Step> - -### Build, migrate and restart - -Run `pnpm build:plugins && pnpm db:migrate`, then restart `pnpm dev`. The migration adds the two publication columns and an index. Existing rows become drafts: - -```sql title="migration.sql (breakpoints removed)" -ALTER TABLE "example_recipes" ADD COLUMN "publishedAt" timestamp; -ALTER TABLE "example_recipes" ADD COLUMN "status" varchar(32) DEFAULT 'draft' NOT NULL; -CREATE INDEX "example_recipes_status_published_at_idx" ON "example_recipes" ("status","publishedAt"); -``` - -</Step> - -<Step> - -### Publish some recipes - -Open **AdminCP → Example → Recipes**. Every recipe now shows a **Draft** badge, and the public API returns an empty list. Tick a few rows and use **Publish** in the bar that slides up: - -<ImgDocs - withoutBackground - className="*:max-w-full" - src={bulkPublish} - alt="Recipes list with three of four draft rows ticked and a floating bar showing 3 selected, Publish, Unpublish and Delete" -/> - -Confirm with **Yes, publish**. The badges turn into **Published**. You can also publish one record with the paper plane button in its row, or with **Publish** in the create form, which now sits next to **Save as draft**. - -<ImgDocs - withoutBackground - className="*:max-w-full" - src={published} - alt="Recipes list with three Published recipes and Beef wellington still a Draft" -/> - -</Step> - -</Steps> - -## Check the result - -Read the list. Filters are plain query parameters named after the field, and `orderBy` accepts `publishedAt` plus your `orderableFields`: - -```bash -curl "http://localhost:3000/api/@vitnode/example/content/recipes?vegetarian=true&orderBy=cookingTime&order=asc&first=2" -``` - -```json -{ - "edges": [ - { - "title": "Shakshuka for two", - "slug": "shakshuka-for-two", - "summary": "Eggs poached in a spicy tomato and pepper sauce.", - "cookingTime": 30, - "difficulty": "easy", - "vegetarian": true, - "publishedAt": "2026-10-05T19:34:57.870Z" - }, - { - "title": "Mushroom risotto", - "slug": "mushroom-risotto", - "summary": "Slow-stirred rice with porcini and parmesan.", - "cookingTime": 45, - "difficulty": "medium", - "vegetarian": true, - "publishedAt": "2026-10-05T19:34:57.886Z" - } - ], - "pageInfo": { - "totalCount": 2, - "totalPages": 1, - "currentPage": null, - "pageSize": 2, - "count": 2, - "hasNextPage": false, - "hasPreviousPage": false, - "endCursor": "eyJjb2x1bW4iOiJjb29raW5nVGltZSIsImlkIjozLCJ2YWx1ZSI6NDV9", - "startCursor": "eyJjb2x1bW4iOiJjb29raW5nVGltZSIsImlkIjoyLCJ2YWx1ZSI6MzB9" - } -} -``` - -Read one recipe by its slug at `/api/@vitnode/example/content/recipes/shakshuka-for-two`. The draft `beef-wellington` answers `404` with `Recipe not found.`, exactly like a slug that never existed, so the API never confirms that a draft exists. Sorting by a column you did not allow is a `400`. - -## Read the API from your plugin - -The generated routes are typed, so the [universal fetcher](/docs/dev/fetcher) infers the arguments and the response. The module is `content/` followed by `publicApi.path`: - -```ts title="plugins/example/src/views/recipes/recipe-queries.ts" -import { fetcher } from "@vitnode/core/tanstack/fetcher"; - -export const fetchQuickRecipes = async () => { - const response = await fetcher({ - plugin: "@vitnode/example", - method: "get", - module: "content/recipes", - path: "/", - args: { query: { orderBy: "cookingTime", order: "asc", first: "5" } }, - }); - - return await response.json(); -}; -``` - -A content type without `publicApi` adds no module, so `module: "content/categories"` for a private type is a compile error instead of a runtime `404`. To give each recipe its own web page, continue with [Show content on a public page](/docs/dev/content-engine/content-delivery-and-seo). - -## Public API routes - -| Route | Returns | -| ----------------------- | --------------------------------------------------------------------------------------------------------------- | -| `GET /` | Published records. Query: `first`, `last`, `cursor`, `page`, `search`, `orderBy`, `order` and one key per filterable field | -| `GET /{slug}` | One published record, or `404` | -| `GET /preview/{token}` | A signed draft preview, only with [`editorial.preview`](/docs/dev/content-engine/publication-and-editorial) | -| `GET /delivery/...` | URL resolution, metadata and sitemap entries, only with [`delivery`](/docs/dev/content-engine/content-delivery-and-seo) | - -A page holds at most 50 records; ask for fewer with `first`. A record is public only when its status is `published` and its `publishedAt` is in the past. Every option of `publicApi` is in the [content type reference](/docs/dev/content-engine/reference#publicapi). - -## Caching - -Public reads go straight to the database; core does not cache them for you. If a separate front end caches rendered pages, `content.revalidateOrigins` in your API config lists the origins to notify. VitNode currently notifies them only when a [scheduled](/docs/dev/content-engine/publication-and-editorial#schedule-publishing) publish or unpublish runs. It sends a `POST` to `{origin}/api/vitnode/content/revalidate` with `Authorization: Bearer <CRON_SECRET>`, and your front end has to provide that endpoint. - -```ts title="apps/api/src/vitnode.api.config.ts" -export const vitNodeApiConfig = buildApiConfig({ - content: { - revalidateOrigins: ["https://www.example.com"], // [!code ++] - }, -}); -``` - -Leave it unset when your pages read the public API on every request. - -Because the request carries your `CRON_SECRET` - the same key that runs every cron job - VitNode is picky about where it sends it: - -- Each origin must use `https`. Plain `http` is only allowed for `localhost` and `127.0.0.1`. -- Outside development, nothing is sent while `CRON_SECRET` is unset or still the built-in default. The API logs a warning once and the scheduled run records the origins as not delivered. - -### Build the revalidate endpoint - -Your endpoint is the lock on that door, so check two things before you expire anything: - -1. **The bearer, in constant time.** A plain `===` stops at the first wrong character, and that timing leaks the secret one character at a time. -2. **The `x-vitnode-timestamp` header.** It holds the send time in milliseconds. Refuse anything more than 5 minutes away from your clock, so a captured request can't be replayed next week. - -```ts title="apps/web/src/routes/api/vitnode/content/revalidate.ts" -import { createHash, timingSafeEqual } from "node:crypto"; - -const MAX_SKEW_MS = 5 * 60 * 1000; - -// Hashing first gives both sides the same length, which `timingSafeEqual` needs. -const digest = (value: string) => createHash("sha256").update(value).digest(); - -export const isTrustedRevalidation = (request: Request): boolean => { - const secret = process.env.CRON_SECRET; - if (!secret) return false; - - const bearer = /^Bearer (.+)$/.exec(request.headers.get("authorization") ?? "")?.[1]; - if (!bearer || !timingSafeEqual(digest(bearer), digest(secret))) return false; - - const sentAt = Number(request.headers.get("x-vitnode-timestamp")); - - return Number.isFinite(sentAt) && Math.abs(Date.now() - sentAt) <= MAX_SKEW_MS; -}; -``` - -Answer `403` when it returns `false` - VitNode reads that as a wrong secret and skips the retry - and a `2xx` once the tags are expired. The JSON body is the invalidation input: the content type, the record `id`, its `slugs` and whether it `wasPublic` and `isPublic`. Pass it to `contentInvalidationTags` from `@vitnode/core/content` to get the exact tags to expire. diff --git a/apps/web/content/docs/dev/content-engine/publication-and-editorial.mdx b/apps/web/content/docs/dev/content-engine/publication-and-editorial.mdx deleted file mode 100644 index e5230cf798..0000000000 --- a/apps/web/content/docs/dev/content-engine/publication-and-editorial.mdx +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: Revisions, previews and scheduling -description: Add editorial workflows to a Content Engine content type. Keep revision history with restore, protect edits from overwriting each other, share signed draft previews and schedule publishing. -icon: Clock ---- - -The `editorial` block adds the tools an editorial team needs on top of [drafts and publishing](/docs/dev/content-engine/public-api-and-caching): a history of every save with one-click restore, protection against two editors overwriting each other, signed links that show a draft to someone without an AdminCP account, and publishing at a chosen time. - -## Turn on editorial features - -```ts title="plugins/example/src/content/recipe.ts" -export const recipeContentType = defineContentType({ - id: "example.recipe", - tableName: "example_recipes", - publication: { enabled: true }, - publicApi: { enabled: true, path: "recipes", fields: ["title", "slug"] }, - editorial: { // [!code ++:6] - enabled: true, - revisions: { retention: 20 }, - preview: { enabled: true, expiresInMinutes: 60 }, - scheduling: { enabled: true }, - }, - fields: { - title: field.text({ required: true, maxLength: 120 }), - slug: field.slug({ source: "title" }), - }, -}); -``` - -Run `pnpm build:plugins && pnpm db:migrate` and restart `pnpm dev`. The migration adds a `version` column. Revisions and schedules use shared core tables, so nothing else changes in your schema. Editorial also adds a `can_restore` permission; label it at `@vitnode/example:recipe:can_restore`. - -## Revisions - -Every create, update, publish, unpublish and restore stores a snapshot in `core_content_revisions`. `revisions.retention` keeps the newest 1 to 500 snapshots per record, 50 by default. - -Open a record's history in the AdminCP to compare each revision with the one before it and restore an earlier version. Restoring needs `can_restore`, creates a new revision and emits `content.example.recipe.restored`. - -## Safe concurrent edits - -Each editorial write raises the record's `version` by one and only succeeds when the version still matches the one the editor loaded. If someone else saved in between, the second save fails with `409` and the code `CONTENT_VERSION_CONFLICT` instead of silently overwriting their work. The AdminCP sends the version for you. - -Calls to the edit routes must send it too. `PUT /api/@vitnode/example/admin/content/recipe/{id}` takes `{ "expectedVersion": 3, "values": { "title": "…" } }`, and `DELETE` takes `{ "expectedVersion": 3 }`. In your own server code, use `recipeContent.editorialService` to get the same check; see [Services, schemas and events](/docs/dev/content-engine/services-and-api#editorial-writes). - -## Preview links - -A preview link lets a reviewer read a draft without signing in. Previews need `publicApi`, because the preview shows the same fields as the public API. Links are HMAC-signed and expire after `expiresInMinutes`, from 1 to 1440 minutes, 15 by default. - -The link points to the first match of: - -1. `preview.pathTemplate`, such as `/preview/recipes/{token}`, which must contain exactly one `{token}`. -2. The record's page with `?preview=<token>`, when the type has [delivery](/docs/dev/content-engine/content-delivery-and-seo). -3. The public API route `GET /api/@vitnode/example/content/recipes/preview/{token}`. - -Whichever page opens, it has to load the draft from `/preview/{token}`. A page built from [Show content on a public page](/docs/dev/content-engine/content-delivery-and-seo) does not read `?preview=` on its own, so check for it in your loader. The preview route answers `404` for any invalid or expired token and sends `Cache-Control: private, no-store` and `X-Robots-Tag: noindex, nofollow`. - -Preview links are built from `VITNODE_WEB_URL` and `VITNODE_API_URL`. When either is not a valid URL, the API logs a warning at startup and previews stay disabled. - -## Schedule publishing - -Scheduling lets an editor pick a future time to publish or unpublish a record. It needs `publication`. - -A schedule is a queued task that runs at the chosen time. The queue is drained by the `process-queue` [cron job](/docs/dev/cron) every minute, so scheduled publishing works only when something triggers your cron endpoint, for example the node-cron adapter or your host's scheduler. In production, set `CRON_SECRET` to your own value. - -A time up to two minutes in the past is accepted; anything older is rejected with `CONTENT_SCHEDULE_IN_PAST`. An unpublish cannot be scheduled before a pending publish. - -Booking a schedule emits `content.example.recipe.scheduled`, and cancelling it emits `schedule_cancelled`. When the time comes, the record is published or unpublished and the usual `published` or `unpublished` event carries the `scheduleId`. If your front end caches pages, [`content.revalidateOrigins`](/docs/dev/content-engine/public-api-and-caching#caching) tells it about the change. - -## How publishing behaves - -- New records start as drafts. -- Publishing sets `publishedAt` once. Unpublishing and publishing again keeps the original date. -- A record is public only when its status is `published` and its `publishedAt` is not in the future. - -Every `editorial` option is in the [content type reference](/docs/dev/content-engine/reference#editorial). diff --git a/apps/web/content/docs/dev/content-engine/recipe-create-dialog.png b/apps/web/content/docs/dev/content-engine/recipe-create-dialog.png deleted file mode 100644 index 99bfd93ccb..0000000000 Binary files a/apps/web/content/docs/dev/content-engine/recipe-create-dialog.png and /dev/null differ diff --git a/apps/web/content/docs/dev/content-engine/recipe-public-page.png b/apps/web/content/docs/dev/content-engine/recipe-public-page.png deleted file mode 100644 index ac931ef599..0000000000 Binary files a/apps/web/content/docs/dev/content-engine/recipe-public-page.png and /dev/null differ diff --git a/apps/web/content/docs/dev/content-engine/recipes-admin-bulk-publish.png b/apps/web/content/docs/dev/content-engine/recipes-admin-bulk-publish.png deleted file mode 100644 index d7ca6c9df9..0000000000 Binary files a/apps/web/content/docs/dev/content-engine/recipes-admin-bulk-publish.png and /dev/null differ diff --git a/apps/web/content/docs/dev/content-engine/recipes-admin-empty.png b/apps/web/content/docs/dev/content-engine/recipes-admin-empty.png deleted file mode 100644 index c745159554..0000000000 Binary files a/apps/web/content/docs/dev/content-engine/recipes-admin-empty.png and /dev/null differ diff --git a/apps/web/content/docs/dev/content-engine/recipes-admin-list.png b/apps/web/content/docs/dev/content-engine/recipes-admin-list.png deleted file mode 100644 index effe896776..0000000000 Binary files a/apps/web/content/docs/dev/content-engine/recipes-admin-list.png and /dev/null differ diff --git a/apps/web/content/docs/dev/content-engine/recipes-admin-published.png b/apps/web/content/docs/dev/content-engine/recipes-admin-published.png deleted file mode 100644 index a54427734a..0000000000 Binary files a/apps/web/content/docs/dev/content-engine/recipes-admin-published.png and /dev/null differ diff --git a/apps/web/content/docs/dev/content-engine/reference.mdx b/apps/web/content/docs/dev/content-engine/reference.mdx deleted file mode 100644 index 70a7d16bbf..0000000000 --- a/apps/web/content/docs/dev/content-engine/reference.mdx +++ /dev/null @@ -1,155 +0,0 @@ ---- -title: Content type reference -description: Every option of defineContentType in the VitNode Content Engine, with types, defaults, validation rules and the options each one depends on. -icon: BookOpen ---- - -`defineContentType` from `@vitnode/core/content` validates a content type when your plugin loads and returns the resolved definition. Invalid input throws a `ContentEngineError` whose message starts with `[Content Engine] <id>:` and names the problem. For a guided walkthrough, start with [Create your first content type](/docs/dev/content-engine/defining-a-content-type). - -```ts -import { defineContentType, field } from "@vitnode/core/content"; - -export const recipeContentType = defineContentType({ - id: "example.recipe", - tableName: "example_recipes", - fields: { title: field.text({ required: true }) }, -}); -``` - -## Top-level options - -| Name | Type | Required | Default | Description | -| -------------- | --------------------------------------------- | -------- | ------- | ---------------------------------------------------------------------------- | -| `id` | `string` | Yes | | `plugin.entity`, lowercase and dot separated, for example `example.recipe` | -| `tableName` | `string` | Yes | | Postgres table name: snake_case, starts with a letter, at most 63 characters | -| `fields` | `Record<string, field>` | Yes | | At least one field built with a [`field.*` helper](/docs/dev/content-engine/fields) | -| `admin` | [`admin`](#admin) | No | `{}` | AdminCP URL, list and form | -| `indexes` | [`indexes`](#indexes) | No | `[]` | Extra database indexes | -| `publication` | `{ enabled: true }` | No | off | Adds `status` and `publishedAt`, and the `can_publish` permission | -| `publicApi` | [`publicApi`](#publicapi) | No | off | Read-only public routes | -| `delivery` | [`delivery`](#delivery) | No | off | Page URLs, SEO metadata, redirects and sitemap entries | -| `editorial` | [`editorial`](#editorial) | No | off | Revisions, previews, scheduling and a `version` column | -| `localization` | [`localization`](#localization) | No | off | A translations table for `localized: true` fields | -| `search` | [`search`](#search) | No | off | Adds records to the site search index | - -To turn a feature off, leave its block out. The `enabled` keys accept only `true`. - -### Names - -- **`id`** must match `plugin.entity` with at least two segments. Later segments may contain dashes. With `search` or `editorial` it can be at most 100 characters. -- **Field names** are camelCase and start with a lowercase letter. -- **Reserved field names:** `id`, `createdAt` and `updatedAt` always; `status` and `publishedAt` with `publication`; `version` with `editorial`. The pagination parameters `cursor`, `first`, `last`, `order`, `orderBy` and `search` are refused too. -- **Entity key:** the id without its first segment, with further dots turned into `_` (`example.recipe` → `recipe`). Translations live under `{pluginId}.content.{entityKey}`. -- **Permission module:** the entity key by default, so permissions are named `@vitnode/example:recipe:can_view`. Override it with `admin.permissionModule`. - -## Which options need which - -| Option | Needs | -| ---------------------------- | ------------------------------------------------------------ | -| `publicApi` | `publication` | -| `search` | `publication` and `publicApi` | -| `delivery` | `publicApi` | -| `delivery.redirects` | `editorial`, and a localized slug on a localized type | -| `delivery.sitemap` | `publication` | -| `delivery.hreflang` | `localization` | -| `editorial.preview` | `publicApi` | -| `editorial.scheduling` | `publication` | -| a `localized: true` field | `localization` | -| `localization` | at least one `localized: true` field | - -## `admin` - -| Name | Type | Default | Description | -| --------------------------- | ---------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------- | -| `path` | `string` | the id with dots as slashes (`example/recipe`) | AdminCP URL under `/admin/content/`. Lowercase segments; the last cannot be `create` or `edit`; unique site-wide | -| `permissionModule` | `string` | the entity key | Middle part of the permission names | -| `titleField` | field name or `null` | first text or textarea field | Names a record in headings, dialogs and toasts | -| `colorField` | field name | | A shared field holding a color, shown as a swatch next to the title in pickers and cells | -| `create.mode`, `edit.mode` | `"dialog"` \| `"page"` | `"dialog"` | Open the form in a dialog or on its own page | -| `navigation.enabled` | `boolean` | `true` | `false` hides the sidebar entry | -| `list.columns` | field names | `status` (with publication), shared columns, `updatedAt` | Table columns, in order | -| `list.searchableFields` | text, textarea or slug fields | shared text and textarea fields | Fields the search box matches. Localized fields match in any language | -| `list.orderableFields` | shared scalar fields | `[]` | Sortable fields you declared. `id`, `createdAt`, `updatedAt`, `status`, `publishedAt` and `version` are always sortable and must not be listed | -| `list.defaultOrderBy` | column name | `"updatedAt"` | Must be a system column or in `orderableFields` | -| `list.defaultOrder` | `"asc"` \| `"desc"` | `"desc"` | | -| `list.thumbnailField` | a single `field.file()` | | Image drawn in the title cell. Needs `titleField` in `columns` | -| `form.fields` | field names | every field except `blocks` | Fields in the generated form | -| `form.sections` | `{ name, fields }[]` | | Groups fields into titled sections. Cannot be combined with `form.fields` | - -How these options look on screen is covered in [Customize the AdminCP](/docs/dev/content-engine/admincp). - -## `publicApi` - -| Name | Type | Default | Description | -| ------------------ | -------------------- | ---------------- | --------------------------------------------------------------------------------------------- | -| `enabled` | `true` | | Required | -| `path` | `string` | | One lowercase segment, at most 64 characters, not `admin`, unique within the plugin. Routes live at `/api/{pluginId}/content/{path}` | -| `fields` | field names | | The allowlist. Exactly one top-level slug field. May include `id`, `createdAt`, `updatedAt` and `publishedAt`. Not `status`, not `field.user()`; list group leaves as `"seo.title"` | -| `searchableFields` | exposed fields | `[]` | Text, textarea or slug fields matched by `?search=` | -| `filterableFields` | exposed fields | `[]` | Boolean, enum, number, relation, slug or text fields, each its own query parameter | -| `orderableFields` | exposed fields | `[]` | Accepted by `?orderBy=`. `publishedAt` is always added. No localized, file or to-many fields | -| `defaultOrderBy` | column name | `"publishedAt"` | | -| `defaultOrder` | `"asc"` \| `"desc"` | `"desc"` | | - -## `delivery` - -| Name | Type | Default | Description | -| ------------------------------ | ----------------------- | --------------------------- | --------------------------------------------------------------------------- | -| `enabled` | `true` | | Required | -| `path` | `string` | `/{publicApi.path}/:slug` | The page's English route. Exactly one `:slug`, no other parameters, not under `/admin` or `/api` | -| `redirects.enabled` | `boolean` | `false` | Old slugs answer `308` to the current URL | -| `seo.titleField` | text field | | Page title | -| `seo.fallbackTitleField` | text field | | Used when `titleField` is empty | -| `seo.descriptionField` | text or textarea field | | Meta description, stripped of HTML and cut to 160 characters | -| `seo.fallbackDescriptionField` | text or textarea field | | Used when `descriptionField` is empty | -| `seo.noIndexField` | shared boolean field | | `true` marks the page `noindex` and leaves it out of the sitemap | -| `seo.openGraph` | `{ titleField?, descriptionField? }` | | | -| `sitemap.enabled` | `boolean` | `false` | Lists published URLs at `/delivery/sitemap` | -| `sitemap.changeFrequency` | `"always"` … `"never"` | | | -| `sitemap.priority` | `number` from 0 to 1 | | | -| `hreflang.xDefault` | `"defaultLocale"` | | Reports an `x-default` alternate in the API metadata | - -Every SEO field must be in `publicApi.fields`. A localized type also needs `"id"` there. - -## `editorial` - -| Name | Type | Default | Description | -| ---------------------------- | --------- | ------- | --------------------------------------------------------------------------- | -| `enabled` | `true` | | Adds `version`, revision history and the `can_restore` permission | -| `revisions.retention` | `number` | `50` | Revisions kept per record, from 1 to 500 | -| `preview.enabled` | `boolean` | `false` | Signed preview links for drafts | -| `preview.expiresInMinutes` | `number` | `15` | Link lifetime, from 1 to 1440 | -| `preview.pathTemplate` | `string` | | Custom preview URL with exactly one `{token}`, such as `/preview/{token}` | -| `scheduling.enabled` | `boolean` | `false` | Publish or unpublish at a chosen time | - -## `localization` - -| Name | Type | Default | Description | -| --------------- | ----------------------- | -------- | ----------------------------------------------------------------------- | -| `enabled` | `true` | | Required | -| `defaultLocale` | `string` | | Required. A language code such as `"en"` | -| `fallback` | `"none"` \| `"default"` | `"none"` | `"default"` serves the default language when a translation is missing | - -## `search` - -| Name | Type | Description | -| ------------------ | ---------------- | ------------------------------------------------------------------------------- | -| `enabled` | `true` | Required | -| `titleField` | text field | Required. Not nullable, and in `publicApi.fields` | -| `contentFields` | field names | Required. Slug, text or textarea fields that are public | -| `pathTemplate` | `string` | Required. The result URL with exactly one `{slug}`, such as `/recipes/{slug}`. A page must serve it | -| `descriptionField` | text or textarea | Optional, and public | -| `authorField` | `field.user()` | Optional. Credits people in the index; it does not have to be public | - -Drafts are indexed as private documents; public search returns only published records. - -## `indexes` - -```ts -indexes: [ - { on: ["difficulty", "cookingTime"] }, - { on: ["code"], unique: true, name: "example_recipes_code_key" }, -], -``` - -`on` takes shared fields, group leaves such as `"seo.title"`, and system columns. Localized, `blocks`, to-many and repeatable fields cannot be indexed. Names are snake_case and at most 63 characters. Indexes the engine already creates are listed in [Database and migrations](/docs/dev/content-engine/database-and-migrations#generated-indexes). diff --git a/apps/web/content/docs/dev/content-engine/relations-and-advanced-modeling.mdx b/apps/web/content/docs/dev/content-engine/relations-and-advanced-modeling.mdx deleted file mode 100644 index 8cfc43c171..0000000000 --- a/apps/web/content/docs/dev/content-engine/relations-and-advanced-modeling.mdx +++ /dev/null @@ -1,123 +0,0 @@ ---- -title: Relations and advanced fields -description: Link Content Engine records with foreign keys and junction tables, group related fields into one object, and store repeatable rows in a real child table. -icon: Network ---- - -The Content Engine stores structured data in real tables, not JSON blobs. A relation is a foreign key, a to-many relation is an indexed junction table, a group is a set of columns and a repeatable field is a child table. This guide extends the recipes from [Create your first content type](/docs/dev/content-engine/defining-a-content-type) with a category, tags, a nutrition group and a list of ingredients. - -## Link to one record - -Point a recipe at a category with `field.relation`. `target` is a function, so two content types can refer to each other without an import cycle: - -```ts title="plugins/example/src/content/recipe.ts" -import { categoryContentType } from "./category"; - -fields: { - category: field.relation({ // [!code ++:5] - target: () => categoryContentType, - required: true, - onDelete: "restrict", - }), -} -``` - -Then tell `createContentModel` which column the foreign key references: - -```ts title="plugins/example/src/database/recipes.ts" -import { example_categories } from "./categories"; - -export const recipeContent = createContentModel(recipeContentType, { - references: { category: () => example_categories.id }, // [!code ++] -}); -``` - -`references` needs exactly one entry per relation field. A missing or extra key is a type error, and a missing entry also throws when the model is created. `field.user()`, `field.file()` and `self: true` relations resolve their table themselves and need no entry. - -The AdminCP form shows a searchable picker for the category. - -## Link to many records - -Add `multiple: true` for tags, co-authors or a gallery: - -```ts title="plugins/example/src/content/recipe.ts" -fields: { - tags: field.relation({ // [!code ++:5] - target: () => tagContentType, - multiple: true, - min: 1, - }), - cooks: field.user({ multiple: true, ordered: true }), // [!code ++] -} -``` - -Each to-many field gets a junction table with `itemId`, `relatedItemId`, `position` and `createdAt`. `ordered: true` keeps the order editors drag the items into. Export the junction tables, or Drizzle Kit leaves them out of the migration: - -```ts title="plugins/example/src/database/recipes.ts" -export const example_recipes = recipeContent.table; -export const example_recipes_tags = recipeContent.advancedTables.junctions.tags; // [!code ++:2] -export const example_recipes_cooks = recipeContent.advancedTables.junctions.cooks; -``` - -To-many fields cannot set `required`, `nullable` or `onDelete: "set null"`. Use `min` to demand at least one item. - -## Group fields - -`field.group` keeps related fields together under one key: - -```ts -nutrition: field.group({ - fields: { - calories: field.number({ integer: true, min: 0, nullable: true }), - protein: field.number({ integer: false, min: 0, nullable: true }), - notes: field.textarea({ nullable: true }), - }, -}), -``` - -A group adds one column per leaf to the main table, named in camelCase: `nutritionCalories`, `nutritionProtein`, `nutritionNotes`. The API reads and writes it as `{ nutrition: { calories, protein, notes } }`. Leaves can be text, textarea, number, boolean, enum or dateTime fields. A group that is not required needs leaves that are nullable or have a default. Refer to a leaf elsewhere with a path such as `"nutrition.calories"`. - -## Repeatable rows - -`field.repeatable` stores an ordered list in a child table, which suits ingredients, steps or FAQ entries: - -```ts -ingredients: field.repeatable({ - min: 1, - max: 40, - fields: { - name: field.text({ required: true, maxLength: 120 }), - amount: field.text({ maxLength: 40, nullable: true }), - }, -}), -``` - -The child table `example_recipes_ingredients` has `id`, `itemId`, `position`, `createdAt`, `updatedAt` and one column per field. Deleting a recipe deletes its rows. `max` defaults to 100. Export the table as well: - -```ts title="plugins/example/src/database/recipes.ts" -export const example_recipes_ingredients = - recipeContent.advancedTables.repeatables.ingredients; -``` - -## Check the result - -Run `pnpm build:plugins && pnpm db:migrate` and restart `pnpm dev`. The generated migration should contain one table per junction and repeatable field. Open the create form: the category has a picker, tags accept several values, the nutrition fields sit in their own card and ingredients can be added, removed and reordered. - -## Relation options - -| Name | Type | Default | Description | -| ---------- | ------------------------------------------- | -------------- | ------------------------------------------------------------ | -| `target` | `() => ContentTypeDefinition` | | The linked content type. Use either `target` or `self` | -| `self` | `true` | | Links to the same content type, for parents or "related" lists | -| `required` | `boolean` | `false` | Single relations only | -| `nullable` | `boolean` | `false` | Single relations only. Set `required` or `nullable` | -| `multiple` | `boolean` | `false` | Uses a junction table | -| `ordered` | `boolean` | `false` | Keeps editor order. Needs `multiple` | -| `min` | `number` | | Fewest items, from 1 to 500. Needs `multiple` | -| `onDelete` | `"restrict"` \| `"cascade"` \| `"set null"` | `"restrict"` | What deleting the target does. `"set null"` needs a nullable single relation | - -A to-many relation accepts at most 500 items per record. - -## Service helpers - -The [content service](/docs/dev/content-engine/services-and-api) has helpers for these fields: `relations.tags.add()`, `.remove()`, `.set()`, `.reorder()` and `.get()`, and `repeatable.ingredients.create()`, `.update()`, `.delete()`, `.reorder()`, `.set()` and `.list()`. diff --git a/apps/web/content/docs/dev/content-engine/services-and-api.mdx b/apps/web/content/docs/dev/content-engine/services-and-api.mdx deleted file mode 100644 index db4e3e73ba..0000000000 --- a/apps/web/content/docs/dev/content-engine/services-and-api.mdx +++ /dev/null @@ -1,182 +0,0 @@ ---- -title: Services, schemas and events -description: Use a Content Engine content type from your own Hono routes with the typed service, reuse its Zod schemas, and react to content changes with typed events. -icon: Server ---- - -The generated routes cover the usual create, read, update and delete. When you need more, such as a "quick dinners" endpoint or a reaction to every published recipe, the model from `createContentModel` gives you typed services, Zod schemas and events. - -## Read records in a custom route - -`recipeContent.publicService` reads what the public API can read: published records and public fields only. It is typed as optional, because a content type without `publicApi` has none, so check it once. - -```ts title="plugins/example/src/api/modules/recipes/quick.route.ts" -import { buildRoute } from "@vitnode/core/api/lib/route"; - -import { CONFIG_PLUGIN } from "@/const"; -import { recipeContent } from "@/database/recipes"; - -export const quickRecipesRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - route: { - method: "get", - path: "/quick", - description: "Vegetarian recipes, quickest first", - responses: { 200: { description: "Quick vegetarian recipes" } }, - }, - handler: async c => { - const { publicService } = recipeContent; - if (!publicService) throw new Error("Recipes have no public API."); - - const { edges } = await publicService(c).findMany({ - filters: { vegetarian: true }, - orderBy: { column: "cookingTime", order: "asc" }, - query: { first: "5" }, - }); - - return c.json(edges); - }, -}); -``` - -`filters` accepts only `publicApi.filterableFields` and `orderBy.column` only orderable columns. `findBySlug(slug)` and `findById(id)` return one published record or `null`. - -## Write records - -`recipeContent.service(c)` is the unrestricted service: it sees drafts and every field. Use it in staff routes or background jobs, and check permissions yourself. - -```ts -const service = recipeContent.service(c); - -const recipe = await service.create({ title: "Pancakes", cookingTime: 20 }); -await service.update(recipe.id, { vegetarian: true }); -await service.publish(recipe.id); -``` - -`create` validates its input with the content type's Zod schema and throws a `ZodError` on invalid input. Every method except `findMany` and `options` accepts `{ tx }` as its last argument, so several calls can share a transaction: - -```ts -await c.get("db").transaction(async tx => { - const recipe = await service.create({ title: "Pancakes", cookingTime: 20 }, { tx }); - await service.publish(recipe.id, { tx }); -}); -``` - -The plain service does not emit events, store revisions or check versions. The generated routes do all three. When you want the same behaviour from your own code, use the editorial service below or call the generated routes. - -| Method | Returns | -| ------------------------------------ | ----------------------------------------------------------------------- | -| `findMany({ filters, orderBy, query, where })` | `{ edges, pageInfo }`, drafts included | -| `findById(id)` | The row, or `null` | -| `findRowById(id)` | The row with labels for relations and users, or `null` | -| `findDetail(id)` | The row with relation, repeatable and file values, or `null` | -| `create(values)` | The new row | -| `update(id, values)` | `{ row, changedFields }`, or `null` when the id does not exist | -| `delete(id)` | The deleted row, or `null` | -| `publish(id)`, `unpublish(id)` | `{ changed, publishedAt, row }`, or `null`. Only with `publication` | -| `advanced(id)`, `advancedFields(id, fields)` | Values of to-many, repeatable and group fields | -| `options(field, search?, ids?)` | `{ value, label, color? }[]` for a relation or user picker | -| `relations.<field>` | `add`, `remove`, `set`, `reorder`, `get` for a to-many field | -| `repeatable.<field>` | `create`, `update`, `delete`, `reorder`, `set`, `list` for a repeatable | - -## Editorial writes - -For a content type with [`editorial`](/docs/dev/content-engine/publication-and-editorial), `recipeContent.editorialService` writes the way the AdminCP does: it checks the version, stores a revision and emits events. Like `publicService`, it is optional in the type. - -```ts -import { resolveContentActor } from "@vitnode/core/content/server"; - -const { editorialService } = recipeContent; -if (!editorialService) throw new Error("Recipes have no editorial features."); - -const editorial = editorialService(c, { pluginId: CONFIG_PLUGIN.pluginId }); - -await editorial.update( - recipeId, - { title: "Shakshuka for four" }, - { actor: resolveContentActor(c), expectedVersion: 3 }, -); -``` - -When the record's version is no longer `3`, the call throws `ContentVersionConflict` from `@vitnode/core/content`. It carries `itemId`, `expectedVersion` and `currentVersion`, and the generated routes turn it into a `409` with the code `CONTENT_VERSION_CONFLICT`. Background jobs pass `CONTENT_SYSTEM_ACTOR` instead of a request's actor. - -## Reuse the schemas - -`recipeContent.schemas` holds the Zod schemas the generated routes use. Reuse them so a custom route validates exactly like the built-in ones: - -| Schema | Validates | -| --------------------- | ----------------------------------------------------- | -| `create` | A create payload, with required fields and defaults | -| `update` | A partial update payload | -| `select` | A full row | -| `publicSelect` | A row as the public API returns it | -| `filters` | Staff list filters | -| `publicFilters` | Public list filters | -| `form` | The AdminCP form values | - -```ts -request: { - body: { - content: { - "application/json": { schema: recipeContent.schemas.create }, - }, - }, -}, -``` - -## Events - -The generated routes emit an event after every change, named `content.{contentTypeId}.{action}`, such as `content.example.recipe.published`. Register the content type's events once so listeners get typed names and payloads: - -```ts title="plugins/example/src/api/lib/events.ts" -import type { ContentEventsFor } from "@vitnode/core/content"; - -import type { recipeContentType } from "@/content/recipe"; - -declare module "@vitnode/core/api/models/events" { - interface VitNodeEvents extends ContentEventsFor<typeof recipeContentType> {} -} -``` - -Then listen with `buildEventListener`: - -```ts title="plugins/example/src/api/lib/listeners.ts" -import { buildEventListener } from "@vitnode/core/api/lib/events"; - -export const onRecipePublished = buildEventListener({ - event: "content.example.recipe.published", - name: "announce-new-recipe", - description: "Logs every newly published recipe", - handler: async (c, payload) => { - await c - .get("log") - .debug(`Recipe ${payload.contentId} published at ${payload.publishedAt.toISOString()}`); - }, -}); -``` - -Add the listener to the `events` array of a **top-level** module, such as your `admin` module. Listeners on nested modules are not collected. - -```ts title="plugins/example/src/api/modules/admin/admin.module.ts" -export const adminModule = buildModule({ - pluginId: CONFIG_PLUGIN.pluginId, - name: "admin", - routes: [], - modules: [buildContentAdminModule({ pluginId: CONFIG_PLUGIN.pluginId, contentTypes: [recipeContent] })], - events: [onRecipePublished], // [!code ++] -}); -``` - -| Event | Payload | Available with | -| --------------------------- | ------------------------------------------------------------- | ------------------- | -| `created`, `deleted` | `{ contentId }` | always | -| `updated` | `{ contentId, changedFields }` | always | -| `published` | `{ contentId, publishedAt, scheduledBy?, scheduleId? }` | `publication` | -| `unpublished` | `{ contentId, scheduledBy?, scheduleId? }` | `publication` | -| `restored` | `{ contentId, changedFields, restoredFromRevisionId, revisionId, version }` | `editorial` | -| `scheduled` | `{ contentId, action, actorUserId, scheduledFor, scheduleId }` | `editorial.scheduling` | -| `schedule_cancelled` | `{ contentId, action, actorUserId, scheduleId }` | `editorial.scheduling` | -| `translation_created`, `translation_updated`, `translation_deleted`, `translation_published`, `translation_unpublished`, `translation_restored` | `{ contentId, languageId, locale, version, … }` | `localization` | -| `delivery_slug_changed`, `delivery_redirect_created` | `{ contentId, locale, canonicalPath, previousPath, previousSlug, … }` | `delivery` | - -No event fires when a save changes nothing. Every event, including core's, is listed in [Built-in events](/docs/dev/events/built-in-events). diff --git a/apps/web/content/docs/dev/content-engine/staff-permissions-recipes.png b/apps/web/content/docs/dev/content-engine/staff-permissions-recipes.png deleted file mode 100644 index 65a8bfce32..0000000000 Binary files a/apps/web/content/docs/dev/content-engine/staff-permissions-recipes.png and /dev/null differ diff --git a/apps/web/content/docs/dev/database/index.mdx b/apps/web/content/docs/dev/database/index.mdx index 74b44ff689..944eadcfbb 100644 --- a/apps/web/content/docs/dev/database/index.mdx +++ b/apps/web/content/docs/dev/database/index.mdx @@ -87,7 +87,7 @@ Run these with the [VitNode CLI](/docs/dev/cli/database) in the app that owns th | `vitnode db push` | Syncs the schema directly - development only | <Callout type="info" title="Content Engine Alternative"> - If your table represents an editorial entity (articles, FAQs, products), consider [Content Engine](/docs/dev/content-engine/defining-a-content-type) to generate your Drizzle table, API routes, and AdminCP screens from one definition. + If your table represents an editorial entity (articles, FAQs, products), consider [Content Engine](/docs/content-engine/create-a-content-type) to generate your Drizzle table, API routes, and AdminCP screens from one definition. </Callout> ## Connection pool @@ -171,6 +171,6 @@ Direct connections and session-mode poolers keep `prepare: true` - it is the fas <Card title="Defining a Content Type" description="Generate database models with Content Engine" - href="/docs/dev/content-engine/defining-a-content-type" + href="/docs/content-engine/create-a-content-type" /> </Cards> diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index 4f1f64b72a..e1796918ab 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -619,7 +619,7 @@ export const pageLayoutCacheListener = buildEventListener({ ## Content Engine events Every content type declared with the -[Content Engine](/docs/dev/content-engine) gets its own event names, built from +[Content Engine](/docs/content-engine) gets its own event names, built from its id: `content.<id>.<action>`. For the example plugin's `example.article` that means `content.example.article.created`, and for the blog's `blog.post` it means `content.blog.post.created`. @@ -799,7 +799,7 @@ bookings collide, or pre-warm a render shortly before `scheduledFor`. ### Translation events -A content type with [`localization`](/docs/dev/content-engine/localization) +A content type with [`localization`](/docs/content-engine/localization) emits one event per translation mutation, in addition to the record events above. Every one of them carries `locale` and `languageId`, so a listener never has to go and ask which language moved. @@ -879,7 +879,7 @@ translation event names localized fields only, and never a shared one. ### Delivery events A content type with -[`delivery`](/docs/dev/content-engine/content-delivery-and-seo) emits two more +[`delivery`](/docs/content-engine/public-pages) emits two more when its public URL moves. Both arrive **alongside** the lifecycle event that moved it - `updated`, `restored`, a publication transition or a `translation_*` - never instead of one: a field moving and a URL moving are @@ -1033,13 +1033,13 @@ inspects `failures` and retries the whole delivery when it is non-empty. no-op publish and a restore that changed nothing all emit nothing. Calling `service.publish()` - or any other content service method - directly changes the database and emits **nothing**; that code owns its own follow-up. See - [Services and API](/docs/dev/content-engine/services-and-api). + [Services and API](/docs/content-engine/services). </Callout> ## Blog events (`@vitnode/blog`) <Callout type="warn" title="These six names are compatibility adapters"> - The blog runs on the [Content Engine](/docs/dev/content-engine), so the events + The blog runs on the [Content Engine](/docs/content-engine), so the events that describe what actually happened are `content.blog.post.*` and `content.blog.category.*` - they carry changed fields, revision ids, publication transitions, per-locale translation events and slug history. The @@ -1150,7 +1150,7 @@ deleted in the first place. so `content.example.localized-article.created` really is emitted and still cannot be given a type-checked listener. If your event name does not autocomplete, that declaration is what is missing - see - [Plugin registration](/docs/dev/content-engine/plugin-registration). + [Plugin registration](/docs/content-engine/plugin-files). </Callout> <Callout type="warn" title="Editorial payloads carry more than they declare"> @@ -1176,6 +1176,13 @@ issue or PR - adding an event is a one-line `emit` plus an entry in the `VitNodeEvents` map. (`user.deleted` is the special case: it is already on the map, and waits on core growing a user-deletion flow.) +Live editing is quiet on the bus too. Taking or giving back a field lock, +autosaving the shared draft and every keystroke in a co-edited rich text field +happen many times a minute and change nothing anyone outside the form relies +on, so none of them is an event. What does reach the bus is the commit: when +someone presses **Save**, the record emits its usual `updated` (and its +translations theirs), exactly as it would without live editing. + ## Next <Cards> @@ -1191,7 +1198,7 @@ map, and waits on core growing a user-deletion flow.) /> <Card title="Content Engine" - href="/docs/dev/content-engine" + href="/docs/content-engine" description="Declare a content type and its whole event set is generated for you." /> <Card diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index 0ce4674006..cb5dea8bb4 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -300,7 +300,7 @@ export const deleteNote = async (id: string) => { ## Content Engine routes -Public routes created via [Content Engine](/docs/dev/content-engine) (`publicApi`) are automatically typed under `module: "content/<path>"`: +Public routes created via [Content Engine](/docs/content-engine) (`publicApi`) are automatically typed under `module: "content/<path>"`: ```ts const response = await fetcher({ @@ -312,14 +312,14 @@ const response = await fetcher({ }) ``` -For generated AdminCP content modules where schemas are dynamic, use `rawFetcher` from `@vitnode/core/tanstack/fetcher` instead. See [Content Engine](/docs/dev/content-engine) for defining content types, schema modeling, and generating CRUD APIs. +For generated AdminCP content modules where schemas are dynamic, use `rawFetcher` from `@vitnode/core/tanstack/fetcher` instead. See [Content Engine](/docs/content-engine) for defining content types, schema modeling, and generating CRUD APIs. <Cards> <Card icon={<BoxesIcon />} title="Content Engine" description="Define content types, schemas, and typed public endpoints." - href="/docs/dev/content-engine" + href="/docs/content-engine" /> <Card icon={<MapIcon />} diff --git a/apps/web/content/docs/dev/i18n/localized-urls.mdx b/apps/web/content/docs/dev/i18n/localized-urls.mdx index 16f57021ef..5102fc6498 100644 --- a/apps/web/content/docs/dev/i18n/localized-urls.mdx +++ b/apps/web/content/docs/dev/i18n/localized-urls.mdx @@ -357,7 +357,7 @@ export const vitNodeApiConfig = buildApiConfig({ A plugin whose content publishes no URLs (no delivery, no search) needs none of this. -Details live in [Content Delivery and SEO](/docs/dev/content-engine/content-delivery-and-seo) and [Search](/docs/dev/search). +Details live in [Content Delivery and SEO](/docs/content-engine/public-pages) and [Search](/docs/dev/search). <Callout type="info" title="Rebuild the search index after URL changes"> Search stores each result's URL when it indexes it. After you change @@ -462,7 +462,7 @@ export const i18n = { <Card title="Content Delivery and SEO" description="Canonical URLs, hreflang, redirects and sitemaps for content" - href="/docs/dev/content-engine/content-delivery-and-seo" + href="/docs/content-engine/public-pages" /> <Card title="Search" diff --git a/apps/web/content/docs/dev/index.mdx b/apps/web/content/docs/dev/index.mdx index 6e86d2c289..7c9e658a73 100644 --- a/apps/web/content/docs/dev/index.mdx +++ b/apps/web/content/docs/dev/index.mdx @@ -100,6 +100,6 @@ route to visit—not just a philosophical plugin. icon={<BookOpenIcon />} title="Content Engine" description="Define content once and receive data, API, and AdminCP tools." - href="/docs/dev/content-engine" + href="/docs/content-engine" /> </Cards> diff --git a/apps/web/content/docs/dev/meta.json b/apps/web/content/docs/dev/meta.json index f0d283f7a2..91ff1b562c 100644 --- a/apps/web/content/docs/dev/meta.json +++ b/apps/web/content/docs/dev/meta.json @@ -22,7 +22,6 @@ "i18n", "performance", "---Extend VitNode---", - "content-engine", "widgets", "working-with-users", "events", diff --git a/apps/web/content/docs/dev/performance.mdx b/apps/web/content/docs/dev/performance.mdx index 92728897d1..2fb7cd87ed 100644 --- a/apps/web/content/docs/dev/performance.mdx +++ b/apps/web/content/docs/dev/performance.mdx @@ -133,7 +133,7 @@ The home page went from 133 running animations to 42 on arrival with this one ru ### 6. Keep the Plugin Factory Light -`vitnode.config.ts` is bundled with the document shell, so everything a plugin factory imports ships to every visitor. A factory that spreads `admin/content` or `admin/nav` drags the AdminCP editing stack - AutoForm, schemas, dialogs - into the public bundle. Keep it to `pluginId`, `messages` and `routes`; see [Plugin frontend modules](/docs/dev/content-engine/plugin-registration). +`vitnode.config.ts` is bundled with the document shell, so everything a plugin factory imports ships to every visitor. A factory that spreads `admin/content` or `admin/nav` drags the AdminCP editing stack - AutoForm, schemas, dialogs - into the public bundle. Keep it to `pluginId`, `messages` and `routes`; see [Plugin frontend modules](/docs/content-engine/plugin-files). ### 7. Route-Scoped Stylesheets diff --git a/apps/web/content/docs/dev/search-elasticsearch.mdx b/apps/web/content/docs/dev/search-elasticsearch.mdx index d4fd58d065..250dd309c8 100644 --- a/apps/web/content/docs/dev/search-elasticsearch.mdx +++ b/apps/web/content/docs/dev/search-elasticsearch.mdx @@ -102,6 +102,6 @@ collection) needs the **Rebuild and clear the search index** permission <Card title="Content Engine" description="Make a content type searchable without hand-writing an indexer." - href="/docs/dev/content-engine/content-delivery-and-seo" + href="/docs/content-engine/public-pages" /> </Cards> diff --git a/apps/web/content/docs/dev/search.mdx b/apps/web/content/docs/dev/search.mdx index 8709bae3a0..1e222b31f9 100644 --- a/apps/web/content/docs/dev/search.mdx +++ b/apps/web/content/docs/dev/search.mdx @@ -137,7 +137,6 @@ export const blogPostContentType = defineContentType({ // ... }, search: { - enabled: true, titleField: 'title', contentFields: ['title', 'content'], pathTemplate: '/blog/{slug}', @@ -157,7 +156,7 @@ The timelines come from two routes: ## Result URLs for Content Engine types -`search.pathTemplate` is the English route of a result's page, with one `{slug}` placeholder. Leave the language out: VitNode adds the prefix, the domain and any translated segments from your app's [localized URL](/docs/dev/i18n/localized-urls) settings. +`search.pathTemplate` is the English route of a result's page, with one `{slug}` placeholder. To turn search on for a content type, see [Content Engine search](/docs/content-engine/search). Leave the language out: VitNode adds the prefix, the domain and any translated segments from your app's [localized URL](/docs/dev/i18n/localized-urls) settings. | `pathTemplate` | English result | Polish result (`as-needed`) | Polish result (with `domains`) | | :----------------------- | :---------------------- | :-------------------------- | :----------------------------------- | @@ -256,8 +255,8 @@ A rebuild is a queued job, so it only runs once [cron](/docs/dev/cron) is runnin href="/docs/dev/search-elasticsearch" /> <Card - title="Content Engine Delivery" + title="Content Engine search" description="Automatic search indexing with Content Engine" - href="/docs/dev/content-engine/content-delivery-and-seo" + href="/docs/content-engine/search" /> </Cards> diff --git a/apps/web/content/docs/dev/storage/index.mdx b/apps/web/content/docs/dev/storage/index.mdx index 8f092c35c2..5a29d7d262 100644 --- a/apps/web/content/docs/dev/storage/index.mdx +++ b/apps/web/content/docs/dev/storage/index.mdx @@ -150,7 +150,7 @@ Test your storage adapter anytime in the AdminCP: <Card title="Content Engine Fields" description="Use field.file() in Content Engine models" - href="/docs/dev/content-engine/fields" + href="/docs/content-engine/fields" /> <Card title="Self-Hosted Deployment" diff --git a/apps/web/content/docs/dev/websocket.mdx b/apps/web/content/docs/dev/websocket.mdx index e6f1fd7526..ed4b039cb9 100644 --- a/apps/web/content/docs/dev/websocket.mdx +++ b/apps/web/content/docs/dev/websocket.mdx @@ -108,12 +108,92 @@ export const ChatRoom = ({ roomId }: { roomId: string }) => { --- +## Rooms + +A room is a named group of browser tabs, such as everyone looking at one chat thread or editing one record. Send a message to a room and only its members get it, on every API instance. A room is any string your feature picks, for example `chat:42`. + +### Join and leave per tab + +All tabs of a browser share one socket, so a socket joins a room on behalf of a tab. Let each tab send its own random `clientId` with every message, and join or leave with it: + +```ts title="plugins/chat/src/api/modules/rooms/rooms.socket.ts" +import { buildWebSocket } from "@vitnode/core/api/lib/websocket" +import { wsRegistry } from "@vitnode/core/ws/registry" + +interface RoomMessage { + clientId: string + roomId: string + type: "join" | "leave" +} + +export const roomsSocket = buildWebSocket<RoomMessage>({ + id: "presence", + onMessage: ({ data, ws }) => { + const room = `chat:${data.roomId}` + + // [!code ++:5] + if (data.type === "join") { + wsRegistry.join(ws, room, data.clientId) + } else { + wsRegistry.leave(ws, room, data.clientId) + } + }, +}) +``` + +Register the socket on its module with `webSockets: [roomsSocket]`. The socket stays in the room until its last tab leaves, so a closed tab never cuts off the others. Check who may join (the session on `c`, a permission) before calling `join`: a room is only as private as that check. + +`join` and `leave` return `true` when they changed something. `wsRegistry.membersOf(room)` lists the members on this instance and `wsRegistry.roomsOf(ws)` lists the rooms of one socket. + +### Send to a room + +```ts +// [!code ++:4] +c.get("realtime").toRoom(`chat:${roomId}`, chatChannel, { + roomId, + message: "Hello!", + senderId: user.id, +}) +``` + +Each socket in the room receives the payload once, however many of its tabs joined. Put the room or record in the payload so every tab can ignore what is not its own. + +### Clean up when a socket closes + +A socket that closes leaves all of its rooms. Use `onConnectionClose` to update your own state, such as an "online now" list: + +```ts title="plugins/chat/src/api/modules/rooms/presence.ts" +import { wsRegistry } from "@vitnode/core/ws/registry" + +// [!code ++:5] +wsRegistry.onConnectionClose((ws, left) => { + for (const { memberId, room } of left) { + markOffline(room, memberId) + } +}) +``` + +`left` holds every `{ memberId, room }` the socket was in. Register the listener once, at module load. + +### Rooms on several instances + +With `REDIS_URL` set, `toRoom` also publishes the message over Redis, and every other instance delivers it to its own members. Room membership itself stays local to each instance. + +When an instance keeps per-room state in memory, it can follow the other instances: + +- `onRemoteRoomMessage((room, id, data) => ...)` sees room messages from other instances before they reach local sockets. A collaborative document uses it to apply the same edit to its own copy. +- `publishToInstances(topic, data)` and `onInstanceMessage(({ topic, data, origin }) => ...)` exchange data between instances that never reaches a socket, such as each instance's list of members. Republish it periodically and let entries from a silent instance expire, so a crashed instance leaves no ghosts. + +Without Redis, all of this works on a single instance with no extra setup: `toRoom` delivers locally and instance messages go nowhere. + +--- + ## Architectural Highlights - **Single Connection**: Only one `/api/ws` socket is opened per client. All features share it via message multiplexing. - **Tab Leader Election**: When a user opens multiple tabs, a Web Lock elects one leader tab to hold the socket, distributing messages across tabs via `BroadcastChannel`. - **Automatic Reconnect**: Backoff retry automatically re-establishes dropped connections within seconds. -- **Multiple Instances**: With `REDIS_URL` set, `sendToUser` and `broadcast` are relayed to every API instance over Redis pub/sub. Without Redis they only reach clients connected to the same instance. +- **Multiple Instances**: With `REDIS_URL` set, `sendToUser`, `broadcast` and `toRoom` are relayed to every API instance over Redis pub/sub. Without Redis they only reach clients connected to the same instance. ## Learn More diff --git a/apps/web/content/docs/dev/widgets/blocks-field.mdx b/apps/web/content/docs/dev/widgets/blocks-field.mdx index 15238d5d87..c642cdd66c 100644 --- a/apps/web/content/docs/dev/widgets/blocks-field.mdx +++ b/apps/web/content/docs/dev/widgets/blocks-field.mdx @@ -23,7 +23,7 @@ export const pageContentType = defineContentType({ }), }, - publication: { enabled: true }, + publication: true, }) ``` @@ -169,7 +169,6 @@ For a zone that is not a column on a record at all - a settings screen, a forum ```ts publicApi: { - enabled: true, path: "pages", fields: ["title", "slug", "content", "publishedAt"], } diff --git a/apps/web/content/docs/dev/widgets/end-to-end.mdx b/apps/web/content/docs/dev/widgets/end-to-end.mdx index 54faf87c92..dc2f57bdcc 100644 --- a/apps/web/content/docs/dev/widgets/end-to-end.mdx +++ b/apps/web/content/docs/dev/widgets/end-to-end.mdx @@ -132,10 +132,9 @@ export const pageContentType = defineContentType({ }), }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, path: 'pages', fields: ['title', 'slug', 'content', 'publishedAt'], }, diff --git a/apps/web/content/docs/guides/blog.mdx b/apps/web/content/docs/guides/blog.mdx index d2921c93b6..ab755c82e5 100644 --- a/apps/web/content/docs/guides/blog.mdx +++ b/apps/web/content/docs/guides/blog.mdx @@ -104,30 +104,36 @@ boringly reliable. ## Write and translate articles -The article editor keeps the text in the middle and everything readers see -around it in a **Ready to publish** panel on the right. +The article editor reads top to bottom like the published article: the cover +image and its alt text, then the title, slug and content. The checks before +publishing live in the **SEO** sheet, so the text gets the whole width. -{/* Image prompt: VitNode AdminCP blog article editor. Large article title, slug under it, a TipTap editor, and a right panel with a "Ready to publish" checklist, a Google-style search preview and a social card. Header with Translate, a Draft/Published switch and Save changes. Light theme, 1440x900. */} +{/* Image prompt: VitNode AdminCP blog article editor at full width. A wide cover image with Replace file and Remove buttons in its corner, alt text field under it, a large article title and slug, then a TipTap editor. Header with Translate, SEO with a small amber badge "2", AI review, a Draft/Published switch and Save changes. Light theme, 1440x900. */} ### Check the article before you publish -The checklist counts what a published article usually needs: +Press **SEO** in the header to open the **Ready to publish** sheet. The badge on +the button counts what is still left, so you know when it needs a look. The +sheet checks what a published article usually needs: -| Check | Passes when | -| --- | --- | -| Title fits search results | Every started language has a title of 60 characters or less | -| Excerpt in every language | Every started language has an excerpt | -| Cover image | A cover image is set | -| Cover alt text in every language | Every started language describes the cover | -| Translations complete | Every language has a title, friendly URL and content | -| Translations up to date | No translation is older than the default language | +| Check | Passes when | +| -------------------------------- | ----------------------------------------------------------- | +| Title fits search results | Every started language has a title of 60 characters or less | +| Excerpt in every language | Every started language has an excerpt | +| Cover image | A cover image is set | +| URL | The language you edit has a friendly URL | +| Cover alt text in every language | Every started language describes the cover | +| Translations complete | Every language has a title, friendly URL and content | +| Translations up to date | No translation is older than the default language | The title, excerpt and alt text fields show a live counter against the recommended length: 60, 160 and 125 characters. They are recommendations, not limits, so you can still save a longer title. -The search result and social card previews switch between languages. A missing -field falls back to the default language, the same way the public site does. +Each row is green when it is done and says what is missing when it is not, and +the bar on top counts how many are ready. You can fix most rows right there: +write the excerpt in its row, press **Edit under the cover** to jump to the alt +text, or **Open** a language that is missing a translation. ### Publish or unpublish @@ -148,8 +154,13 @@ it. **Generate from title** builds the friendly URL from the translated title. With AI configured, the editor adds: - **Translate** on each field, and **Translate missing fields** for a whole - language. Content keeps its HTML structure. + language. Content keeps its formatting. - **Write with AI** on the excerpt, which writes it from the title and content. +- **AI review** in the header, which opens a sheet with suggestions about + clarity, completeness, structure and tone. When a suggestion is about the + wording, **Show fix** shows the current text next to a rewrite, and **Apply** + puts the rewrite into the article. **Dismiss** hides a suggestion you do not + agree with. The review never blocks publishing. <Callout type="info" title="When the AI buttons appear"> The buttons appear only when the API has at least one model in `ai.models`. @@ -158,12 +169,27 @@ With AI configured, the editor adds: </Callout> AI never saves for you. Read the suggestion, then press **Save changes**. +While others are editing the article, a translated body goes into the shared +editor as one change, so everyone sees it and you can undo it. + +### Write together + +Several people can work on one article at once. The header shows who else is +in it, and a colored outline with a name tag shows who is editing which field. The title, +slug, excerpt and other plain fields are edited by one person at a time; the +article body is shared, with everyone's cursor visible. Your changes are +autosaved to a draft until someone presses **Save changes**. See +[Live editing and autosave](/docs/content-engine/live-editing) for how it +works and what it needs on your server. ### Upgrade an existing blog Articles have a localized **Excerpt** field. Search results use it as the -description and fall back to the article content when it is empty. Run your -migrations after you upgrade `@vitnode/blog`: +description and fall back to the article content when it is empty. The article +content is now stored as [rich text](/docs/content-engine/rich-text) JSON +instead of HTML; the migration keeps the words of existing articles as one +paragraph, so re-check their formatting. Run your migrations after you upgrade +`@vitnode/blog`: <Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Migrate the blog excerpt"> @@ -198,7 +224,7 @@ together. icon={<CompassIcon />} title="Content delivery and SEO" description="Add plugin routes, redirects, canonical metadata, and sitemaps." - href="/docs/dev/content-engine/content-delivery-and-seo" + href="/docs/content-engine/public-pages" /> <Card icon={<LayoutDashboardIcon />} diff --git a/apps/web/content/docs/meta.json b/apps/web/content/docs/meta.json index 653dab9944..2cb92aaccf 100644 --- a/apps/web/content/docs/meta.json +++ b/apps/web/content/docs/meta.json @@ -1,3 +1,3 @@ { - "pages": ["dev", "guides", "ui", "brand"] + "pages": ["dev", "content-engine", "guides", "ui", "brand"] } diff --git a/apps/web/content/docs/ui/user.mdx b/apps/web/content/docs/ui/user.mdx index 26e0e0cb21..20c95fadf4 100644 --- a/apps/web/content/docs/ui/user.mdx +++ b/apps/web/content/docs/ui/user.mdx @@ -95,7 +95,7 @@ makes `nameCode` and `avatarColor` optional. ## In the Content Engine -A [`field.user()`](/docs/dev/content-engine/fields) renders this field, like +A [`field.user()`](/docs/content-engine/fields) renders this field, like the blog post author picker. Its options come from the content type's own picker route, so editors can pick an author without browsing the member list. diff --git a/apps/web/src/docs/redirects.ts b/apps/web/src/docs/redirects.ts new file mode 100644 index 0000000000..96ec68a336 --- /dev/null +++ b/apps/web/src/docs/redirects.ts @@ -0,0 +1,27 @@ +const MOVED_SECTIONS: readonly (readonly [string, string])[] = [ + ['dev/content-engine', 'content-engine'], +] + +const RENAMED_PAGES: Readonly<Record<string, string>> = { + 'content-engine/content-delivery-and-seo': 'content-engine/public-pages', + 'content-engine/database-and-migrations': 'content-engine/database', + 'content-engine/defining-a-content-type': + 'content-engine/create-a-content-type', + 'content-engine/plugin-registration': 'content-engine/plugin-files', + 'content-engine/production-and-security': 'content-engine/production', + 'content-engine/public-api-and-caching': 'content-engine/public-api', + 'content-engine/publication-and-editorial': 'content-engine/editorial', + 'content-engine/relations-and-advanced-modeling': 'content-engine/relations', + 'content-engine/services-and-api': 'content-engine/services', +} + +export const movedDocsPath = (splat: string): string | undefined => { + const path = splat.replace(/^\/+|\/+$/g, '') + const section = MOVED_SECTIONS.find( + ([from]) => path === from || path.startsWith(`${from}/`), + ) + const moved = section ? `${section[1]}${path.slice(section[0].length)}` : path + const renamed = RENAMED_PAGES[moved] ?? moved + + return renamed === path ? undefined : renamed +} diff --git a/apps/web/src/locales/@vitnode/blog/pl.json b/apps/web/src/locales/@vitnode/blog/pl.json index 7aeba7cf82..38df53ecd8 100644 --- a/apps/web/src/locales/@vitnode/blog/pl.json +++ b/apps/web/src/locales/@vitnode/blog/pl.json @@ -57,7 +57,6 @@ "not_configured": "Ta strona nie ma skonfigurowanego modelu AI." }, "publish": { - "open": "Gotowość do publikacji: {done} z {total}", "title": "Gotowe do publikacji", "checks": { "title": "Tytuł mieści się w wynikach wyszukiwania", @@ -74,17 +73,54 @@ "outdated_detail": "{languages} zmieniano dawniej niż wersję {source}.", "open": "Otwórz: {language}", "write": "Napisz z AI" + } + }, + "review": { + "title": "Recenzja AI", + "run": "Sprawdź z AI", + "again": "Sprawdź ponownie", + "nothing": "Brak sugestii - dobra robota.", + "high": "Warto poprawić", + "areas": { + "clarity": "Przejrzystość", + "completeness": "Kompletność", + "structure": "Struktura", + "tone": "Ton" }, - "previews": { - "title": "Podgląd", - "language": "Język podglądu", - "search": "Wynik wyszukiwania", - "social": "Karta społecznościowa", - "fallback": "Czytelnicy w języku {language} zobaczą tekst w wersji {source} w brakujących polach.", - "no_cover": "Dodaj obraz wyróżniający, aby zobaczyć kartę społecznościową." + "open": "Recenzja AI", + "intro": "AI czyta artykuł, wskazuje, co zmienić, i przygotowuje poprawki sformułowań. Nie sprawdza faktów i nic się nie zmieni, dopóki nie klikniesz Zastosuj.", + "verdict": "Warto poprawić: {high}, opcjonalnie: {low}", + "fix": { + "show": "Pokaż poprawkę", + "apply": "Zastosuj", + "dismiss": "Odrzuć", + "undo": "Cofnij", + "applied": "Zastosowano", + "dismissed": "Odrzucono", + "before": "Obecny tekst", + "after": "Proponowany tekst", + "removes": "Ten fragment zostanie usunięty.", + "stale": "Ten fragment zmienił się po recenzji, więc poprawki nie da się zastosować. Uruchom recenzję ponownie." + } + }, + "seo": { + "open": "SEO", + "open_issues": "SEO: {count, plural, one {# rzecz do poprawy} few {# rzeczy do poprawy} many {# rzeczy do poprawy} other {# rzeczy do poprawy}}", + "title": "SEO", + "desc": "Jak ten artykuł wygląda w wynikach wyszukiwania i po udostępnieniu.", + "left": "Zostało: {items}", + "done": "Gotowe", + "needs_work": "Do poprawy", + "rows": { + "title": "Tytuł", + "excerpt": "Zajawka", + "url": "URL", + "cover": "Obraz do udostępniania", + "alt": "Tekst alternatywny obrazu" }, - "details": "Szczegóły", - "cover": "Obraz wyróżniający" + "url_missing": "Brak adresu URL w tym języku.", + "cover_cropped": "Obraz wyróżniający przycięty do kart społecznościowych.", + "alt_edit": "Edytuj pod obrazem" } } }, diff --git a/apps/web/src/routes/_docs/docs.$.tsx b/apps/web/src/routes/_docs/docs.$.tsx index 6ade479c34..78327e75df 100644 --- a/apps/web/src/routes/_docs/docs.$.tsx +++ b/apps/web/src/routes/_docs/docs.$.tsx @@ -1,14 +1,27 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, redirect } from '@tanstack/react-router' import { pageHead } from '@vitnode/core/tanstack/metadata' import { Suspense } from 'react' import { DocsError, DocsNotFound } from '@/docs/error-views' import { DocsPageContent } from '@/docs/page-content' import { DocsPagePendingSkeleton } from '@/docs/pending' +import { movedDocsPath } from '@/docs/redirects' import { DOCS_STALE_TIME } from '@/docs/shared' import { getDocsPage } from '@/docs/transport' export const Route = createFileRoute('/_docs/docs/$')({ + beforeLoad: ({ params }) => { + const moved = movedDocsPath(params._splat ?? '') + + if (moved) { + // oxlint-disable-next-line typescript/only-throw-error + throw redirect({ + params: { _splat: moved }, + statusCode: 301, + to: '/docs/$', + }) + } + }, loader: async ({ params }) => { const page = await getDocsPage({ data: params._splat ?? '' }) const { docs } = await import('@/docs/source') diff --git a/apps/web/src/routes/docs.{$}[.]md.ts b/apps/web/src/routes/docs.{$}[.]md.ts index 1381be17a6..b4a051e291 100644 --- a/apps/web/src/routes/docs.{$}[.]md.ts +++ b/apps/web/src/routes/docs.{$}[.]md.ts @@ -1,13 +1,23 @@ import { createFileRoute } from '@tanstack/react-router' -import { decodeMarkdownUrl } from '@/docs/shared' +import { movedDocsPath } from '@/docs/redirects' +import { decodeMarkdownUrl, encodeMarkdownUrl } from '@/docs/shared' import { getLLMText, source } from '@/docs/source.server' export const Route = createFileRoute('/docs/{$}.md')({ server: { handlers: { - GET: async ({ params }) => { + GET: async ({ params, request }) => { const slugs = decodeMarkdownUrl(params._splat?.split('/') ?? []) + const moved = movedDocsPath(slugs.join('/')) + + if (moved) { + return Response.redirect( + new URL(encodeMarkdownUrl(moved.split('/')), request.url), + 301, + ) + } + const page = source.getPage(slugs) if (!page) return new Response('Not Found', { status: 404 }) diff --git a/apps/web/src/site/home/sections/features-bento.tsx b/apps/web/src/site/home/sections/features-bento.tsx index 1b5fc07296..10b04d9d43 100644 --- a/apps/web/src/site/home/sections/features-bento.tsx +++ b/apps/web/src/site/home/sections/features-bento.tsx @@ -33,7 +33,7 @@ const FEATURES: Feature[] = [ { className: 'md:col-span-2 lg:row-span-2', heading: 'Describe it once. Ship the whole thing.', - docs: 'dev/content-engine', + docs: 'content-engine', Icon: Boxes, linkLabel: 'Explore the Content Engine', text: 'Describe a content type in one file and get the database table, a typed API, admin screens, search indexing and translations. Articles, listings, docs, events: anything with a shape.', diff --git a/apps/web/src/site/marketing/footer.tsx b/apps/web/src/site/marketing/footer.tsx index 9ac53a03d0..823b75a51f 100644 --- a/apps/web/src/site/marketing/footer.tsx +++ b/apps/web/src/site/marketing/footer.tsx @@ -16,7 +16,7 @@ const COLUMNS: { links: FooterLink[]; title: string }[] = [ { href: '/', label: 'Overview' }, { href: '/solutions', label: 'Solutions' }, { href: '/plugins', label: 'Plugins' }, - { href: '/docs/dev/content-engine', label: 'Content Engine' }, + { href: '/docs/content-engine', label: 'Content Engine' }, { href: '/docs/dev/plugins/admin', label: 'Admin Control Panel' }, ], title: 'Product', diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 2941fd5e02..a3abdb57e3 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -92,6 +92,7 @@ --ui-color: oklch(0.52 0.102 170); --guides-color: oklch(0.45 0.16 264); --brand-color: oklch(0.55 0.17 10); + --content-engine-color: oklch(0.52 0.13 145); } .dark { @@ -99,6 +100,7 @@ --ui-color: oklch(0.7 0.138 170); --guides-color: oklch(0.65 0.16 264); --brand-color: oklch(0.72 0.15 10); + --content-engine-color: oklch(0.72 0.15 145); } .dev { @@ -117,6 +119,10 @@ --color-fd-primary: var(--brand-color); } +.content-engine { + --color-fd-primary: var(--content-engine-color); +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 4fbb59b229..3b9ee5f49a 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -114,6 +114,16 @@ "types": "./dist/src/content/server/index.d.ts", "default": "./dist/src/content/server/index.js" }, + "./content/rich-text": { + "import": "./dist/src/content/rich-text/index.js", + "types": "./dist/src/content/rich-text/index.d.ts", + "default": "./dist/src/content/rich-text/index.js" + }, + "./components/rich-text": { + "import": "./dist/src/components/rich-text/index.js", + "types": "./dist/src/components/rich-text/index.d.ts", + "default": "./dist/src/components/rich-text/index.js" + }, "./content/editor": { "import": "./dist/src/content/editor/index.js", "types": "./dist/src/content/editor/index.d.ts", @@ -233,6 +243,8 @@ "@simplewebauthn/server": "^14.0.3", "@tanstack/charts": "^1.0.0", "@tiptap/extension-audio": "^3.31.4", + "@tiptap/extension-collaboration": "^3.31.4", + "@tiptap/extension-collaboration-caret": "^3.31.4", "@tiptap/extension-drag-handle": "^3.31.4", "@tiptap/extension-drag-handle-react": "^3.31.4", "@tiptap/extension-emoji": "^3.31.4", @@ -246,6 +258,7 @@ "@tiptap/react": "^3.31.4", "@tiptap/starter-kit": "^3.31.4", "@tiptap/suggestion": "^3.31.4", + "@tiptap/y-tiptap": "^3.0.9", "ai": "^7.0.134", "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", @@ -271,7 +284,9 @@ "shadcn": "^4.21.4", "sharp": "^0.35.5", "use-debounce": "^10.1.1", - "use-intl": "^4.14.9" + "use-intl": "^4.14.9", + "y-protocols": "^1.0.7", + "yjs": "^13.6.33" }, "peerDependenciesMeta": { "@tanstack/react-query": { diff --git a/packages/vitnode/src/api/lib/check-staff-permission.ts b/packages/vitnode/src/api/lib/check-staff-permission.ts index 0f532ec794..be5a2095fa 100644 --- a/packages/vitnode/src/api/lib/check-staff-permission.ts +++ b/packages/vitnode/src/api/lib/check-staff-permission.ts @@ -226,18 +226,26 @@ export const getStaffFlags = async ( }; }; -export const checkStaffPermission = async ( +export const checkStaffPermissionOfUser = async ( c: Context, + user: StaffUser, { type, ...args }: PermissionsStaffArgs & { type: PermissionStaffType }, ): Promise<boolean> => { - const user = type === "admin" ? c.get("admin")?.user : c.get("user"); - if (!user) return false; - const set = await resolveStaffPermissions(c, { type, user }); return hasStaffPermission(set, args); }; +export const checkStaffPermission = async ( + c: Context, + args: PermissionsStaffArgs & { type: PermissionStaffType }, +): Promise<boolean> => { + const user = args.type === "admin" ? c.get("admin")?.user : c.get("user"); + if (!user) return false; + + return await checkStaffPermissionOfUser(c, user, args); +}; + export const assertStaffPermission = async ( c: Context, args: PermissionsStaffArgs & { type: PermissionStaffType }, diff --git a/packages/vitnode/src/api/modules/content/content.module.ts b/packages/vitnode/src/api/modules/content/content.module.ts index 8c8196e1cd..f45f38cdbe 100644 --- a/packages/vitnode/src/api/modules/content/content.module.ts +++ b/packages/vitnode/src/api/modules/content/content.module.ts @@ -1,5 +1,6 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; +import { contentLiveWebSocket } from "@/content/server/live/socket"; import { contentEditorialCleanupCron } from "./cron/content-editorial-cleanup.cron"; import { contentScheduleEffectsQueueTask } from "./tasks/content-schedule-effects.task"; @@ -11,4 +12,5 @@ export const contentModule = buildModule({ routes: [], cronJobs: [contentEditorialCleanupCron], queueTasks: [contentScheduleQueueTask, contentScheduleEffectsQueueTask], + webSockets: [contentLiveWebSocket], }); diff --git a/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts b/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts index 30ac6ac209..2863b65b74 100644 --- a/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts +++ b/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts @@ -3,6 +3,8 @@ import { CONTENT_REVISION_MAX_RETENTION, CONTENT_SCHEDULE_RETENTION_DAYS, } from "@/content/const"; +import { cleanupStaleContentDrafts } from "@/content/server/live-store"; +import { cleanupStaleContentDocuments } from "@/content/server/live/document-store"; import { pruneContentRevisions } from "@/content/server/revisions-model"; import { pruneContentSchedules } from "@/content/server/schedules-model"; @@ -11,7 +13,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; export const contentEditorialCleanupCron = buildCron({ name: "content-editorial-cleanup", description: - "Remove revisions and schedules for content types that are no longer registered, and settled schedules past their retention window.", + "Remove revisions and schedules for content types that are no longer registered, settled schedules past their retention window, expired field locks, and live-editing drafts and documents nobody touched for 30 days.", // 03:20 daily, off the hour so it does not pile onto every other daily job. schedule: "20 3 * * *", handler: async c => { @@ -33,6 +35,17 @@ export const contentEditorialCleanupCron = buildCron({ knownContentTypeIds: known, }); + const live = await cleanupStaleContentDrafts(c); + const documents = await cleanupStaleContentDocuments(c); + + if (live.drafts + live.locks + documents > 0) { + await c + .get("log") + .debug( + `[content-editorial-cleanup] removed ${live.locks} expired field locks, ${live.drafts} stale drafts and ${documents} stale documents.`, + ); + } + if (schedules.orphaned + revisions.orphaned === 0) return; // Worth saying out loud: an unexpected number here usually means a plugin diff --git a/packages/vitnode/src/components/form/fields/editor.tsx b/packages/vitnode/src/components/form/fields/editor.tsx index 857642ea90..ff2253df6a 100644 --- a/packages/vitnode/src/components/form/fields/editor.tsx +++ b/packages/vitnode/src/components/form/fields/editor.tsx @@ -1,7 +1,17 @@ -import type React from "react"; +import React from "react"; +import type { TipTapEditorBaseProps } from "@/components/tiptap/tiptap-editor"; +import type { FormFieldApi } from "@/components/ui/form"; +import type { RichTextDocument } from "@/content/rich-text/document"; +import type { MultiLangValue } from "@/lib/helpers/multi-lang"; + +import { + CollaborativeEditorSlot, + EditorCollaborationContext, +} from "@/components/tiptap/collaboration"; import { Editor } from "@/components/ui/editor"; import { FormControl, FormMessage, useFormField } from "@/components/ui/form"; +import { isRichTextEmpty } from "@/content/rich-text/document"; import { stripHtml } from "@/lib/strip-html"; import type { ItemAutoFormComponentProps } from "../auto-form"; @@ -10,13 +20,18 @@ import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; import { type MultiLangFieldProps, - MultiLangSelect, + MultiLangLabel, useMultiLangField, + useMultiLangValueField, } from "./multi-lang"; import { MultiLangSelectedContext } from "./multi-lang-language"; const hasHtmlText = (html: string): boolean => stripHtml(html) !== ""; +const hasDocumentContent = ( + value: null | RichTextDocument | undefined, +): boolean => !isRichTextEmpty(value); + const useEditorLabelledBy = (label: React.ReactNode): string | undefined => { const { formItemId } = useFormField(); @@ -24,24 +39,70 @@ const useEditorLabelledBy = (label: React.ReactNode): string | undefined => { }; type AutoFormEditorProps = ItemAutoFormComponentProps & - Omit<React.ComponentProps<typeof Editor>, "onChange" | "value"> & { + TipTapEditorBaseProps & { + format?: "html" | "json"; multiLang?: boolean; }; -const MultiLangEditor = ({ +type MultiLangEditorProps<TField> = Omit< + AutoFormEditorProps, + "field" | "format" | "itemParams" | "multiLang" | "otherProps" +> & { + field: TField; + isOptional?: boolean; +}; + +const MultiLangEditorLayout = ({ + canSelect, + children, + description, + isOptional, + label, + labelRight, + languages, + onSelect, + selected, +}: { + canSelect: boolean; + children: React.ReactNode; + description?: React.ReactNode; + isOptional?: boolean; + label?: React.ReactNode; + labelRight?: React.ReactNode; + languages: React.ComponentProps<typeof MultiLangLabel>["languages"]; + onSelect: (code: string) => void; + selected: string; +}) => ( + <> + <MultiLangLabel + canSelect={canSelect} + isOptional={isOptional} + label={label} + labelRight={labelRight} + languages={languages} + onSelect={onSelect} + selected={selected} + /> + + <FormControl> + <MultiLangSelectedContext value={selected}> + {children} + </MultiLangSelectedContext> + </FormControl> + + {!!description && <AutoFormDesc>{description}</AutoFormDesc>} + <FormMessage /> + </> +); + +const MultiLangHtmlEditor = ({ label, labelRight, description, isOptional, field, ...props -}: MultiLangFieldProps & - Omit< - AutoFormEditorProps, - "field" | "itemParams" | "multiLang" | "otherProps" - > & { - isOptional?: boolean; - }) => { +}: MultiLangEditorProps<MultiLangFieldProps["field"]>) => { const { canSelect, languages, @@ -53,38 +114,85 @@ const MultiLangEditor = ({ const labelledBy = useEditorLabelledBy(label); return ( - <> - <div className="flex items-center justify-between gap-2"> - {!!label && ( - <AutoFormLabel isOptional={isOptional} labelRight={labelRight}> - {label} - </AutoFormLabel> - )} - {canSelect && ( - <MultiLangSelect - languages={languages} - onSelect={setSelected} - selected={selected} - /> - )} - </div> + <MultiLangEditorLayout + canSelect={canSelect} + description={description} + isOptional={isOptional} + label={label} + labelRight={labelRight} + languages={languages} + onSelect={setSelected} + selected={selected} + > + <Editor + aria-labelledby={labelledBy} + key={selected} + onBlur={field.onBlur} + onChange={setValue} + value={currentValue} + {...props} + /> + </MultiLangEditorLayout> + ); +}; - <FormControl> - <MultiLangSelectedContext value={selected}> - <Editor - aria-labelledby={labelledBy} - key={selected} - onBlur={field.onBlur} - onChange={setValue} - value={currentValue} - {...props} - /> - </MultiLangSelectedContext> - </FormControl> +const MultiLangJsonEditor = ({ + label, + labelRight, + description, + isOptional, + field, + ...props +}: MultiLangEditorProps< + FormFieldApi<MultiLangValue<null | RichTextDocument> | undefined> +>) => { + const { + canSelect, + languages, + selected, + setSelected, + currentValue, + setValue, + } = useMultiLangValueField<null | RichTextDocument>(field, { + isFilled: hasDocumentContent, + }); + const labelledBy = useEditorLabelledBy(label); + const collaborative = React.use(EditorCollaborationContext); - {!!description && <AutoFormDesc>{description}</AutoFormDesc>} - <FormMessage /> - </> + return ( + <MultiLangEditorLayout + canSelect={canSelect} + description={description} + isOptional={isOptional} + label={label} + labelRight={labelRight} + languages={languages} + onSelect={setSelected} + selected={selected} + > + {collaborative ? ( + <CollaborativeEditorSlot + aria-labelledby={labelledBy} + key={selected} + locale={selected} + onBlur={field.onBlur} + onChange={setValue} + render={collaborative} + value={currentValue ?? null} + {...props} + /> + ) : ( + <Editor + aria-labelledby={labelledBy} + format="json" + key={selected} + onBlur={field.onBlur} + onChange={setValue} + value={currentValue ?? null} + {...props} + /> + )} + </MultiLangEditorLayout> ); }; @@ -94,16 +202,31 @@ export const AutoFormEditor = ({ description, otherProps: { isOptional }, field, + format = "html", // oxlint-disable-next-line no-unused-vars itemParams, multiLang, ...props }: AutoFormEditorProps) => { const labelledBy = useEditorLabelledBy(label); + const collaborative = React.use(EditorCollaborationContext); + + if (multiLang && format === "json") { + return ( + <MultiLangJsonEditor + description={description} + field={field} + isOptional={isOptional} + label={label} + labelRight={labelRight} + {...props} + /> + ); + } if (multiLang) { return ( - <MultiLangEditor + <MultiLangHtmlEditor description={description} field={field} isOptional={isOptional} @@ -123,13 +246,34 @@ export const AutoFormEditor = ({ )} <FormControl> - <Editor - aria-labelledby={labelledBy} - onBlur={field.onBlur} - onChange={field.onChange} - value={field.value ?? ""} - {...props} - /> + {format === "json" && collaborative ? ( + <CollaborativeEditorSlot + aria-labelledby={labelledBy} + locale={null} + onBlur={field.onBlur} + onChange={field.onChange} + render={collaborative} + value={field.value ?? null} + {...props} + /> + ) : format === "json" ? ( + <Editor + aria-labelledby={labelledBy} + format="json" + onBlur={field.onBlur} + onChange={field.onChange} + value={field.value ?? null} + {...props} + /> + ) : ( + <Editor + aria-labelledby={labelledBy} + onBlur={field.onBlur} + onChange={field.onChange} + value={field.value ?? ""} + {...props} + /> + )} </FormControl> {!!description && <AutoFormDesc>{description}</AutoFormDesc>} diff --git a/packages/vitnode/src/components/form/fields/file-display.ts b/packages/vitnode/src/components/form/fields/file-display.ts new file mode 100644 index 0000000000..67aef31017 --- /dev/null +++ b/packages/vitnode/src/components/form/fields/file-display.ts @@ -0,0 +1,6 @@ +import React from "react"; + +export type AutoFormFileDisplay = "card" | "cover"; + +export const AutoFormFileDisplayContext = + React.createContext<AutoFormFileDisplay>("card"); diff --git a/packages/vitnode/src/components/form/fields/file.test.tsx b/packages/vitnode/src/components/form/fields/file.test.tsx new file mode 100644 index 0000000000..1c5cdf4241 --- /dev/null +++ b/packages/vitnode/src/components/form/fields/file.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import z from "zod"; + +import type { ItemAutoFormComponentProps } from "../auto-form"; +import type { AutoFormFileDisplay } from "./file-display"; +import type { AutoFormFileValue } from "./file-shared"; + +import { AutoForm } from "../auto-form"; +import { AutoFormFile, AutoFormFileDisplayContext } from "./file"; + +const image: AutoFormFileValue = { + id: 7, + mimeType: "image/webp", + name: "cover.webp", + size: 4096, + url: "https://cdn.test/cover.webp", +}; + +const pdf: AutoFormFileValue = { + id: 7, + mimeType: "application/pdf", + name: "brief.pdf", + size: 4096, + url: "https://cdn.test/brief.pdf", +}; + +const renderFile = ({ + display, + file, +}: { + display?: AutoFormFileDisplay; + file: AutoFormFileValue; +}) => { + const CoverField = (props: ItemAutoFormComponentProps) => ( + <AutoFormFile + {...props} + file={file} + label="Cover image" + maxBytes={1_000_000} + onUpload={async () => await Promise.resolve(file)} + /> + ); + const form = ( + <AutoForm + fields={[{ id: "cover", component: CoverField }]} + formSchema={z.object({ cover: z.number().nullable().default(7) })} + /> + ); + + return render( + <QueryClientProvider client={new QueryClient()}> + {display ? ( + <AutoFormFileDisplayContext value={display}> + {form} + </AutoFormFileDisplayContext> + ) : ( + form + )} + </QueryClientProvider>, + ); +}; + +describe("AutoFormFile cover display", () => { + it("shows a stored image as a cover with replace and remove actions", () => { + const view = renderFile({ display: "cover", file: image }); + + const cover = view.container.querySelector("[data-slot=file-cover]"); + expect(cover?.querySelector("img")?.getAttribute("src")).toBe(image.url); + expect( + screen.getByRole("button", { name: "core.global.file.replace" }), + ).toBeTruthy(); + expect(view.container.querySelector("[data-slot=attachment]")).toBeNull(); + }); + + it("goes back to the dropzone after the cover is removed", () => { + const view = renderFile({ display: "cover", file: image }); + + fireEvent.click( + screen.getByRole("button", { name: "core.global.file.remove" }), + ); + + expect(view.container.querySelector("[data-slot=file-cover]")).toBeNull(); + expect( + view.container.querySelector("[data-slot=file-dropzone]"), + ).toBeTruthy(); + }); + + it("keeps the file card for a file that is not an image", () => { + const view = renderFile({ display: "cover", file: pdf }); + + expect(view.container.querySelector("[data-slot=file-cover]")).toBeNull(); + expect(view.container.querySelector("[data-slot=attachment]")).toBeTruthy(); + }); + + it("keeps the file card without a cover display", () => { + const view = renderFile({ file: image }); + + expect(view.container.querySelector("[data-slot=file-cover]")).toBeNull(); + expect(view.container.querySelector("[data-slot=attachment]")).toBeTruthy(); + }); +}); diff --git a/packages/vitnode/src/components/form/fields/file.tsx b/packages/vitnode/src/components/form/fields/file.tsx index 5f6b98b17a..02795794c5 100644 --- a/packages/vitnode/src/components/form/fields/file.tsx +++ b/packages/vitnode/src/components/form/fields/file.tsx @@ -1,4 +1,5 @@ import { useMutation } from "@tanstack/react-query"; +import { cn } from "cn"; import { RotateCcwIcon, XIcon } from "lucide-react"; import React from "react"; import { useTranslations } from "use-intl"; @@ -19,15 +20,19 @@ import type { FileUploadOptions } from "./file-upload-queue"; import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; +import { AutoFormFileDisplayContext } from "./file-display"; import { FileCard, FileConstraintsLine, FileDropzone, FileError, + isImageFile, resolveFormFiles, useUploadFailureMessage, } from "./file-shared"; +export type { AutoFormFileDisplay } from "./file-display"; +export { AutoFormFileDisplayContext } from "./file-display"; export type { AutoFormFileValue } from "./file-shared"; export type { FileUploadOptions } from "./file-upload-queue"; @@ -183,6 +188,7 @@ export const AutoFormFile = ({ itemParams, }: AutoFormFileProps) => { const t = useTranslations("core.global.file"); + const display = React.use(AutoFormFileDisplayContext); const [uploaded, setUploaded] = React.useState<AutoFormFileValue[]>([]); const [resolved] = resolveFormFiles(field.value, [initialFile, ...uploaded]); const file = resolved?.file ?? null; @@ -218,23 +224,47 @@ export const AutoFormFile = ({ isPending: upload.isPending, }); + const cover = + display === "cover" && file && isImageFile(file) && !upload.isPending + ? file + : null; + return ( <> {!!label && ( - <AutoFormLabel isOptional={isOptional} labelRight={labelRight}> + <AutoFormLabel + className={cn(cover && "sr-only")} + isOptional={isOptional} + labelRight={labelRight} + > {label} </AutoFormLabel> )} - <FileConstraintsLine - allowedExtensions={allowedExtensions} - allowedMimeTypes={allowedMimeTypes} - maxBytes={maxBytes} - /> + {cover ? null : ( + <FileConstraintsLine + allowedExtensions={allowedExtensions} + allowedMimeTypes={allowedMimeTypes} + maxBytes={maxBytes} + /> + )} <FormControl> <div className="flex flex-col gap-2"> - {resolved && !upload.isPending ? ( + {cover ? ( + <FileCover file={cover}> + <ReplaceAction accept={accept} onPick={pick} withLabel /> + <AttachmentAction + aria-label={t("remove")} + className={coverActionClassName} + onClick={remove} + type="button" + variant="outline" + > + <XIcon /> + </AttachmentAction> + </FileCover> + ) : resolved && !upload.isPending ? ( <FileCard file={ file ?? { id: resolved.id, name: t("stored"), size: 0, url: "" } @@ -290,12 +320,39 @@ export const AutoFormFile = ({ ); }; +const coverActionClassName = "bg-background/90 backdrop-blur"; + +const FileCover = ({ + children, + file, +}: { + children: React.ReactNode; + file: AutoFormFileValue; +}) => ( + <div + className="bg-muted relative overflow-hidden rounded-xl ring-1 ring-black/8 dark:ring-white/10" + data-slot="file-cover" + > + <img + alt="" + className="aspect-2/1 w-full object-cover" + decoding="async" + height={file.height} + src={file.url} + width={file.width} + /> + <div className="absolute end-3 top-3 flex gap-1.5">{children}</div> + </div> +); + const ReplaceAction = ({ accept, onPick, + withLabel = false, }: { accept?: string; onPick: (file: File | undefined) => void; + withLabel?: boolean; }) => { const t = useTranslations("core.global.file"); const inputRef = React.useRef<HTMLInputElement>(null); @@ -313,13 +370,26 @@ const ReplaceAction = ({ tabIndex={-1} type="file" /> - <AttachmentAction - aria-label={t("replace")} - onClick={() => inputRef.current?.click()} - type="button" - > - <RotateCcwIcon /> - </AttachmentAction> + {withLabel ? ( + <Button + className={coverActionClassName} + onClick={() => inputRef.current?.click()} + size="xs" + type="button" + variant="outline" + > + <RotateCcwIcon aria-hidden /> + {t("replace")} + </Button> + ) : ( + <AttachmentAction + aria-label={t("replace")} + onClick={() => inputRef.current?.click()} + type="button" + > + <RotateCcwIcon /> + </AttachmentAction> + )} </> ); }; diff --git a/packages/vitnode/src/components/form/fields/input.tsx b/packages/vitnode/src/components/form/fields/input.tsx index 05e0fb313c..23a1ca5afb 100644 --- a/packages/vitnode/src/components/form/fields/input.tsx +++ b/packages/vitnode/src/components/form/fields/input.tsx @@ -1,8 +1,4 @@ -import { - InputGroup, - InputGroupAddon, - InputGroupInput, -} from "@/components/ui/input-group"; +import { InputGroup, InputGroupInput } from "@/components/ui/input-group"; import { getMultiLangConstraints } from "@/lib/helpers/multi-lang"; import type { ItemAutoFormComponentProps } from "../auto-form"; @@ -13,10 +9,9 @@ import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; import { type MultiLangFieldProps, - MultiLangSelect, + MultiLangLabel, useMultiLangField, } from "./multi-lang"; -import { MultiLangSelectedContext } from "./multi-lang-language"; type AutoFormInputProps = ItemAutoFormComponentProps & Omit<React.ComponentProps<typeof Input>, "value"> & { @@ -52,51 +47,35 @@ const MultiLangInput = ({ return ( <> - {!!label && ( - <AutoFormLabel - isOptional={isOptional} - labelRight={ - labelRight ? ( - <MultiLangSelectedContext value={selected}> - {labelRight} - </MultiLangSelectedContext> - ) : undefined - } - > - {label} - </AutoFormLabel> - )} + <MultiLangLabel + canSelect={canSelect} + isOptional={isOptional} + label={label} + labelRight={labelRight} + languages={languages} + onSelect={setSelected} + selected={selected} + /> - <InputGroup> - <FormControl> - <InputGroupInput - {...field} - {...props} - maxLength={maxLength} - minLength={minLength} - onBlur={e => { - field.onBlur(); - props.onBlur?.(e); - }} - onChange={e => { - setValue(e.target.value); - props.onChange?.(e); - }} - pattern={pattern} - type={type ?? "text"} - value={currentValue} - /> - </FormControl> - {canSelect && ( - <InputGroupAddon align="inline-end"> - <MultiLangSelect - languages={languages} - onSelect={setSelected} - selected={selected} - /> - </InputGroupAddon> - )} - </InputGroup> + <FormControl> + <Input + {...field} + {...props} + maxLength={maxLength} + minLength={minLength} + onBlur={e => { + field.onBlur(); + props.onBlur?.(e); + }} + onChange={e => { + setValue(e.target.value); + props.onChange?.(e); + }} + pattern={pattern} + type={type ?? "text"} + value={currentValue} + /> + </FormControl> {!!description && <AutoFormDesc>{description}</AutoFormDesc>} <FormMessage /> diff --git a/packages/vitnode/src/components/form/fields/multi-lang-presence.ts b/packages/vitnode/src/components/form/fields/multi-lang-presence.ts new file mode 100644 index 0000000000..8efd6ed268 --- /dev/null +++ b/packages/vitnode/src/components/form/fields/multi-lang-presence.ts @@ -0,0 +1,13 @@ +import React from "react"; + +export interface MultiLangPresence { + busy: (code: string) => boolean; + marker: (code: string) => React.ReactNode; +} + +export const MultiLangPresenceContext = + React.createContext<MultiLangPresence | null>(null); + +export const MultiLangShownLanguageContext = React.createContext< + ((code: string) => void) | null +>(null); diff --git a/packages/vitnode/src/components/form/fields/multi-lang.tsx b/packages/vitnode/src/components/form/fields/multi-lang.tsx index cd62d33cf9..6bb1c7a1c4 100644 --- a/packages/vitnode/src/components/form/fields/multi-lang.tsx +++ b/packages/vitnode/src/components/form/fields/multi-lang.tsx @@ -14,13 +14,23 @@ import { SelectValue, } from "@/components/ui/select"; import { + findLangValue, getLangValue, - pickLangCode, + hasText, + pickLangCodeWhere, upsertLangValue, } from "@/lib/helpers/multi-lang"; +import { AutoFormLabel } from "../common/label"; import { useMultiLangDefaultLanguage } from "./multi-lang-default-language"; -import { useMultiLangLanguage } from "./multi-lang-language"; +import { + MultiLangSelectedContext, + useMultiLangLanguage, +} from "./multi-lang-language"; +import { + MultiLangPresenceContext, + MultiLangShownLanguageContext, +} from "./multi-lang-presence"; export { multiLangValueSchema } from "@/lib/helpers/multi-lang"; export type { @@ -32,33 +42,51 @@ export interface MultiLangFieldProps { field: FormFieldApi<MultiLangValue | undefined>; } -export const useMultiLangField = ( - field: MultiLangFieldProps["field"], - { isFilled }: { isFilled?: (text: string) => boolean } = {}, +const useMultiLangSelection = ( + isFilledIn: (languageCode: string) => boolean, ) => { const languages = useLanguages(); const locale = useLocale(); const defaultLanguage = useMultiLangDefaultLanguage(); const lockedLanguage = useMultiLangLanguage(); - const { value } = field; const [selected, setSelected] = React.useState(() => - pickLangCode({ + pickLangCodeWhere({ defaultLanguage, - isFilled, + isFilledIn, languageCodes: languages.map(language => language.code), locale, - value, }), ); const language = lockedLanguage ?? selected; + const reportShown = React.use(MultiLangShownLanguageContext); + React.useEffect(() => { + reportShown?.(language); + }, [language, reportShown]); + + return { + canSelect: lockedLanguage === null && languages.length > 1, + language, + languages, + setSelected, + }; +}; + +export const useMultiLangField = ( + field: MultiLangFieldProps["field"], + { isFilled = hasText }: { isFilled?: (text: string) => boolean } = {}, +) => { + const { value } = field; + const { canSelect, language, languages, setSelected } = useMultiLangSelection( + code => isFilled(getLangValue(value, code)), + ); const setValue = (newValue: string) => { field.onChange(upsertLangValue(value, language, newValue)); }; return { - canSelect: lockedLanguage === null && languages.length > 1, + canSelect, languages, selected: language, setSelected, @@ -67,6 +95,29 @@ export const useMultiLangField = ( }; }; +export const useMultiLangValueField = <TValue,>( + field: FormFieldApi<MultiLangValue<TValue> | undefined>, + { isFilled }: { isFilled: (value: TValue | undefined) => boolean }, +) => { + const { value } = field; + const { canSelect, language, languages, setSelected } = useMultiLangSelection( + code => isFilled(findLangValue(value, code)), + ); + + const setValue = (newValue: TValue) => { + field.onChange(upsertLangValue(value, language, newValue)); + }; + + return { + canSelect, + languages, + selected: language, + setSelected, + currentValue: findLangValue(value, language), + setValue, + }; +}; + export const MultiLangSelect = ({ languages, onSelect, @@ -77,6 +128,12 @@ export const MultiLangSelect = ({ selected: string; }) => { const t = useTranslations("core.global"); + const presence = React.use(MultiLangPresenceContext); + const elsewhere = + presence !== null && + languages.some( + language => language.code !== selected && presence.busy(language.code), + ); return ( <Select @@ -93,14 +150,64 @@ export const MultiLangSelect = ({ size="sm" > <SelectValue /> + {elsewhere ? ( + <span aria-hidden className="bg-primary size-1.5 rounded-full" /> + ) : null} </SelectTrigger> <SelectContent align="end"> {languages.map(language => ( <SelectItem key={language.code} value={language.code}> {language.name} + {presence?.marker(language.code)} </SelectItem> ))} </SelectContent> </Select> ); }; + +export const MultiLangLabel = ({ + canSelect, + isOptional, + label, + labelRight, + languages, + onSelect, + selected, +}: React.ComponentProps<typeof MultiLangSelect> & { + canSelect: boolean; + isOptional?: boolean; + label?: React.ReactNode; + labelRight?: React.ReactNode; +}) => { + if (!label && !canSelect) return null; + + return ( + <div className="flex items-center gap-2"> + {!!label && ( + <AutoFormLabel + className="flex-1" + isOptional={isOptional} + labelRight={ + labelRight ? ( + <MultiLangSelectedContext value={selected}> + {labelRight} + </MultiLangSelectedContext> + ) : undefined + } + > + {label} + </AutoFormLabel> + )} + {canSelect && ( + <div className="-my-1.5 ms-auto shrink-0"> + <MultiLangSelect + languages={languages} + onSelect={onSelect} + selected={selected} + /> + </div> + )} + </div> + ); +}; diff --git a/packages/vitnode/src/components/form/fields/textarea.tsx b/packages/vitnode/src/components/form/fields/textarea.tsx index f48fb62ed3..377a62227c 100644 --- a/packages/vitnode/src/components/form/fields/textarea.tsx +++ b/packages/vitnode/src/components/form/fields/textarea.tsx @@ -11,10 +11,9 @@ import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; import { type MultiLangFieldProps, - MultiLangSelect, + MultiLangLabel, useMultiLangField, } from "./multi-lang"; -import { MultiLangSelectedContext } from "./multi-lang-language"; type AutoFormTextareaProps = ItemAutoFormComponentProps & Omit<React.ComponentProps<typeof Textarea>, "value"> & { @@ -50,29 +49,15 @@ const MultiLangTextarea = ({ return ( <> - <div className="flex items-center justify-between gap-2"> - {!!label && ( - <AutoFormLabel - isOptional={isOptional} - labelRight={ - labelRight ? ( - <MultiLangSelectedContext value={selected}> - {labelRight} - </MultiLangSelectedContext> - ) : undefined - } - > - {label} - </AutoFormLabel> - )} - {canSelect && ( - <MultiLangSelect - languages={languages} - onSelect={setSelected} - selected={selected} - /> - )} - </div> + <MultiLangLabel + canSelect={canSelect} + isOptional={isOptional} + label={label} + labelRight={labelRight} + languages={languages} + onSelect={setSelected} + selected={selected} + /> <FormControl> <Textarea diff --git a/packages/vitnode/src/components/rich-text/boundaries.test.ts b/packages/vitnode/src/components/rich-text/boundaries.test.ts new file mode 100644 index 0000000000..5a9bafe1c6 --- /dev/null +++ b/packages/vitnode/src/components/rich-text/boundaries.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment node +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { externalGraph, reachedFiles } from "@/tests/import-graph"; + +const here = dirname(fileURLToPath(import.meta.url)); + +const EDITOR_ONLY = + /^(?:@tiptap\/|prosemirror|y-|yjs$|@vitnode\/core\/components\/tiptap)/; + +const entries = { + "components/rich-text": join(here, "index.ts"), + "content/rich-text": join( + here, + "..", + "..", + "content", + "rich-text", + "index.ts", + ), +}; + +describe("the rich text renderer never ships the editor", () => { + it.each(Object.entries(entries))( + "%s reaches no Tiptap, ProseMirror or Yjs module", + (_name, entry) => { + const editorOnly = [...externalGraph(entry).keys()].filter(specifier => + EDITOR_ONLY.test(specifier), + ); + + expect(editorOnly).toStrictEqual([]); + }, + ); + + it.each(Object.entries(entries))( + "%s reaches no file of the editor itself", + (_name, entry) => { + expect( + reachedFiles(entry).filter(file => + file.startsWith("components/tiptap"), + ), + ).toStrictEqual([]); + }, + ); + + it("walks the renderer it guards", () => { + expect(reachedFiles(entries["components/rich-text"])).toEqual( + expect.arrayContaining([ + "components/rich-text/rich-text-content.tsx", + "content/rich-text/elements.ts", + "content/rich-text/html.ts", + ]), + ); + }); +}); diff --git a/packages/vitnode/src/components/rich-text/index.ts b/packages/vitnode/src/components/rich-text/index.ts new file mode 100644 index 0000000000..88084bba8e --- /dev/null +++ b/packages/vitnode/src/components/rich-text/index.ts @@ -0,0 +1,20 @@ +export { RichTextContent } from "./rich-text-content"; +export type { + RichTextContentProps, + RichTextMarkRenderer, + RichTextMarkRendererProps, + RichTextNodeRenderer, + RichTextNodeRendererProps, +} from "./rich-text-content"; + +export { + isRichTextEmpty, + richTextToHtml, + richTextToPlainText, + sanitizeRichTextHref, +} from "@/content/rich-text"; +export type { + RichTextDocument, + RichTextMark, + RichTextNode, +} from "@/content/rich-text"; diff --git a/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx b/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx new file mode 100644 index 0000000000..7f78eda1a2 --- /dev/null +++ b/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx @@ -0,0 +1,380 @@ +// @vitest-environment jsdom +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { + RichTextDocument, + RichTextMark, + RichTextNode, +} from "@/content/rich-text/document"; + +import { RichTextContent } from "./rich-text-content"; + +const text = (value: string, marks?: RichTextMark[]): RichTextNode => + marks ? { marks, text: value, type: "text" } : { text: value, type: "text" }; + +const p = (...content: RichTextNode[]): RichTextNode => ({ + content, + type: "paragraph", +}); + +const doc = (...content: RichTextNode[]): RichTextDocument => ({ + content, + type: "doc", +}); + +const renderDoc = (content: RichTextDocument) => + render(<RichTextContent content={content} />).container; + +const textOf = (element: Element | null | undefined) => + element?.textContent ?? null; + +describe("RichTextContent", () => { + it("renders nothing without a document", () => { + const { container } = render(<RichTextContent content={null} />); + + expect(container.childElementCount).toBe(0); + }); + + it("renders headings at their level, with a safe alignment", () => { + renderDoc( + doc( + { attrs: { level: 3 }, content: [text("Setup")], type: "heading" }, + { + attrs: { level: 9, textAlign: "center" }, + content: [text("Fallback")], + type: "heading", + }, + { + attrs: { textAlign: "expression(alert(1))" }, + content: [text("Plain")], + type: "paragraph", + }, + ), + ); + + expect(textOf(screen.getByRole("heading", { level: 3 }))).toBe("Setup"); + expect(screen.getByRole("heading", { level: 2 }).style.textAlign).toBe( + "center", + ); + expect(screen.getByText("Plain").getAttribute("style")).toBeNull(); + }); + + it("renders every inline mark as its element", () => { + const container = renderDoc( + doc( + p( + text("bold", [{ type: "bold" }]), + text("italic", [{ type: "italic" }]), + text("strike", [{ type: "strike" }]), + text("code", [{ type: "code" }]), + text("underline", [{ type: "underline" }]), + text("both", [{ type: "bold" }, { type: "italic" }]), + ), + ), + ); + + expect(textOf(container.querySelector("p > strong"))).toBe("bold"); + expect(textOf(container.querySelector("p > em"))).toBe("italic"); + expect(textOf(container.querySelector("s"))).toBe("strike"); + expect(textOf(container.querySelector("code"))).toBe("code"); + expect(textOf(container.querySelector("u"))).toBe("underline"); + expect(textOf(container.querySelector("strong > em"))).toBe("both"); + }); + + it("renders text color and size, and drops unsafe values", () => { + const container = renderDoc( + doc( + p( + text("red", [ + { + attrs: { color: "#ff0000", fontSize: "18px" }, + type: "textStyle", + }, + ]), + text("evil", [ + { + attrs: { color: "red;background:url(x)", fontSize: "1e9px" }, + type: "textStyle", + }, + ]), + ), + ), + ); + + const styled = screen.getByText("red"); + expect(styled.style.color).toBe("rgb(255, 0, 0)"); + expect(styled.style.fontSize).toBe("18px"); + expect(screen.getByText("evil", { exact: false }).tagName).toBe("P"); + expect(container.querySelectorAll("span")).toHaveLength(1); + }); + + it("renders links, opening a new tab safely", () => { + renderDoc( + doc( + p( + text("docs", [{ attrs: { href: "/docs" }, type: "link" }]), + text("site", [ + { + attrs: { href: "https://vitnode.com", target: "_blank" }, + type: "link", + }, + ]), + ), + ), + ); + + const internal = screen.getByRole("link", { name: "docs" }); + expect(internal.getAttribute("href")).toBe("/docs"); + expect(internal.hasAttribute("target")).toBe(false); + + const external = screen.getByRole("link", { name: "site" }); + expect(external.getAttribute("target")).toBe("_blank"); + expect(external.getAttribute("rel")).toBe("noopener noreferrer nofollow"); + }); + + it.each([ + "javascript:alert(1)", + " JAVASCRIPT:alert(1)", + "java\nscript:alert(1)", + "data:text/html;base64,PHNjcmlwdD4=", + "vbscript:msgbox(1)", + ])("renders a link to %j as plain text", href => { + renderDoc(doc(p(text("click me", [{ attrs: { href }, type: "link" }])))); + + expect(screen.queryByRole("link")).toBeNull(); + expect(screen.getByText("click me").tagName).toBe("P"); + }); + + it("renders lists, keeping an ordered list's start", () => { + renderDoc( + doc( + { + content: [{ content: [p(text("Bullet"))], type: "listItem" }], + type: "bulletList", + }, + { + attrs: { start: 3 }, + content: [ + { content: [p(text("Third"))], type: "listItem" }, + { content: [p(text("Fourth"))], type: "listItem" }, + ], + type: "orderedList", + }, + ), + ); + + const [bullets, numbered] = screen.getAllByRole("list"); + expect(bullets.tagName).toBe("UL"); + expect(textOf(within(bullets).getByRole("listitem"))).toBe("Bullet"); + expect(numbered.tagName).toBe("OL"); + expect(numbered.getAttribute("start")).toBe("3"); + expect(within(numbered).getAllByRole("listitem")).toHaveLength(2); + }); + + it("renders blocks: quote, code, rule and a hard break", () => { + const container = renderDoc( + doc( + { content: [p(text("Quoted"))], type: "blockquote" }, + { + attrs: { language: "ts" }, + content: [text("const a = 1;")], + type: "codeBlock", + }, + { + attrs: { language: '"><script>' }, + content: [text("plain")], + type: "codeBlock", + }, + { type: "horizontalRule" }, + p(text("line"), { type: "hardBreak" }, text("next")), + ), + ); + + expect(textOf(container.querySelector("blockquote"))).toBe("Quoted"); + const [typed, untyped] = container.querySelectorAll("pre > code"); + expect(textOf(typed)).toBe("const a = 1;"); + expect(typed.getAttribute("class")).toBe("language-ts"); + expect(untyped.hasAttribute("class")).toBe(false); + expect(screen.getByRole("separator").tagName).toBe("HR"); + expect(container.querySelector("p br")).not.toBeNull(); + }); + + it("renders a table with header cells and spans", () => { + renderDoc( + doc({ + content: [ + { + content: [ + { + attrs: { colspan: 2, colwidth: [100, 200], rowspan: 1 }, + content: [p(text("Name"))], + type: "tableHeader", + }, + ], + type: "tableRow", + }, + { + content: [ + { content: [p(text("Ada"))], type: "tableCell" }, + { + attrs: { colspan: 1, rowspan: 2 }, + content: [p(text("Admin"))], + type: "tableCell", + }, + ], + type: "tableRow", + }, + ], + type: "table", + }), + ); + + const table = screen.getByRole("table"); + expect( + screen + .getByRole("columnheader", { name: "Name" }) + .getAttribute("colspan"), + ).toBe("2"); + expect(within(table).getAllByRole("row")).toHaveLength(2); + expect( + screen.getByRole("cell", { name: "Admin" }).getAttribute("rowspan"), + ).toBe("2"); + expect( + [...table.querySelectorAll("col")].map(col => col.style.width), + ).toEqual(["100px", "200px"]); + }); + + it("renders a task as a disabled checkbox named after its text", () => { + renderDoc( + doc({ + content: [ + { + attrs: { checked: true }, + content: [p(text("Write the docs"))], + type: "taskItem", + }, + { + attrs: { checked: false }, + content: [p(text("Ship"))], + type: "taskItem", + }, + ], + type: "taskList", + }), + ); + + const done = screen.getByRole<HTMLInputElement>("checkbox", { + name: "Write the docs", + }); + expect(done.checked).toBe(true); + expect(done.disabled).toBe(true); + expect( + screen.getByRole<HTMLInputElement>("checkbox", { name: "Ship" }).checked, + ).toBe(false); + }); + + it("renders a panel of a known kind only", () => { + const container = renderDoc( + doc( + { + attrs: { kind: "warning" }, + content: [p(text("Careful"))], + type: "panel", + }, + { + attrs: { kind: "javascript" }, + content: [p(text("Odd"))], + type: "panel", + }, + ), + ); + + const [warning, fallback] = container.querySelectorAll("[data-panel]"); + expect(warning.getAttribute("data-panel")).toBe("warning"); + expect(textOf(warning)).toBe("Careful"); + expect(fallback.getAttribute("data-panel")).toBe("info"); + }); + + it("renders audio from a safe source only", () => { + const container = renderDoc( + doc( + { attrs: { src: "https://cdn.test/a.mp3" }, type: "audio" }, + { attrs: { src: "javascript:alert(1)" }, type: "audio" }, + { attrs: { src: "data:audio/mp3;base64,AAAA" }, type: "audio" }, + ), + ); + + const audio = container.querySelectorAll("audio"); + expect(audio).toHaveLength(1); + expect(audio[0].getAttribute("src")).toBe("https://cdn.test/a.mp3"); + expect(audio[0].hasAttribute("controls")).toBe(true); + }); + + it("renders an emoji as its glyph, its image, or its shortcode", () => { + renderDoc( + doc( + p( + { attrs: { emoji: "😄", name: "smile" }, type: "emoji" }, + { + attrs: { name: "parrot", src: "/emoji/parrot.gif" }, + type: "emoji", + }, + { attrs: { name: "unknown" }, type: "emoji" }, + ), + ), + ); + + expect(screen.getByText("😄").getAttribute("data-name")).toBe("smile"); + expect( + screen.getByRole("img", { name: ":parrot:" }).getAttribute("src"), + ).toBe("/emoji/parrot.gif"); + expect(screen.getByText(":unknown:").tagName).toBe("SPAN"); + }); + + it("renders the content of an unknown node and the text of an unknown mark", () => { + const container = renderDoc( + doc({ + content: [p(text("Hidden", [{ type: "sparkle" }]))], + type: "spoiler", + }), + ); + + expect(screen.getByText("Hidden").tagName).toBe("P"); + expect(container.querySelector(".tiptap > p")).not.toBeNull(); + }); + + it("lets a plugin replace or add renderers", () => { + render( + <RichTextContent + content={doc( + { content: [p(text("Secret"))], type: "spoiler" }, + p(text("@ada", [{ attrs: { id: 7 }, type: "mention" }])), + { attrs: { level: 2 }, content: [text("Title")], type: "heading" }, + )} + marks={{ + mention: ({ children, mark }) => ( + <a href={`/profile/${String(mark.attrs?.id)}`}>{children}</a> + ), + }} + nodes={{ + heading: ({ children }) => <h5>{children}</h5>, + spoiler: ({ children }) => ( + <details> + <summary>Spoiler</summary> + {children} + </details> + ), + }} + />, + ); + + expect(textOf(screen.getByText("Spoiler").closest("details"))).toBe( + "SpoilerSecret", + ); + expect( + screen.getByRole("link", { name: "@ada" }).getAttribute("href"), + ).toBe("/profile/7"); + expect(textOf(screen.getByRole("heading", { level: 5 }))).toBe("Title"); + }); +}); diff --git a/packages/vitnode/src/components/rich-text/rich-text-content.tsx b/packages/vitnode/src/components/rich-text/rich-text-content.tsx new file mode 100644 index 0000000000..655043c83a --- /dev/null +++ b/packages/vitnode/src/components/rich-text/rich-text-content.tsx @@ -0,0 +1,154 @@ +import { cn } from "cn"; +import React from "react"; + +import type { + RichTextDocument, + RichTextMark, + RichTextNode, +} from "@/content/rich-text/document"; +import type { RichTextElement } from "@/content/rich-text/elements"; + +import { + RICH_TEXT_SLOT, + RICH_TEXT_VOID_TAGS, + richTextMarkElement, + richTextNodeElement, +} from "@/content/rich-text/elements"; + +export interface RichTextNodeRendererProps { + children?: React.ReactNode; + node: RichTextNode; +} + +export interface RichTextMarkRendererProps { + children?: React.ReactNode; + mark: RichTextMark; + node: RichTextNode; +} + +export type RichTextNodeRenderer = ( + props: RichTextNodeRendererProps, +) => React.ReactNode; + +export type RichTextMarkRenderer = ( + props: RichTextMarkRendererProps, +) => React.ReactNode; + +export interface RichTextContentProps { + className?: string; + content: null | RichTextDocument | undefined; + marks?: Readonly<Record<string, RichTextMarkRenderer>>; + nodes?: Readonly<Record<string, RichTextNodeRenderer>>; +} + +interface RenderContext { + marks?: Readonly<Record<string, RichTextMarkRenderer>>; + nodes?: Readonly<Record<string, RichTextNodeRenderer>>; +} + +const MAX_DEPTH = 256; + +const REACT_PROP_NAMES: Readonly<Record<string, string>> = { + checked: "defaultChecked", + class: "className", + colspan: "colSpan", + rowspan: "rowSpan", +}; + +const renderElement = ( + element: RichTextElement, + slot: React.ReactNode, + key?: number, +): React.ReactElement => { + const props: Record<string, unknown> = { key }; + for (const [name, value] of Object.entries(element.attrs ?? {})) { + props[REACT_PROP_NAMES[name] ?? name] = value; + } + if (element.style) props.style = element.style; + + if (RICH_TEXT_VOID_TAGS.has(element.tag)) { + return React.createElement(element.tag, props); + } + + const children: React.ReactNode[] = []; + for (const child of element.children ?? []) { + if (child === RICH_TEXT_SLOT) children.push(slot); + else if (typeof child === "string") children.push(child); + else children.push(renderElement(child, slot)); + } + + return React.createElement(element.tag, props, ...children); +}; + +const renderText = ( + node: RichTextNode, + key: number, + context: RenderContext, +): React.ReactElement => { + let content: React.ReactNode = typeof node.text === "string" ? node.text : ""; + + for (const mark of [...(node.marks ?? [])].reverse()) { + const Override = context.marks?.[mark.type]; + if (Override) { + content = ( + <Override mark={mark} node={node}> + {content} + </Override> + ); + continue; + } + + const element = richTextMarkElement(mark); + if (element) content = renderElement(element, content); + } + + return <React.Fragment key={key}>{content}</React.Fragment>; +}; + +const renderNode = ( + node: RichTextNode, + key: number, + context: RenderContext, + depth: number, +): null | React.ReactElement => { + if (depth > MAX_DEPTH || typeof node !== "object") return null; + if (node.type === "text") return renderText(node, key, context); + + const children = Array.isArray(node.content) + ? node.content.map((child, index) => + renderNode(child, index, context, depth + 1), + ) + : null; + + const Override = context.nodes?.[node.type]; + if (Override) { + return ( + <Override key={key} node={node}> + {children} + </Override> + ); + } + + const element = richTextNodeElement(node); + + return element ? ( + renderElement(element, children, key) + ) : ( + <React.Fragment key={key}>{children}</React.Fragment> + ); +}; + +export const RichTextContent = ({ + className, + content, + marks, + nodes, +}: RichTextContentProps) => { + if (!content || typeof content !== "object") return null; + + return ( + <div className={cn("tiptap", className)}> + {renderNode(content, 0, { marks, nodes }, 0)} + </div> + ); +}; diff --git a/packages/vitnode/src/components/tiptap/collaboration.test.tsx b/packages/vitnode/src/components/tiptap/collaboration.test.tsx new file mode 100644 index 0000000000..024c35e25c --- /dev/null +++ b/packages/vitnode/src/components/tiptap/collaboration.test.tsx @@ -0,0 +1,25 @@ +// @vitest-environment jsdom +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { CollaborativeEditorSlot } from "./collaboration"; + +describe("CollaborativeEditorSlot", () => { + it("renders the collaborative editor in the same render, without suspending", () => { + const editor = vi.fn(({ locale }: { locale: null | string }) => ( + <p>Shared editor ({locale})</p> + )); + + render( + <CollaborativeEditorSlot + locale="pl" + onChange={() => {}} + render={editor} + value={null} + />, + ); + + expect(screen.getByText("Shared editor (pl)")).toBeTruthy(); + expect(editor).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/vitnode/src/components/tiptap/collaboration.ts b/packages/vitnode/src/components/tiptap/collaboration.ts new file mode 100644 index 0000000000..3eb29c7f2c --- /dev/null +++ b/packages/vitnode/src/components/tiptap/collaboration.ts @@ -0,0 +1,23 @@ +import React from "react"; + +import type { RichTextDocument } from "@/content/rich-text/document"; + +import type { TipTapEditorBaseProps } from "./tiptap-editor"; + +export type CollaborativeEditorProps = TipTapEditorBaseProps & { + locale: null | string; + onChange: (value: RichTextDocument) => void; + value: null | RichTextDocument; +}; + +export const EditorCollaborationContext = React.createContext< + ((props: CollaborativeEditorProps) => React.ReactNode) | null +>(null); + +export const CollaborativeEditorSlot = ({ + render, + ...props +}: CollaborativeEditorProps & { + render: (props: CollaborativeEditorProps) => React.ReactNode; +}): React.JSX.Element => + React.createElement(React.Fragment, null, render(props)); diff --git a/packages/vitnode/src/components/tiptap/drag-handle.tsx b/packages/vitnode/src/components/tiptap/drag-handle.tsx index 55fe25dcda..f1353f749e 100644 --- a/packages/vitnode/src/components/tiptap/drag-handle.tsx +++ b/packages/vitnode/src/components/tiptap/drag-handle.tsx @@ -11,7 +11,7 @@ export const TipTapDragHandle = ({ editor }: { editor: Editor }) => { <DragHandle className="z-10" editor={editor} nested> <span aria-hidden="true" - className="text-muted-foreground hover:bg-accent hover:text-foreground me-1 flex size-6 cursor-grab items-center justify-center rounded-sm active:cursor-grabbing" + className="text-muted-foreground hover:bg-accent hover:text-foreground me-0.5 flex h-6 w-4 cursor-grab items-center justify-center rounded-sm active:cursor-grabbing" title={t("drag_handle")} > <GripVerticalIcon className="size-4" /> diff --git a/packages/vitnode/src/components/tiptap/extension.ts b/packages/vitnode/src/components/tiptap/extension.ts index 0337cd1f13..9d61e7d42c 100644 --- a/packages/vitnode/src/components/tiptap/extension.ts +++ b/packages/vitnode/src/components/tiptap/extension.ts @@ -23,11 +23,14 @@ export const SUPPORTED_HEADINGS_LEVELS = [1, 2, 3, 4] as const; export const createTipTapExtensions = ({ customEmojis, placeholder, + undoRedo = true, }: { customEmojis?: EditorEmojiSection[]; placeholder?: string; + undoRedo?: boolean; } = {}): Extensions => [ StarterKit.configure({ + ...(undoRedo ? {} : { undoRedo: false }), orderedList: { HTMLAttributes: { class: "list-decimal", diff --git a/packages/vitnode/src/components/tiptap/rich-text-json.ts b/packages/vitnode/src/components/tiptap/rich-text-json.ts new file mode 100644 index 0000000000..bae78d9d7a --- /dev/null +++ b/packages/vitnode/src/components/tiptap/rich-text-json.ts @@ -0,0 +1,123 @@ +import type { EmojiItem } from "@tiptap/extension-emoji"; +import type { JSONContent } from "@tiptap/react"; + +import { gitHubEmojis, shortcodeToEmoji } from "@tiptap/extension-emoji"; +import { generateJSON } from "@tiptap/react"; + +import type { EditorEmojiSection } from "@/components/editor-provider"; +import type { + RichTextAttrPrimitive, + RichTextAttrs, + RichTextAttrValue, + RichTextDocument, + RichTextMark, + RichTextNode, +} from "@/content/rich-text/document"; + +import { customEmojiToTipTap } from "./emoji/custom-emoji"; +import { createTipTapExtensions } from "./extension"; + +const isAttrPrimitive = (value: unknown): value is RichTextAttrPrimitive => + value === null || + typeof value === "string" || + typeof value === "boolean" || + (typeof value === "number" && Number.isFinite(value)); + +const toAttrValue = (value: unknown): RichTextAttrValue | undefined => { + if (isAttrPrimitive(value)) return value; + if (Array.isArray(value) && value.every(isAttrPrimitive)) return value; + + return undefined; +}; + +const toAttrs = (attrs: unknown): RichTextAttrs | undefined => { + if (typeof attrs !== "object" || attrs === null) return undefined; + + const entries = Object.entries(attrs).flatMap(([name, value]) => { + const attrValue = toAttrValue(value); + + return attrValue === undefined ? [] : [[name, attrValue] as const]; + }); + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +}; + +const withEmoji = ( + attrs: RichTextAttrs | undefined, + emojis: EmojiItem[], +): RichTextAttrs | undefined => { + const name = attrs?.name; + if (typeof name !== "string") return attrs; + + const item = shortcodeToEmoji(name, emojis); + if (item?.emoji) return { ...attrs, emoji: item.emoji }; + if (item?.fallbackImage) return { ...attrs, src: item.fallbackImage }; + + return attrs; +}; + +const toMark = (mark: JSONContent): null | RichTextMark => { + if (typeof mark.type !== "string") return null; + + const attrs = toAttrs(mark.attrs); + + return attrs ? { attrs, type: mark.type } : { type: mark.type }; +}; + +const toNode = ( + json: JSONContent, + emojis: EmojiItem[], +): null | RichTextNode => { + if (typeof json.type !== "string") return null; + + const node: RichTextNode = { type: json.type }; + const attrs = + json.type === "emoji" + ? withEmoji(toAttrs(json.attrs), emojis) + : toAttrs(json.attrs); + if (attrs) node.attrs = attrs; + + const content = (json.content ?? []).flatMap(child => { + const converted = toNode(child, emojis); + + return converted ? [converted] : []; + }); + if (content.length > 0) node.content = content; + + const marks = (json.marks ?? []).flatMap(mark => { + const converted = toMark(mark); + + return converted ? [converted] : []; + }); + if (marks.length > 0) node.marks = marks; + + if (typeof json.text === "string") node.text = json.text; + + return node; +}; + +export const editorEmojiItems = ( + customEmojis?: EditorEmojiSection[], +): EmojiItem[] => [...gitHubEmojis, ...customEmojiToTipTap(customEmojis)]; + +export const toRichTextDocument = ( + json: JSONContent, + emojis: EmojiItem[] = [], +): RichTextDocument => { + const content = (json.content ?? []).flatMap(child => { + const converted = toNode(child, emojis); + + return converted ? [converted] : []; + }); + + return content.length > 0 ? { content, type: "doc" } : { type: "doc" }; +}; + +export const richTextDocumentFromHtml = ( + html: string, + { customEmojis }: { customEmojis?: EditorEmojiSection[] } = {}, +): RichTextDocument => + toRichTextDocument( + generateJSON(html, createTipTapExtensions({ customEmojis })), + editorEmojiItems(customEmojis), + ); diff --git a/packages/vitnode/src/components/tiptap/tiptap-editor.tsx b/packages/vitnode/src/components/tiptap/tiptap-editor.tsx index 7040391c8e..eeefd23e27 100644 --- a/packages/vitnode/src/components/tiptap/tiptap-editor.tsx +++ b/packages/vitnode/src/components/tiptap/tiptap-editor.tsx @@ -1,24 +1,48 @@ -import { EditorContent, useEditor } from "@tiptap/react"; +import { + type Editor, + EditorContent, + type Extensions, + useEditor, +} from "@tiptap/react"; import { cn } from "cn"; +import React from "react"; import { useTranslations } from "use-intl"; +import type { RichTextDocument } from "@/content/rich-text/document"; + import { useEditorConfig } from "@/components/editor-provider"; import { TipTapDragHandle } from "./drag-handle"; import { EditorSkeleton } from "./editor-skeleton"; import { createTipTapExtensions } from "./extension"; +import { editorEmojiItems, toRichTextDocument } from "./rich-text-json"; import { TipTapToolbar } from "./toolbar/tiptap-toolbar"; -export type TipTapEditorProps = Omit< +export type TipTapEditorBaseProps = Omit< React.ComponentProps<"div">, - "onChange" + "defaultValue" | "onChange" > & { disableScroll?: boolean; - onChange?: (value: string) => void; + extensions?: Extensions; + onEditor?: (editor: Editor | null) => void; placeholder?: string; + undoRedo?: boolean; +}; + +export type TipTapEditorHtmlProps = TipTapEditorBaseProps & { + format?: "html"; + onChange?: (value: string) => void; value?: string; }; +export type TipTapEditorJsonProps = TipTapEditorBaseProps & { + format: "json"; + onChange?: (value: RichTextDocument) => void; + value?: null | RichTextDocument; +}; + +export type TipTapEditorProps = TipTapEditorHtmlProps | TipTapEditorJsonProps; + const textboxAttributesOf = ({ describedBy, invalid, @@ -33,44 +57,75 @@ const textboxAttributesOf = ({ ...(invalid === true || invalid === "true" ? { "aria-invalid": "true" } : {}), }); -export const TipTapEditor = ({ - "aria-describedby": describedBy, - "aria-labelledby": labelledBy, - className, - disableScroll, - placeholder, - value = "", - onChange, - onBlur, - ...props -}: TipTapEditorProps) => { +export const TipTapEditor = (props: TipTapEditorProps) => { + const { + "aria-describedby": describedBy, + "aria-labelledby": labelledBy, + className, + disableScroll, + extensions, + format: _format, + placeholder, + undoRedo, + value: _value, + onChange: _onChange, + onBlur, + onEditor, + ...rest + } = props; const t = useTranslations("core.global.editor"); const { emojis } = useEditorConfig(); + const emojiItems = React.useMemo(() => editorEmojiItems(emojis), [emojis]); const editor = useEditor({ - extensions: createTipTapExtensions({ - customEmojis: emojis, - placeholder: placeholder ?? t("placeholder"), - }), + extensions: [ + ...createTipTapExtensions({ + customEmojis: emojis, + placeholder: placeholder ?? t("placeholder"), + undoRedo, + }), + ...(extensions ?? []), + ], editorProps: { attributes: { class: - "max-w-full min-h-40 py-4 ps-10 pe-4 text-base focus:outline-none md:text-sm", + "max-w-full min-h-40 py-4 ps-5 pe-4 text-base focus:outline-none md:text-sm", role: "textbox", "aria-multiline": "true", ...textboxAttributesOf({ describedBy, - invalid: props["aria-invalid"], + invalid: rest["aria-invalid"], labelledBy, }), }, }, - content: value, + content: + props.format === "json" ? (props.value ?? null) : (props.value ?? ""), immediatelyRender: false, onUpdate: ({ editor: currentEditor }) => { - onChange?.(currentEditor.getHTML()); + if (props.format === "json") { + props.onChange?.( + toRichTextDocument(currentEditor.getJSON(), emojiItems), + ); + + return; + } + + props.onChange?.(currentEditor.getHTML()); }, }); + const announceEditor = React.useEffectEvent((next: Editor | null) => { + onEditor?.(next); + }); + React.useEffect(() => { + if (!editor) return; + announceEditor(editor); + + return () => { + announceEditor(null); + }; + }, [editor]); + if (!editor) return <EditorSkeleton className={className} />; return ( @@ -81,7 +136,7 @@ export const TipTapEditor = ({ className, )} onBlur={onBlur} - {...props} + {...rest} > <TipTapToolbar editor={editor} /> <TipTapDragHandle editor={editor} /> diff --git a/packages/vitnode/src/components/ui/sheet.tsx b/packages/vitnode/src/components/ui/sheet.tsx index 2e0afd0c10..6b40370529 100644 --- a/packages/vitnode/src/components/ui/sheet.tsx +++ b/packages/vitnode/src/components/ui/sheet.tsx @@ -40,17 +40,19 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { function SheetContent({ className, children, + keepMounted, side = "right", showCloseButton = true, ...props }: SheetPrimitive.Popup.Props & { + keepMounted?: boolean; showCloseButton?: boolean; side?: "bottom" | "left" | "right" | "top"; }) { const t = useTranslations("core.global"); return ( - <SheetPortal> + <SheetPortal keepMounted={keepMounted}> <SheetOverlay /> <SheetPrimitive.Popup className={cn( diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index 289f836e17..d75a62fb06 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -25,6 +25,11 @@ import { contentInnerFields, isContentReferenceCollection, } from "../paths"; +import { + isRichTextDocument, + isRichTextEmpty, + type RichTextDocument, +} from "../rich-text/document"; import { humanizeFieldName } from "./labels"; export interface ContentFormFieldSpec { @@ -80,7 +85,9 @@ export interface ContentFormSpec { contentTypeId: string; defaultLocale: null | string; + editorial?: true; fields: ContentFormFieldSpec[]; + liveEditing?: true; permissionModule: string; pluginId: string; @@ -283,6 +290,8 @@ export const buildContentFormSpec = ({ defaultLocale: definition.localization.enabled ? definition.localization.defaultLocale : null, + ...(definition.editorial.enabled ? { editorial: true as const } : {}), + ...(definition.liveEditing.enabled ? { liveEditing: true as const } : {}), permissionModule: definition.permissionModule, pluginId, titleField: definition.admin.titleField, @@ -400,6 +409,22 @@ export const buildGroupFormSchema = ( spec: ContentFormFieldSpec, ): z.ZodObject<z.ZodRawShape> => leafObjectSchema(spec); +const richTextFormSchema = (spec: ContentFormFieldSpec): z.ZodType => { + const doc = z.looseObject({ + content: z.array(z.record(z.string(), z.unknown())).optional(), + type: z.literal("doc"), + }); + + return spec.required && !spec.nullable + ? doc.refine( + value => isRichTextDocument(value) && !isRichTextEmpty(value), + { + message: `${spec.label} is empty.`, + }, + ) + : doc; +}; + const referenceSetSchema = (spec: ContentFormFieldSpec): z.ZodType => { const schema = z.array(z.number()); @@ -464,6 +489,8 @@ const baseFieldSchema = (spec: ContentFormFieldSpec): z.ZodType => { .min(spec.minItems ?? 0) .max(spec.maxItems ?? Number.MAX_SAFE_INTEGER); } + case "richText": + return richTextFormSchema(spec); case "user": // A to-many people field holds identifiers, exactly as a to-many relation // does: the set picker renders the names it fetched and stores what the @@ -561,8 +588,15 @@ const isLocalizedFieldName = (spec: ContentFormSpec, name: string): boolean => const localizedValueForApi = ( fieldSpec: ContentFormFieldSpec, - raw: string, + raw: unknown, ): unknown => { + if (fieldSpec.kind === "richText") { + if (isRichTextDocument(raw) && !isRichTextEmpty(raw)) return raw; + + return fieldSpec.nullable ? null : undefined; + } + + if (typeof raw !== "string") return undefined; if (raw.trim() !== "") return raw; // An empty slug means "derive it from the source field in this language". if (EMPTY_MEANS_UNSET.has(fieldSpec.kind)) return undefined; @@ -583,7 +617,7 @@ export const contentFormValuesToTranslations = ( const entries = values[fieldSpec.name]; if (!Array.isArray(entries)) continue; - for (const entry of entries as MultiLangValue) { + for (const entry of entries as MultiLangValue<unknown>) { const locale = entry.languageCode; if (typeof locale !== "string" || locale === "") continue; @@ -612,6 +646,21 @@ export const contentFormInitialValues = ( for (const fieldSpec of spec.fields) { if (fieldSpec.localized !== true) continue; + if (fieldSpec.kind === "richText") { + let documents: MultiLangValue<null | RichTextDocument> = []; + for (const translation of translations) { + const stored = translation.values[fieldSpec.name]; + documents = upsertLangValue<null | RichTextDocument>( + documents, + translation.locale, + isRichTextDocument(stored) ? stored : null, + ); + } + + initial[fieldSpec.name] = documents; + continue; + } + let value: MultiLangValue = []; for (const translation of translations) { const stored = translation.values[fieldSpec.name]; @@ -628,6 +677,35 @@ export const contentFormInitialValues = ( return initial; }; +const localizedRichTextSchema = ( + fieldSpec: ContentFormFieldSpec, + defaultLocale: null | string, + initial: MultiLangValue<unknown>, +): z.ZodType => + z + .array( + z.object({ + languageCode: z.string(), + value: richTextFormSchema({ ...fieldSpec, required: false }).nullable(), + }), + ) + .superRefine((rows, ctx) => { + if (!fieldSpec.required || fieldSpec.nullable || defaultLocale === null) { + return; + } + + const stored = rows.find( + row => row.languageCode === defaultLocale, + )?.value; + if (!isRichTextDocument(stored) || isRichTextEmpty(stored)) { + ctx.addIssue({ + code: "custom", + message: `${fieldSpec.label} is required in "${defaultLocale}", the language every record is stored in.`, + }); + } + }) + .default(initial); + const localizedFieldSchema = ( fieldSpec: ContentFormFieldSpec, defaultLocale: null | string, @@ -689,6 +767,19 @@ export const buildFormSchemaFromSpec = ( spec.fields.map(fieldSpec => { // A localized field holds every language at once, so its rules are // per-language rather than per-field. + if (fieldSpec.localized === true && fieldSpec.kind === "richText") { + return [ + fieldSpec.name, + localizedRichTextSchema( + fieldSpec, + spec.defaultLocale, + (values?.[fieldSpec.name] as + | MultiLangValue<unknown> + | undefined) ?? [], + ), + ]; + } + if (fieldSpec.localized === true) { return [ fieldSpec.name, @@ -772,3 +863,57 @@ export const buildFormSchemaFromSpec = ( }), ), ); + +export const contentFormFieldToApi = ( + spec: ContentFormSpec, + fieldSpec: ContentFormFieldSpec, + value: unknown, + locale: null | string, +): undefined | { value: unknown } => { + if (fieldSpec.localized === true) { + if (locale === null) return undefined; + + const converted = localizedValueForApi( + fieldSpec, + getLangValue(value as MultiLangValue | undefined, locale), + ); + + return converted === undefined ? undefined : { value: converted }; + } + + const schema = buildFormSchemaFromSpec({ ...spec, fields: [fieldSpec] }) + .shape[fieldSpec.name]; + const parsed = z.safeParse(schema, value); + if (!parsed.success || parsed.data === undefined) return undefined; + + return { + value: contentFormValuesToPayload(spec, { [fieldSpec.name]: parsed.data })[ + fieldSpec.name + ], + }; +}; + +export const contentApiValueToFormField = ( + fieldSpec: ContentFormFieldSpec, + value: unknown, + current: unknown, + locale: null | string, +): unknown => { + if (fieldSpec.localized === true) { + if (locale === null) return current; + + return upsertLangValue( + current as MultiLangValue | undefined, + locale, + typeof value === "string" ? value : "", + ); + } + + const shown = current as null | Partial<ContentReferenceOption> | undefined; + const label = + shown?.value !== undefined && shown.value === String(value) + ? (shown.label ?? null) + : null; + + return toInitialValue(fieldSpec, value, { [fieldSpec.name]: label }); +}; diff --git a/packages/vitnode/src/content/advanced.test-d.ts b/packages/vitnode/src/content/advanced.test-d.ts index 598d7a9dfa..d6ec569e03 100644 --- a/packages/vitnode/src/content/advanced.test-d.ts +++ b/packages/vitnode/src/content/advanced.test-d.ts @@ -37,7 +37,7 @@ const articleContentType = defineContentType({ list: { columns: ["title"] }, titleField: "title", }, - editorial: { enabled: true }, + editorial: true, fields: { categories: field.relation({ multiple: true, @@ -59,11 +59,10 @@ const articleContentType = defineContentType({ }, id: "test.d-article", publicApi: { - enabled: true, fields: ["title", "slug", "categories", "seo.title", "faq.question"], path: "d-articles", }, - publication: { enabled: true }, + publication: true, tableName: "test_d_articles", }); diff --git a/packages/vitnode/src/content/advanced.test.ts b/packages/vitnode/src/content/advanced.test.ts index c222c84b92..fd2d0e5596 100644 --- a/packages/vitnode/src/content/advanced.test.ts +++ b/packages/vitnode/src/content/advanced.test.ts @@ -338,7 +338,7 @@ describe("relations", () => { target: () => target, } as never), }, - localization: { defaultLocale: "en", enabled: true }, + localization: { defaultLocale: "en" }, }), ).toThrow(/per-locale references are out of scope/); }); @@ -384,7 +384,7 @@ describe("groups", () => { name: field.text({ required: true }), seo: field.group({ fields: seo, localized: true }), }, - localization: { defaultLocale: "en", enabled: true }, + localization: { defaultLocale: "en" }, }); expect(definition.advanced.leaves.every(leaf => leaf.localized)).toBe(true); @@ -400,7 +400,7 @@ describe("groups", () => { fields: { title: field.text({ localized: true, nullable: true }) }, }), }, - localization: { defaultLocale: "en", enabled: true }, + localization: { defaultLocale: "en" }, }), ).toThrow(/Localization is a property of the whole group/); }); @@ -536,7 +536,7 @@ describe("groups", () => { }), name: field.text({ required: true }), }, - localization: { defaultLocale: "en", enabled: true }, + localization: { defaultLocale: "en" }, }), ).toThrow(/the translation table generates for itself/); }); @@ -574,7 +574,7 @@ describe("repeatables", () => { faq: field.repeatable({ fields: faq, localized: true } as never), name: field.text({ required: true }), }, - localization: { defaultLocale: "en", enabled: true }, + localization: { defaultLocale: "en" }, }), ).toThrow(/Repeatable fields are shared/); }); @@ -591,7 +591,7 @@ describe("repeatables", () => { }), name: field.text({ required: true }), }, - localization: { defaultLocale: "en", enabled: true }, + localization: { defaultLocale: "en" }, }), ).toThrow(/repeatable fields are shared/); }); diff --git a/packages/vitnode/src/content/blocks-field.test.ts b/packages/vitnode/src/content/blocks-field.test.ts index ce45eb7355..64e4c555c6 100644 --- a/packages/vitnode/src/content/blocks-field.test.ts +++ b/packages/vitnode/src/content/blocks-field.test.ts @@ -240,7 +240,7 @@ describe("storage", () => { const localizedPage = defineContentType({ id: "test.localized-page", tableName: "test_blocks_localized", - localization: { defaultLocale: "en", enabled: true }, + localization: { defaultLocale: "en" }, fields: { content: field.blocks({ localized: true }), title: field.text({ required: true }), diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts index d2f9b8e165..17e571e694 100644 --- a/packages/vitnode/src/content/cache.ts +++ b/packages/vitnode/src/content/cache.ts @@ -75,7 +75,6 @@ export interface ContentSitemapChange { export interface ContentInvalidationInput { contentTypeId: string; - /** Delivery tags, for a content type with `delivery: { enabled: true }`. */ delivery?: ContentDeliveryInvalidation; id: number; /** Whether the row is publicly reachable *after* the mutation. */ diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index c11fb7effe..927454c7c2 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -81,6 +81,7 @@ export const CONTENT_SLUG_DEFAULT_LENGTH = 160; export const CONTENT_LOCALIZED_FIELD_KINDS = [ "blocks", + "richText", "slug", "text", "textarea", @@ -208,6 +209,7 @@ export const CONTENT_PUBLIC_EXPOSABLE_KINDS = [ "file", "number", "relation", + "richText", "slug", "text", "textarea", @@ -225,9 +227,18 @@ export const CONTENT_PUBLIC_ALWAYS_ORDERABLE = "publishedAt"; export const CONTENT_SEARCH_TITLE_KINDS = ["text"] as const; /** Field kinds `search.descriptionField` may name. */ -export const CONTENT_SEARCH_DESCRIPTION_KINDS = ["text", "textarea"] as const; +export const CONTENT_SEARCH_DESCRIPTION_KINDS = [ + "richText", + "text", + "textarea", +] as const; -export const CONTENT_SEARCH_TEXT_KINDS = ["slug", "text", "textarea"] as const; +export const CONTENT_SEARCH_TEXT_KINDS = [ + "richText", + "slug", + "text", + "textarea", +] as const; /** The placeholder every `search.pathTemplate` must use. */ export const CONTENT_SEARCH_SLUG_PLACEHOLDER = "{slug}"; @@ -310,7 +321,11 @@ export const CONTENT_SCHEDULE_CODES = { export const CONTENT_DELIVERY_TITLE_KINDS = ["text"] as const; /** Field kinds `delivery.seo.descriptionField` may name. */ -export const CONTENT_DELIVERY_DESCRIPTION_KINDS = ["text", "textarea"] as const; +export const CONTENT_DELIVERY_DESCRIPTION_KINDS = [ + "richText", + "text", + "textarea", +] as const; export const CONTENT_DELIVERY_NO_INDEX_KINDS = ["boolean"] as const; diff --git a/packages/vitnode/src/content/define-admin.ts b/packages/vitnode/src/content/define-admin.ts index 20aa4f9d57..8d0f6bbbab 100644 --- a/packages/vitnode/src/content/define-admin.ts +++ b/packages/vitnode/src/content/define-admin.ts @@ -425,7 +425,7 @@ export const resolveAdmin = <TFields>( searchableFields, thumbnailField, }, - navigation: { enabled: admin.navigation?.enabled ?? true }, + navigation: { enabled: admin.navigation ?? true }, path: resolveAdminPath(id, admin.path), titleField, }; diff --git a/packages/vitnode/src/content/define-editorial.ts b/packages/vitnode/src/content/define-editorial.ts index 5e934cfab8..09ad52c5aa 100644 --- a/packages/vitnode/src/content/define-editorial.ts +++ b/packages/vitnode/src/content/define-editorial.ts @@ -1,5 +1,6 @@ import type { ContentEditorialConfig, + ContentEditorialPreviewConfig, ResolvedContentEditorialConfig, ResolvedContentPublicApiConfig, } from "./types"; @@ -15,6 +16,7 @@ import { CONTENT_REVISION_MIN_RETENTION, CONTENT_SEARCH_ITEM_TYPE_MAX_LENGTH, } from "./const"; +import { contentOptionConfig } from "./define-shared"; import { ContentEngineError } from "./errors"; const disabledEditorial: ResolvedContentEditorialConfig = { @@ -100,11 +102,12 @@ const assertInRange = ({ */ export const resolveEditorial = ( id: string, - editorial: ContentEditorialConfig | undefined, + editorialOption: boolean | ContentEditorialConfig | undefined, publicApi: ResolvedContentPublicApiConfig, publication: boolean, ): ResolvedContentEditorialConfig => { - if (!editorial?.enabled) return disabledEditorial; + const editorial = contentOptionConfig(editorialOption); + if (!editorial) return disabledEditorial; const retention = editorial.revisions?.retention ?? CONTENT_REVISION_DEFAULT_RETENTION; @@ -126,11 +129,12 @@ export const resolveEditorial = ( ); } - const preview = - editorial.preview?.enabled === true ? editorial.preview : null; + const preview = contentOptionConfig<ContentEditorialPreviewConfig>( + editorial.preview, + ); if (preview && !publicApi.enabled) { throw new ContentEngineError( - "editorial.preview needs `publicApi: { enabled: true, path, fields }`. A preview returns the public projection of a draft, so without a public allowlist there is nothing it could safely show.", + "editorial.preview needs `publicApi: { path, fields }`. A preview returns the public projection of a draft, so without a public allowlist there is nothing it could safely show.", { contentTypeId: id }, ); } @@ -151,10 +155,10 @@ export const resolveEditorial = ( } } - const scheduling = editorial.scheduling?.enabled === true; + const scheduling = editorial.scheduling === true; if (scheduling && !publication) { throw new ContentEngineError( - "editorial.scheduling needs `publication: { enabled: true }`. A schedule moves `status`, and without the lifecycle there is no status to move.", + "editorial.scheduling needs `publication: true`. A schedule moves `status`, and without the lifecycle there is no status to move.", { contentTypeId: id }, ); } diff --git a/packages/vitnode/src/content/define-fields.ts b/packages/vitnode/src/content/define-fields.ts index ed69b08517..18db05bc70 100644 --- a/packages/vitnode/src/content/define-fields.ts +++ b/packages/vitnode/src/content/define-fields.ts @@ -24,6 +24,7 @@ import { normalizeContentFileExtensions, normalizeContentFileMimeTypes, } from "./files"; +import { RICH_TEXT_ABSOLUTE_MAX_BYTES } from "./rich-text/document"; /** A slug can only be derived from a field that holds a single line of text. */ const SLUG_SOURCE_KINDS = new Set<ContentFieldDescriptor["kind"]>(["text"]); @@ -52,6 +53,7 @@ const hasWritableFallback = (fieldValue: ContentFieldDescriptor): boolean => { // to-many file field *is* writable, for the same reason a to-many relation is: // the empty set is its default, and it has no column to default at all. if (fieldValue.kind === "file") return fieldValue.multiple; + if (fieldValue.kind === "richText") return false; return fieldValue.defaultValue !== undefined; }; @@ -101,6 +103,7 @@ const FIELD_KINDS = new Set<string>([ "number", "relation", "repeatable", + "richText", "slug", "text", "textarea", @@ -245,6 +248,20 @@ export const assertField = ( } } + if (fieldValue.kind === "richText" && fieldValue.maxBytes !== undefined) { + const { maxBytes } = fieldValue; + if ( + !Number.isInteger(maxBytes) || + maxBytes <= 0 || + maxBytes > RICH_TEXT_ABSOLUTE_MAX_BYTES + ) { + throw new ContentEngineError( + `Field "${name}" has a maxBytes of ${maxBytes}; it must be a whole number of bytes between 1 and ${RICH_TEXT_ABSOLUTE_MAX_BYTES}.`, + { contentTypeId: id }, + ); + } + } + if (fieldValue.kind === "blocks") { const { allowed, max, min } = fieldValue; diff --git a/packages/vitnode/src/content/define-public-api.ts b/packages/vitnode/src/content/define-public-api.ts index 3c27553776..2f6067c4c4 100644 --- a/packages/vitnode/src/content/define-public-api.ts +++ b/packages/vitnode/src/content/define-public-api.ts @@ -93,11 +93,11 @@ const assertPublicLeafPath = ( export const resolvePublicApi = <TField extends string>( id: string, fields: ContentFieldMap, - publicApi: ContentPublicApiConfig<TField> | undefined, + publicApi: ContentPublicApiConfig<TField> | false | undefined, publication: boolean, localizedFields: ContentFieldMap, ): ResolvedContentPublicApiConfig => { - if (!publicApi?.enabled) { + if (!publicApi) { return { defaultOrder: "desc", defaultOrderBy: CONTENT_PUBLIC_ALWAYS_ORDERABLE, @@ -113,7 +113,7 @@ export const resolvePublicApi = <TField extends string>( if (!publication) { throw new ContentEngineError( - "publicApi needs `publication: { enabled: true }`. A public API without a draft state would put every row on the internet the moment it is created.", + "publicApi needs `publication: true`. A public API without a draft state would put every row on the internet the moment it is created.", { contentTypeId: id }, ); } diff --git a/packages/vitnode/src/content/define-search.ts b/packages/vitnode/src/content/define-search.ts index a953554920..26c4181615 100644 --- a/packages/vitnode/src/content/define-search.ts +++ b/packages/vitnode/src/content/define-search.ts @@ -183,23 +183,23 @@ const resolveSearchPathTemplate = ( export const resolveSearch = ( id: string, fields: ContentFieldMap, - search: ContentSearchConfig | undefined, + search: ContentSearchConfig | false | undefined, publicApi: ResolvedContentPublicApiConfig, publication: boolean, localized: boolean, ): ResolvedContentSearchConfig => { - if (!search?.enabled) return disabledSearch; + if (!search) return disabledSearch; if (!publication) { throw new ContentEngineError( - "search needs `publication: { enabled: true }`. Only published records are indexed, and the publication lifecycle is what drives synchronization.", + "search needs `publication: true`. Only published records are indexed, and the publication lifecycle is what drives synchronization.", { contentTypeId: id }, ); } if (!publicApi.enabled) { throw new ContentEngineError( - "search needs `publicApi: { enabled: true, path, fields }`. A search hit links to a public URL, and every indexed field has to be published already.", + "search needs `publicApi: { path, fields }`. A search hit links to a public URL, and every indexed field has to be published already.", { contentTypeId: id }, ); } diff --git a/packages/vitnode/src/content/define-shared.ts b/packages/vitnode/src/content/define-shared.ts index 8dc6a68d99..7fc3298f9e 100644 --- a/packages/vitnode/src/content/define-shared.ts +++ b/packages/vitnode/src/content/define-shared.ts @@ -16,7 +16,7 @@ export const SEARCHABLE_KINDS = new Set<ContentFieldDescriptor["kind"]>([ export const EXPLICIT_SEARCHABLE_KINDS = new Set< ContentFieldDescriptor["kind"] ->([...SEARCHABLE_KINDS, "slug"]); +>([...SEARCHABLE_KINDS, "richText", "slug"]); export const systemFields: readonly string[] = CONTENT_SYSTEM_FIELDS; export const publicationFields: readonly string[] = CONTENT_PUBLICATION_FIELDS; @@ -73,3 +73,58 @@ export const resolveFieldTarget = ( ? { container: container.kind, descriptor: leafValue } : null; }; + +export const contentOptionConfig = <TConfig extends object>( + option: boolean | TConfig | undefined, +): null | Partial<TConfig> => { + if (typeof option === "object") return option; + + return option === true ? {} : null; +}; + +const CONTENT_OPTION_PATHS: readonly (readonly string[])[] = [ + ["admin", "navigation"], + ["delivery"], + ["delivery", "redirects"], + ["delivery", "sitemap"], + ["editorial"], + ["editorial", "preview"], + ["editorial", "scheduling"], + ["localization"], + ["publicApi"], + ["publication"], + ["search"], +]; + +const CONTENT_OPTIONS_WITH_REQUIRED_KEYS = new Set([ + "localization", + "publicApi", + "search", +]); + +const isPlainObject = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null; + +export const assertContentOptionShapes = ( + id: string, + options: Record<string, unknown>, +): void => { + for (const path of CONTENT_OPTION_PATHS) { + const value = path.reduce<unknown>( + (parent, key) => (isPlainObject(parent) ? parent[key] : undefined), + options, + ); + + if (isPlainObject(value) && Object.hasOwn(value, "enabled")) { + const name = path.join("."); + const turnOn = CONTENT_OPTIONS_WITH_REQUIRED_KEYS.has(name) + ? "pass its options object" + : `write \`${name}: true\` or pass its options object`; + + throw new ContentEngineError( + `${name}.enabled is not an option. A block is on when it is present: ${turnOn}, and omit it or set it to \`false\` to turn it off.`, + { contentTypeId: id }, + ); + } + } +}; diff --git a/packages/vitnode/src/content/define.test-d.ts b/packages/vitnode/src/content/define.test-d.ts index 42d4caf45c..c927a4cf36 100644 --- a/packages/vitnode/src/content/define.test-d.ts +++ b/packages/vitnode/src/content/define.test-d.ts @@ -216,3 +216,87 @@ describe("content type inference", () => { }); }); }); + +describe("content type options", () => { + const fields = { + title: field.text({ required: true }), + slug: field.slug({ source: "title" }), + }; + + it("resolves `true` and options objects to enabled literals", () => { + const definition = defineContentType({ + id: "test.options", + tableName: "test_options", + fields, + publication: true, + publicApi: { path: "options", fields: ["title", "slug"] }, + editorial: { preview: true }, + delivery: true, + }); + + expectTypeOf(definition.publication.enabled).toEqualTypeOf<true>(); + expectTypeOf(definition.publicApi.enabled).toEqualTypeOf<true>(); + expectTypeOf(definition.editorial.enabled).toEqualTypeOf<true>(); + expectTypeOf(definition.editorial.preview.enabled).toEqualTypeOf<true>(); + expectTypeOf( + definition.editorial.scheduling.enabled, + ).toEqualTypeOf<false>(); + expectTypeOf(definition.delivery.enabled).toEqualTypeOf<true>(); + }); + + it("resolves omitted blocks to disabled literals", () => { + const definition = defineContentType({ + id: "test.bare", + tableName: "test_bare", + fields, + }); + + expectTypeOf(definition.publication.enabled).toEqualTypeOf<false>(); + expectTypeOf(definition.publicApi.enabled).toEqualTypeOf<false>(); + expectTypeOf(definition.editorial.enabled).toEqualTypeOf<false>(); + expectTypeOf(definition.delivery.enabled).toEqualTypeOf<false>(); + }); + + it("accepts `liveEditing` only alongside `editorial`", () => { + assertType( + defineContentType({ + id: "test.live", + tableName: "test_live", + fields, + editorial: true, + liveEditing: true, + }), + ); + assertType( + defineContentType({ + id: "test.live", + tableName: "test_live", + fields, + // @ts-expect-error - live editing needs `editorial` + liveEditing: true, + }), + ); + }); + + it("rejects the removed `enabled` key", () => { + assertType( + defineContentType({ + id: "test.legacy", + tableName: "test_legacy", + fields, + // @ts-expect-error - `publication` is `true` or `false` + publication: { enabled: true }, + }), + ); + assertType( + defineContentType({ + id: "test.legacy", + tableName: "test_legacy", + fields, + publication: true, + // @ts-expect-error - a block is on when present, there is no `enabled` + publicApi: { enabled: true, path: "legacy", fields: ["slug"] }, + }), + ); + }); +}); diff --git a/packages/vitnode/src/content/define.test.ts b/packages/vitnode/src/content/define.test.ts index 7b29c3aa89..59f3ba9bbf 100644 --- a/packages/vitnode/src/content/define.test.ts +++ b/packages/vitnode/src/content/define.test.ts @@ -769,9 +769,8 @@ describe("defineContentType", () => { ) => define({ editorial, ...overrides }); const publishable = { - publication: { enabled: true } as const, + publication: true as const, publicApi: { - enabled: true, path: "widgets", fields: ["title", "slug"], } as const, @@ -796,7 +795,7 @@ describe("defineContentType", () => { }); it("fills in the defaults when opted in with nothing else", () => { - expect(editorialDefine({ enabled: true }).editorial).toEqual({ + expect(editorialDefine(true).editorial).toEqual({ enabled: true, preview: { enabled: false, @@ -810,48 +809,47 @@ describe("defineContentType", () => { it("keeps a declared retention", () => { expect( - editorialDefine({ enabled: true, revisions: { retention: 5 } }) - .editorial.revisions.retention, + editorialDefine({ revisions: { retention: 5 } }).editorial.revisions + .retention, ).toBe(5); }); }); describe("retention validation", () => { it.each([0, -1, 501, 1.5])("rejects a retention of %s", retention => { - expect(() => - editorialDefine({ enabled: true, revisions: { retention } }), - ).toThrow(ContentEngineError); + expect(() => editorialDefine({ revisions: { retention } })).toThrow( + ContentEngineError, + ); }); it.each([CONTENT_REVISION_MIN_RETENTION, CONTENT_REVISION_MAX_RETENTION])( "accepts the boundary %s", retention => { expect(() => - editorialDefine({ enabled: true, revisions: { retention } }), + editorialDefine({ revisions: { retention } }), ).not.toThrow(); }, ); }); describe("preview", () => { - const withPreview = (preview: { - enabled: true; - expiresInMinutes?: number; - pathTemplate?: string; - }): ReturnType<typeof define> => - editorialDefine({ enabled: true, preview }, publishable); + const withPreview = ( + preview: + | true + | { + expiresInMinutes?: number; + pathTemplate?: string; + }, + ): ReturnType<typeof define> => editorialDefine({ preview }, publishable); it("needs a public API", () => { expect(() => - editorialDefine( - { enabled: true, preview: { enabled: true } }, - { publication: { enabled: true } }, - ), + editorialDefine({ preview: true }, { publication: true }), ).toThrow(/needs `publicApi/); }); it("resolves its defaults", () => { - expect(withPreview({ enabled: true }).editorial.preview).toEqual({ + expect(withPreview(true).editorial.preview).toEqual({ enabled: true, expiresInMinutes: CONTENT_PREVIEW_DEFAULT_TTL_MINUTES, pathTemplate: null, @@ -859,9 +857,9 @@ describe("defineContentType", () => { }); it.each([0, 1441, 2.5])("rejects a TTL of %s minutes", value => { - expect(() => - withPreview({ enabled: true, expiresInMinutes: value }), - ).toThrow(ContentEngineError); + expect(() => withPreview({ expiresInMinutes: value })).toThrow( + ContentEngineError, + ); }); it.each([ @@ -873,34 +871,28 @@ describe("defineContentType", () => { ["/widgets/../{token}", "a traversal"], ["/widgets/pre view/{token}", "whitespace"], ])("rejects the pathTemplate %s (%s)", pathTemplate => { - expect(() => withPreview({ enabled: true, pathTemplate })).toThrow( - ContentEngineError, - ); + expect(() => withPreview({ pathTemplate })).toThrow(ContentEngineError); }); it("accepts a well-formed pathTemplate", () => { expect( - withPreview({ - enabled: true, - pathTemplate: "/widgets/preview/{token}", - }).editorial.preview.pathTemplate, + withPreview({ pathTemplate: "/widgets/preview/{token}" }).editorial + .preview.pathTemplate, ).toBe("/widgets/preview/{token}"); }); }); describe("scheduling", () => { it("needs publication", () => { - expect(() => - editorialDefine({ enabled: true, scheduling: { enabled: true } }), - ).toThrow(/needs `publication/); + expect(() => editorialDefine({ scheduling: true })).toThrow( + /needs `publication/, + ); }); it("is enabled alongside publication", () => { expect( - editorialDefine( - { enabled: true, scheduling: { enabled: true } }, - { publication: { enabled: true } }, - ).editorial.scheduling.enabled, + editorialDefine({ scheduling: true }, { publication: true }).editorial + .scheduling.enabled, ).toBe(true); }); }); @@ -912,9 +904,9 @@ describe("defineContentType", () => { }; it("rejects a field called `version` once enabled", () => { - expect(() => - editorialDefine({ enabled: true }, { fields: versionField }), - ).toThrow(/generated by `editorial`/); + expect(() => editorialDefine(true, { fields: versionField })).toThrow( + /generated by `editorial`/, + ); }); it("allows it when editorial is omitted", () => { @@ -924,20 +916,19 @@ describe("defineContentType", () => { it("rejects a content type id too long to store on a revision", () => { expect(() => - editorialDefine( - { enabled: true }, - { id: `test.${"a".repeat(100)}`, tableName: "test_long_id" }, - ), + editorialDefine(true, { + id: `test.${"a".repeat(100)}`, + tableName: "test_long_id", + }), ).toThrow(/limit for a revision/); }); describe("addressable column", () => { it("accepts `version` in the admin list once enabled", () => { expect( - editorialDefine( - { enabled: true }, - { admin: { list: { columns: ["title", "version"] } } }, - ).admin.list.columns, + editorialDefine(true, { + admin: { list: { columns: ["title", "version"] } }, + }).admin.list.columns, ).toEqual(["title", "version"]); }); @@ -949,15 +940,115 @@ describe("defineContentType", () => { it("accepts an index over it once enabled", () => { expect(() => - editorialDefine( - { enabled: true }, - { indexes: [{ on: ["version"] }] }, - ), + editorialDefine(true, { indexes: [{ on: ["version"] }] }), ).not.toThrow(); }); }); }); + describe("option shapes", () => { + const publicSlug = { + fields: { + title: field.text({ required: true }), + slug: field.slug({ source: "title" }), + }, + publication: true, + publicApi: { path: "widgets", fields: ["title", "slug"] }, + } as const; + + it("turns a block on with `true`", () => { + const definition = define({ + ...publicSlug, + delivery: true, + editorial: { preview: true, scheduling: true }, + }); + + expect(definition.publication.enabled).toBe(true); + expect(definition.editorial.enabled).toBe(true); + expect(definition.editorial.preview.enabled).toBe(true); + expect(definition.editorial.scheduling.enabled).toBe(true); + expect(definition.delivery.enabled).toBe(true); + }); + + it("turns a block on by its options object alone", () => { + const definition = define({ + ...publicSlug, + delivery: { sitemap: { priority: 0.5 } }, + }); + + expect(definition.publicApi.enabled).toBe(true); + expect(definition.delivery.sitemap).toEqual({ + changeFrequency: null, + enabled: true, + priority: 0.5, + }); + }); + + it("keeps a block off with `false`", () => { + const definition = define({ + ...publicSlug, + delivery: false, + editorial: false, + search: false, + }); + + expect(definition.editorial.enabled).toBe(false); + expect(definition.delivery.enabled).toBe(false); + expect(definition.search.enabled).toBe(false); + }); + + it("hides the sidebar entry with `navigation: false`", () => { + expect( + define({ admin: { navigation: false } }).admin.navigation.enabled, + ).toBe(false); + }); + + it.each([ + ["editorial", { editorial: { enabled: false } }], + ["editorial.preview", { editorial: { preview: { enabled: true } } }], + [ + "publicApi", + { publicApi: { enabled: true, path: "w", fields: ["slug"] } }, + ], + ["admin.navigation", { admin: { navigation: { enabled: false } } }], + ])("refuses the removed `%s.enabled` key", (name, overrides) => { + expect(() => define({ ...publicSlug, ...overrides } as never)).toThrow( + `${name}.enabled is not an option`, + ); + }); + + it("keeps live editing off until `liveEditing` opts in", () => { + expect(define({ editorial: true }).liveEditing.enabled).toBe(false); + expect( + defineContentType({ + id: "test.live", + tableName: "test_live", + fields: { title: field.text({ required: true }) }, + editorial: true, + liveEditing: true, + }).liveEditing.enabled, + ).toBe(true); + }); + + it("refuses `liveEditing` without `editorial`", () => { + expect(() => define({ liveEditing: true } as never)).toThrow( + /liveEditing needs `editorial`/, + ); + }); + + it("refuses an options object for `liveEditing`", () => { + expect(() => + define({ editorial: true, liveEditing: {} } as never), + ).toThrow(/liveEditing is `true` or `false`/); + }); + + it("refuses an options object for `publication`", () => { + expect(() => define({ publication: {} } as never)).toThrow( + /publication is `true` or `false`/, + ); + }); + }); + describe("fixtures", () => { it("resolves the article fixture", () => { expect(testArticleContentType.permissionModule).toBe("article"); diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 41366dffe0..b74ff3a5ec 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -4,10 +4,11 @@ import type { ContentDeliveryConfig, ContentDeliveryDescriptionField, ContentDeliveryEnabled, + ContentDeliveryInput, ContentDeliveryNoIndexField, ContentDeliveryTitleField, - ContentEditorialConfig, ContentEditorialEnabled, + ContentEditorialInput, ContentFieldMap, ContentFieldsConstraint, ContentIndexInput, @@ -15,7 +16,6 @@ import type { ContentLocalizationEnabled, ContentPreviewEnabled, ContentPublicApiConfig, - ContentPublicationConfig, ContentPublicExposableField, ContentSchedulingEnabled, ContentSearchAuthorField, @@ -55,6 +55,7 @@ import { import { resolvePublicApi } from "./define-public-api"; import { resolveSearch } from "./define-search"; import { + assertContentOptionShapes, assertKnownColumns, editorialFields, publicationFields, @@ -141,7 +142,9 @@ export const defineContentType = < >, TPublication extends boolean = false, TPublicField extends ContentPublicExposableField<TFields> = never, - TPublicEnabled extends boolean = false, + TPublicEnabled extends boolean = [TPublicField] extends [never] + ? false + : true, // Inferred from the `search` literal and checked against the public allowlist. // The constraint is verified once every other parameter is resolved, which is // what makes "an indexed field is a public field" a compile error. @@ -160,38 +163,35 @@ export const defineContentType = < ContentSearchTextField<TFields, TPublicField>, ContentSearchAuthorField<TFields> > - | { enabled: false } = { enabled: false }, + | false + | undefined = undefined, // The whole `editorial` argument, inferred as one type, for the same two // reasons `TSearch` is: its constraint is checked once `TPublicEnabled` and // `TPublication` are resolved - which is what makes "preview needs a public // API" and "scheduling needs publication" compile errors - and an // intersection member is not an inference site, so inferring the object is // the only way the three `enabled` literals survive. - TEditorial extends - | ContentEditorialConfig<TPublicEnabled, TPublication> - | { enabled: false } = { enabled: false }, + TEditorial extends ContentEditorialInput<TPublicEnabled, TPublication> = + false, // The whole `localization` argument, inferred as one type, for the same reason // `TSearch` and `TEditorial` are: an intersection member is not an inference // site, so this is the only way the `enabled` literal survives - and every // conditional that decides whether a translation table, translation schemas // and a translation service exist reads that literal. - TLocalization extends ContentLocalizationConfig | { enabled: false } = { - enabled: false; - }, + TLocalization extends ContentLocalizationConfig | false | undefined = + undefined, // The whole `delivery` argument, inferred as one type, for the same two reasons // `TSearch` and `TEditorial` are. Its *constraint* is what enforces the field // rules - a constraint is checked once `TPublicField` and `TPublicEnabled` are // resolved, which is what makes "delivery needs a public API" and "an SEO field // has to be public" compile errors rather than boot-time ones. - TDelivery extends - | ContentDeliveryConfig< - TPublicEnabled, - ContentEditorialEnabled<TEditorial>, - ContentDeliveryTitleField<TFields, TPublicField>, - ContentDeliveryDescriptionField<TFields, TPublicField>, - ContentDeliveryNoIndexField<TFields, TPublicField> - > - | { enabled: false } = { enabled: false }, + TDelivery extends ContentDeliveryInput< + TPublicEnabled, + ContentEditorialEnabled<TEditorial>, + ContentDeliveryTitleField<TFields, TPublicField>, + ContentDeliveryDescriptionField<TFields, TPublicField>, + ContentDeliveryNoIndexField<TFields, TPublicField> + > = false, TPublicPath extends string = string, >({ admin = {}, @@ -200,6 +200,7 @@ export const defineContentType = < fields, id, indexes = [], + liveEditing, localization, publicApi, publication, @@ -223,16 +224,17 @@ export const defineContentType = < ContentEditorialEnabled<TEditorial> >[]; + liveEditing?: ContentEditorialEnabled<TEditorial> extends true + ? boolean + : false; localization?: TLocalization; /** * Opts into a generated read-only public API. Needs `publication` and exactly * one exposed slug field. Omit it and nothing public is generated. */ - publicApi?: - | ContentPublicApiConfig<TPublicField, TPublicPath> - | { enabled: TPublicEnabled }; + publicApi?: ContentPublicApiConfig<TPublicField, TPublicPath> | false; /** Opts into the draft/published lifecycle. Omit to stay on Stage 1 behaviour. */ - publication?: ContentPublicationConfig | { enabled: TPublication }; + publication?: TPublication; search?: TSearch; tableName: string; @@ -293,8 +295,39 @@ export const defineContentType = < }); } - const publicationEnabled = publication?.enabled === true; - const editorialEnabled = editorial?.enabled === true; + assertContentOptionShapes(id, { + admin, + delivery, + editorial, + localization, + publicApi, + publication, + search, + }); + + if (publication !== undefined && typeof publication !== "boolean") { + throw new ContentEngineError( + "publication is `true` or `false`. It has no options of its own.", + { contentTypeId: id }, + ); + } + + if (liveEditing !== undefined && typeof liveEditing !== "boolean") { + throw new ContentEngineError( + "liveEditing is `true` or `false`. It has no options of its own.", + { contentTypeId: id }, + ); + } + + const publicationEnabled = publication === true; + const editorialEnabled = editorial !== undefined && editorial !== false; + + if (liveEditing === true && !editorialEnabled) { + throw new ContentEngineError( + "liveEditing needs `editorial`. Field locks and the shared draft are measured against the record version, which only editorial adds.", + { contentTypeId: id }, + ); + } for (const name of fieldNames) { assertFieldName(id, name, publicationEnabled, editorialEnabled); @@ -383,10 +416,7 @@ export const defineContentType = < const resolvedPublicApi = resolvePublicApi( id, fieldMap, - // The `{ enabled: TPublicEnabled }` arm of the parameter exists only so an - // `enabled: false` literal still typechecks; `resolvePublicApi` returns the - // disabled config for anything that is not `enabled: true`. - publicApi as ContentPublicApiConfig<TPublicField> | undefined, + publicApi, publicationEnabled, localizedFields, ); @@ -394,9 +424,7 @@ export const defineContentType = < const resolvedSearch = resolveSearch( id, fieldMap, - // Same shape of widening as `publicApi` above: the `{ enabled: false }` arm - // exists only so an explicit literal typechecks. - search as ContentSearchConfig | undefined, + search, resolvedPublicApi, publicationEnabled, Object.keys(localizedFields).length > 0, @@ -404,9 +432,7 @@ export const defineContentType = < const resolvedEditorial = resolveEditorial( id, - // The `{ enabled: false }` arm of the parameter exists only so an explicit - // literal typechecks - the same widening `publicApi` and `search` do. - editorial as ContentEditorialConfig | undefined, + editorial, resolvedPublicApi, publicationEnabled, ); @@ -417,9 +443,7 @@ export const defineContentType = < const resolvedLocalization = resolveContentLocalization({ fields: fieldMap, id, - // The `{ enabled: false }` arm exists only so an explicit literal - // typechecks - the same widening `publicApi`, `search` and `editorial` do. - localization: localization as ContentLocalizationConfig | undefined, + localization, publication: publicationEnabled, tableName, }); @@ -428,9 +452,7 @@ export const defineContentType = < // off the field partition, and after `publicApi`, because every canonical path // and every SEO field is stated in terms of the resolved public allowlist. const resolvedDelivery = resolveContentDelivery({ - // The `{ enabled: false }` arm exists only so an explicit literal typechecks - - // the same widening `publicApi`, `search`, `editorial` and `localization` do. - delivery: delivery as ContentDeliveryConfig | undefined, + delivery: delivery as boolean | ContentDeliveryConfig | undefined, // Read off the *resolved* editorial config rather than the argument, so the // redirect check sees exactly what `resolveEditorial` decided. editorial: resolvedEditorial.enabled, @@ -473,6 +495,7 @@ export const defineContentType = < fields: fieldMap as unknown as TFields, id, indexes: resolvedIndexes, + liveEditing: { enabled: liveEditing === true }, localization: resolvedLocalization as ResolvedContentLocalizationConfig< ContentLocalizationEnabled<TLocalization> >, diff --git a/packages/vitnode/src/content/delivery.test-d.ts b/packages/vitnode/src/content/delivery.test-d.ts index a94259d684..984091009d 100644 --- a/packages/vitnode/src/content/delivery.test-d.ts +++ b/packages/vitnode/src/content/delivery.test-d.ts @@ -34,9 +34,8 @@ const fields = { const shared = { fields, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, fields: ["id", "title", "slug", "excerpt", "seo.title", "seo.description"], path: "articles", }, @@ -45,10 +44,9 @@ const shared = { const deliveredType = defineContentType({ ...shared, id: "typed.delivered", - editorial: { enabled: true }, + editorial: true, delivery: { - enabled: true, - redirects: { enabled: true }, + redirects: true, seo: { descriptionField: "seo.description", fallbackDescriptionField: "excerpt", @@ -56,7 +54,7 @@ const deliveredType = defineContentType({ openGraph: { descriptionField: "excerpt", titleField: "title" }, titleField: "seo.title", }, - sitemap: { changeFrequency: "weekly", enabled: true, priority: 0.7 }, + sitemap: { changeFrequency: "weekly", priority: 0.7 }, }, tableName: "typed_delivered", }); @@ -111,11 +109,11 @@ describe("delivery requires a public API", () => { it("refuses `enabled: true` without one", () => { defineContentType({ id: "typed.no-public", - // @ts-expect-error - delivery needs `publicApi: { enabled: true }`: without a + // @ts-expect-error - delivery needs `publicApi`: without a // public allowlist there is no canonical URL for delivery to be about. - delivery: { enabled: true }, + delivery: true, fields, - publication: { enabled: true }, + publication: true, tableName: "typed_no_public", }); }); @@ -123,9 +121,9 @@ describe("delivery requires a public API", () => { it("still accepts an explicit `enabled: false`", () => { const off = defineContentType({ id: "typed.off", - delivery: { enabled: false }, + delivery: false, fields, - publication: { enabled: true }, + publication: true, tableName: "typed_off", }); @@ -134,26 +132,25 @@ describe("delivery requires a public API", () => { }); describe("redirects require editorial", () => { - it("refuses `redirects: { enabled: true }` without editorial", () => { + it("refuses `redirects: true` without editorial", () => { defineContentType({ ...shared, id: "typed.no-editorial", delivery: { - enabled: true, // @ts-expect-error - slug history has to be written in the same transaction // as the slug mutation and its revision, and only the editorial mutation // paths own one. Without `editorial` this would record nothing. - redirects: { enabled: true }, + redirects: true, }, tableName: "typed_no_editorial", }); }); - it("still accepts an explicit `redirects: { enabled: false }`", () => { + it("still accepts an explicit `redirects: false`", () => { const off = defineContentType({ ...shared, id: "typed.redirects-off", - delivery: { enabled: true, redirects: { enabled: false } }, + delivery: { redirects: false }, tableName: "typed_redirects_off", }); @@ -164,8 +161,8 @@ describe("redirects require editorial", () => { const on = defineContentType({ ...shared, id: "typed.redirects-on", - editorial: { enabled: true }, - delivery: { enabled: true, redirects: { enabled: true } }, + editorial: true, + delivery: { redirects: true }, tableName: "typed_redirects_on", }); @@ -178,9 +175,8 @@ describe("redirects require editorial", () => { ...shared, id: "typed.reads-only", delivery: { - enabled: true, seo: { descriptionField: "excerpt", titleField: "title" }, - sitemap: { changeFrequency: "daily", enabled: true, priority: 0.5 }, + sitemap: { changeFrequency: "daily", priority: 0.5 }, }, tableName: "typed_reads_only", }); @@ -196,7 +192,6 @@ describe("SEO field references", () => { ...shared, id: "typed.private-seo", delivery: { - enabled: true, // @ts-expect-error - `internalCode` is a text field, but it is not in // `publicApi.fields`, and a `<title>` is rendered into a public page. seo: { titleField: "internalCode" }, @@ -210,7 +205,6 @@ describe("SEO field references", () => { ...shared, id: "typed.prose-title", delivery: { - enabled: true, // @ts-expect-error - `excerpt` is a textarea. A `<title>` is one line, and a // paragraph in a browser tab is not a heading. seo: { titleField: "excerpt" }, @@ -224,7 +218,6 @@ describe("SEO field references", () => { ...shared, id: "typed-bad.description", delivery: { - enabled: true, // @ts-expect-error - `views` is a number, and it is private besides. seo: { descriptionField: "views" }, }, @@ -237,7 +230,6 @@ describe("SEO field references", () => { ...shared, id: "typed.bad-path", delivery: { - enabled: true, // @ts-expect-error - `seo.heading` is not a leaf of the `seo` group. seo: { titleField: "seo.heading" }, }, @@ -250,7 +242,6 @@ describe("SEO field references", () => { ...shared, id: "typed.nested", delivery: { - enabled: true, seo: { descriptionField: "seo.description", titleField: "seo.title" }, }, tableName: "typed_nested", @@ -264,9 +255,8 @@ describe("SEO field references", () => { ...shared, id: "typed.bad-freq", delivery: { - enabled: true, // @ts-expect-error - not one of the seven values the protocol defines. - sitemap: { changeFrequency: "fortnightly", enabled: true }, + sitemap: { changeFrequency: "fortnightly" }, }, tableName: "typed_bad_freq", }); @@ -309,7 +299,6 @@ describe("delivery events", () => { }); it("adds neither for a content type without delivery", () => { - // The keys are gated on `delivery: { enabled: true }`, so a listener for one // cannot even be registered - which is what keeps every Stage 1-7 event map // byte-identical. expectTypeOf<ContentEventsFor<typeof plainType>>().not.toHaveProperty( diff --git a/packages/vitnode/src/content/delivery.test.ts b/packages/vitnode/src/content/delivery.test.ts index 9120074df7..255ec8512d 100644 --- a/packages/vitnode/src/content/delivery.test.ts +++ b/packages/vitnode/src/content/delivery.test.ts @@ -19,12 +19,11 @@ import { import { field } from "./fields"; const base = { - publication: { enabled: true } as const, + publication: true as const, tableName: "delivery_articles", } as const; const publicApi = { - enabled: true, fields: ["id", "title", "slug", "excerpt", "publishedAt"], path: "articles", } as const; @@ -44,12 +43,11 @@ const articleType = defineContentType({ id: "delivery.article", // `redirects` needs `editorial`: slug history has to be written in the same // transaction as the slug mutation and its revision. - editorial: { enabled: true }, + editorial: true, delivery: { - enabled: true, - redirects: { enabled: true }, + redirects: true, seo: { descriptionField: "excerpt", titleField: "title" }, - sitemap: { changeFrequency: "weekly", enabled: true, priority: 0.7 }, + sitemap: { changeFrequency: "weekly", priority: 0.7 }, }, fields, publicApi, @@ -90,7 +88,7 @@ describe("delivery definition validation", () => { id: "delivery.private", // Refused by the types too - see `delivery.test-d.ts`. Cast here because // this asserts the *runtime* guard, which a JavaScript caller still reaches. - delivery: { enabled: true as never }, + delivery: true as never, fields, tableName: "delivery_private", }), @@ -106,7 +104,6 @@ describe("delivery definition validation", () => { // `publicApi.fields`, so a `<title>` built from it would publish something // the public API deliberately withholds. delivery: { - enabled: true, seo: { titleField: "internalCode" as never }, }, fields, @@ -122,7 +119,7 @@ describe("delivery definition validation", () => { ...base, id: "delivery.bad-kind", // `excerpt` is a textarea, which is a description and never a title. - delivery: { enabled: true, seo: { titleField: "excerpt" as never } }, + delivery: { seo: { titleField: "excerpt" as never } }, fields, publicApi, tableName: "delivery_bad_kind", @@ -135,7 +132,7 @@ describe("delivery definition validation", () => { defineContentType({ ...base, id: "delivery.unknown-seo", - delivery: { enabled: true, seo: { titleField: "nope" as never } }, + delivery: { seo: { titleField: "nope" as never } }, fields, publicApi, tableName: "delivery_unknown_seo", @@ -149,7 +146,6 @@ describe("delivery definition validation", () => { ...base, id: "delivery.repeatable-seo", delivery: { - enabled: true, seo: { titleField: "faq.question" as never }, }, fields: { @@ -172,7 +168,6 @@ describe("delivery definition validation", () => { ...base, id: "delivery.group-seo", delivery: { - enabled: true, seo: { descriptionField: "seo.description", fallbackTitleField: "title", @@ -209,7 +204,6 @@ describe("delivery definition validation", () => { ...base, id: "delivery.orphan-fallback", delivery: { - enabled: true, seo: { fallbackTitleField: "title" as never }, }, fields, @@ -224,7 +218,7 @@ describe("delivery definition validation", () => { defineContentType({ ...base, id: "delivery.bad-priority", - delivery: { enabled: true, sitemap: { enabled: true, priority: 7 } }, + delivery: { sitemap: { priority: 7 } }, fields, publicApi, tableName: "delivery_bad_priority", @@ -238,12 +232,10 @@ describe("delivery definition validation", () => { ...base, id: "delivery.bad-freq", delivery: { - enabled: true, sitemap: { // A crawler ignores an unknown value silently, so a typo has to be // caught here or it is a hint nobody ever receives. changeFrequency: "fortnightly" as never, - enabled: true, }, }, fields, @@ -258,7 +250,7 @@ describe("delivery definition validation", () => { defineContentType({ ...base, id: "delivery.bad-noindex", - delivery: { enabled: true, seo: { noIndexField: "title" as never } }, + delivery: { seo: { noIndexField: "title" as never } }, fields, publicApi, tableName: "delivery_bad_noindex", @@ -271,7 +263,7 @@ describe("delivery definition validation", () => { defineContentType({ ...base, id: "delivery.bad-xdefault", - delivery: { enabled: true, hreflang: { xDefault: "defaultLocale" } }, + delivery: { hreflang: { xDefault: "defaultLocale" } }, fields, publicApi, tableName: "delivery_bad_xdefault", @@ -289,16 +281,15 @@ describe("delivery definition validation", () => { defineContentType({ ...base, id: "delivery.shared-slug", - editorial: { enabled: true }, - delivery: { enabled: true, redirects: { enabled: true } }, + editorial: true, + delivery: { redirects: true }, fields: { body: field.textarea({ localized: true, required: true }), slug: field.slug({ source: "title" }), title: field.text({ required: true }), }, - localization: { defaultLocale: "en", enabled: true }, + localization: { defaultLocale: "en" }, publicApi: { - enabled: true, fields: ["title", "slug", "body"], path: "articles", }, @@ -316,8 +307,7 @@ describe("delivery definition validation", () => { // has no version to guard and no history to write. Accepting this would be // accepting a redirect feature that silently records nothing. delivery: { - enabled: true, - redirects: { enabled: true as never }, + redirects: true as never, }, fields, publicApi, @@ -332,16 +322,14 @@ describe("delivery definition validation", () => { ...base, id: "delivery.localized-no-editorial", delivery: { - enabled: true, - redirects: { enabled: true as never }, + redirects: true as never, }, fields: { slug: field.slug({ localized: true, source: "title" }), title: field.text({ localized: true, required: true }), }, - localization: { defaultLocale: "en", enabled: true }, + localization: { defaultLocale: "en" }, publicApi: { - enabled: true, fields: ["id", "title", "slug"], path: "articles", }, @@ -354,8 +342,8 @@ describe("delivery definition validation", () => { const withEditorial = defineContentType({ ...base, id: "delivery.with-editorial", - editorial: { enabled: true }, - delivery: { enabled: true, redirects: { enabled: true } }, + editorial: true, + delivery: { redirects: true }, fields, publicApi, tableName: "delivery_with_editorial", @@ -371,9 +359,8 @@ describe("delivery definition validation", () => { ...base, id: "delivery.reads-only", delivery: { - enabled: true, seo: { descriptionField: "excerpt", titleField: "title" }, - sitemap: { changeFrequency: "weekly", enabled: true, priority: 0.7 }, + sitemap: { changeFrequency: "weekly", priority: 0.7 }, }, fields, publicApi, @@ -395,10 +382,9 @@ describe("delivery definition validation", () => { ...base, id: "delivery.localized-reads", delivery: { - enabled: true, hreflang: { xDefault: "defaultLocale" }, seo: { fallbackTitleField: "title", titleField: "seo.title" }, - sitemap: { enabled: true }, + sitemap: true, }, fields: { seo: field.group({ @@ -409,9 +395,8 @@ describe("delivery definition validation", () => { slug: field.slug({ localized: true, source: "title" }), title: field.text({ localized: true, required: true }), }, - localization: { defaultLocale: "en", enabled: true, fallback: "default" }, + localization: { defaultLocale: "en", fallback: "default" }, publicApi: { - enabled: true, fields: ["id", "title", "slug", "seo.title"], path: "articles", }, @@ -438,14 +423,13 @@ describe("delivery definition validation", () => { defineContentType({ ...base, id: "delivery.no-id", - delivery: { enabled: true }, + delivery: true, fields: { slug: field.slug({ localized: true, source: "title" }), title: field.text({ localized: true, required: true }), }, - localization: { defaultLocale: "en", enabled: true }, + localization: { defaultLocale: "en" }, publicApi: { - enabled: true, // No `id`, so alternates and `hreflang` could not be resolved - and an // empty `hreflang` looks exactly like a record with one translation. fields: ["title", "slug"], @@ -462,9 +446,9 @@ describe("delivery definition validation", () => { const withoutId = defineContentType({ ...base, id: "delivery.no-id-flat", - delivery: { enabled: true }, + delivery: true, fields, - publicApi: { enabled: true, fields: ["title", "slug"], path: "articles" }, + publicApi: { fields: ["title", "slug"], path: "articles" }, tableName: "delivery_no_id_flat", }); @@ -478,7 +462,6 @@ describe("delivery definition validation", () => { ...base, id: "delivery.localized-noindex", delivery: { - enabled: true, seo: { noIndexField: "flags.noIndex" as never }, }, fields: { @@ -491,9 +474,8 @@ describe("delivery definition validation", () => { slug: field.slug({ localized: true, source: "title" }), title: field.text({ localized: true, required: true }), }, - localization: { defaultLocale: "en", enabled: true }, + localization: { defaultLocale: "en" }, publicApi: { - enabled: true, // `id` because a localized delivery content type has to expose it - see // "refuses a localized content type that withholds id" below. fields: ["id", "title", "slug", "flags.noIndex"], @@ -508,17 +490,16 @@ describe("delivery definition validation", () => { const localizedType = defineContentType({ ...base, id: "delivery.localized", - editorial: { enabled: true }, + editorial: true, delivery: { - enabled: true, hreflang: { xDefault: "defaultLocale" }, - redirects: { enabled: true }, + redirects: true, seo: { descriptionField: "seo.description", fallbackTitleField: "title", titleField: "seo.title", }, - sitemap: { changeFrequency: "daily", enabled: true, priority: 0.5 }, + sitemap: { changeFrequency: "daily", priority: 0.5 }, }, fields: { seo: field.group({ @@ -532,9 +513,8 @@ const localizedType = defineContentType({ slug: field.slug({ localized: true, source: "title" }), title: field.text({ localized: true, required: true }), }, - localization: { defaultLocale: "en", enabled: true, fallback: "default" }, + localization: { defaultLocale: "en", fallback: "default" }, publicApi: { - enabled: true, fields: ["id", "title", "slug", "seo.title", "seo.description"], path: "articles", }, @@ -547,7 +527,6 @@ const blogType = defineContentType({ ...base, id: "delivery.blog", delivery: { - enabled: true, hreflang: { xDefault: "defaultLocale" }, path: "/blog/:slug", }, @@ -555,8 +534,8 @@ const blogType = defineContentType({ slug: field.slug({ localized: true, source: "title" }), title: field.text({ localized: true, required: true }), }, - localization: { defaultLocale: "en", enabled: true }, - publicApi: { enabled: true, fields: ["id", "title", "slug"], path: "posts" }, + localization: { defaultLocale: "en" }, + publicApi: { fields: ["id", "title", "slug"], path: "posts" }, tableName: "delivery_blog", }); @@ -577,7 +556,7 @@ describe("delivery.path", () => { defineContentType({ ...base, id: "delivery.routed", - delivery: { enabled: true, path }, + delivery: { path }, fields, publicApi, tableName: "delivery_routed", @@ -903,7 +882,6 @@ describe("Open Graph projection", () => { ...base, id: "delivery.og", delivery: { - enabled: true, seo: { openGraph: {}, titleField: "title" }, }, fields, @@ -926,7 +904,7 @@ describe("robots projection", () => { const withNoIndex = defineContentType({ ...base, id: "delivery.noindex", - delivery: { enabled: true, seo: { noIndexField: "hidden" } }, + delivery: { seo: { noIndexField: "hidden" } }, fields, publicApi: { ...publicApi, fields: [...publicApi.fields, "hidden"] }, tableName: "delivery_noindex", diff --git a/packages/vitnode/src/content/delivery.ts b/packages/vitnode/src/content/delivery.ts index 2de21e3745..7434d040a6 100644 --- a/packages/vitnode/src/content/delivery.ts +++ b/packages/vitnode/src/content/delivery.ts @@ -2,6 +2,7 @@ import type { ContentLocaleRouting, ContentPublicUrl } from "./public-url"; import type { AnyContentTypeDefinition, ContentDeliveryConfig, + ContentDeliverySitemapConfig, ContentFieldDescriptor, ContentFieldMap, ContentSitemapChangeFrequency, @@ -18,10 +19,13 @@ import { CONTENT_DELIVERY_TITLE_KINDS, isContentSitemapChangeFrequency, } from "./const"; +import { contentOptionConfig } from "./define-shared"; import { ContentEngineError } from "./errors"; import { normalizeContentLocale } from "./locale"; import { readContentPath, splitContentFieldPath } from "./paths"; import { contentPublicHref, contentPublicUrl } from "./public-url"; +import { isRichTextDocument } from "./rich-text/document"; +import { richTextToPlainText } from "./rich-text/plain-text"; /** Kinds the three SEO slots accept, as runtime sets. */ const titleKinds: ReadonlySet<string> = new Set(CONTENT_DELIVERY_TITLE_KINDS); @@ -209,7 +213,7 @@ const resolveContentDeliveryPath = ({ }; export const resolveContentDelivery = ({ - delivery, + delivery: deliveryOption, editorial, fields, id, @@ -218,7 +222,7 @@ export const resolveContentDelivery = ({ publicApi, publication, }: { - delivery: ContentDeliveryConfig | undefined; + delivery: boolean | ContentDeliveryConfig | undefined; /** Whether the content type opted into the editorial workflow. */ editorial: boolean; fields: ContentFieldMap; @@ -228,11 +232,12 @@ export const resolveContentDelivery = ({ publicApi: ResolvedContentPublicApiConfig; publication: boolean; }): ResolvedContentDeliveryConfig => { - if (!delivery?.enabled) return contentDeliveryDisabled; + const delivery = contentOptionConfig(deliveryOption); + if (!delivery) return contentDeliveryDisabled; if (!publicApi.enabled) { throw new ContentEngineError( - "delivery needs `publicApi: { enabled: true, path, fields }`. A content type with no public API has no public URL, so there is no canonical path, no redirect and no sitemap entry for delivery to produce.", + "delivery needs `publicApi: { path, fields }`. A content type with no public API has no public URL, so there is no canonical path, no redirect and no sitemap entry for delivery to produce.", { contentTypeId: id }, ); } @@ -245,9 +250,10 @@ export const resolveContentDelivery = ({ ); } - const redirects = delivery.redirects?.enabled === true; - const sitemapConfig = - delivery.sitemap?.enabled === true ? delivery.sitemap : null; + const redirects = delivery.redirects === true; + const sitemapConfig = contentOptionConfig<ContentDeliverySitemapConfig>( + delivery.sitemap, + ); const slugScope = localizedFields[slugField] === undefined ? "shared" : "localized"; @@ -258,12 +264,11 @@ export const resolveContentDelivery = ({ // a version to guard nor a history to write, so accepting this would be accepting a // feature that records nothing. // - // Refused rather than downgraded to `redirects: { enabled: false }`: an author who // asked for redirects and silently got none would find out from a broken link // months later. The type system refuses it too - see `ContentDeliveryConfig`. if (redirects && !editorial) { throw new ContentEngineError( - "delivery.redirects needs `editorial: { enabled: true }`. Redirect history has to be written atomically with the slug mutation and its version and revision, and only the editorial mutation paths own that transaction. Delivery without `redirects` - canonical URLs, SEO, alternates and the sitemap - works without editorial.", + "delivery.redirects needs `editorial`. Redirect history has to be written atomically with the slug mutation and its version and revision, and only the editorial mutation paths own that transaction. Delivery without `redirects` - canonical URLs, SEO, alternates and the sitemap - works without editorial.", { contentTypeId: id }, ); } @@ -288,7 +293,7 @@ export const resolveContentDelivery = ({ // the moment it was created. if (sitemapConfig && !publication) { throw new ContentEngineError( - "delivery.sitemap needs `publication: { enabled: true }`. A sitemap lists what is publicly reachable, and without the lifecycle every row would be listed the moment it was created.", + "delivery.sitemap needs `publication: true`. A sitemap lists what is publicly reachable, and without the lifecycle every row would be listed the moment it was created.", { contentTypeId: id }, ); } @@ -323,7 +328,7 @@ export const resolveContentDelivery = ({ if (!localization.enabled) { throw new ContentEngineError( - "delivery.hreflang needs `localization: { enabled: true, defaultLocale }`. A content type with one language has no alternates, so there is nothing for an x-default to be the default of.", + "delivery.hreflang needs `localization: { defaultLocale }`. A content type with one language has no alternates, so there is nothing for an x-default to be the default of.", { contentTypeId: id }, ); } @@ -612,7 +617,8 @@ const readSeoText = ( for (const name of [primary, fallback]) { if (name === null) continue; - const value = readContentPath(row, name); + const raw = readContentPath(row, name); + const value = isRichTextDocument(raw) ? richTextToPlainText(raw) : raw; if (typeof value !== "string") continue; const trimmed = value.trim(); diff --git a/packages/vitnode/src/content/editorial.test-d.ts b/packages/vitnode/src/content/editorial.test-d.ts index deafe915b6..3596c02830 100644 --- a/packages/vitnode/src/content/editorial.test-d.ts +++ b/packages/vitnode/src/content/editorial.test-d.ts @@ -93,11 +93,10 @@ describe("editorial", () => { id: "test.no-public", tableName: "test_no_public", fields: { title: field.text({ required: true }) }, - publication: { enabled: true }, + publication: true, editorial: { - enabled: true, // @ts-expect-error - preview projects through `publicApi.fields` - preview: { enabled: true }, + preview: true, }, }); }); @@ -108,9 +107,8 @@ describe("editorial", () => { tableName: "test_no_lifecycle", fields: { title: field.text({ required: true }) }, editorial: { - enabled: true, // @ts-expect-error - a schedule moves `status` - scheduling: { enabled: true }, + scheduling: true, }, }); }); @@ -182,7 +180,7 @@ describe("editorial", () => { // @ts-expect-error - generated by `editorial` version: field.number({ integer: true, defaultValue: 0 }), }, - editorial: { enabled: true }, + editorial: true, }); }); @@ -210,7 +208,7 @@ describe("editorial", () => { id: "test.version-column", tableName: "test_version_column", fields: { title: field.text({ required: true }) }, - editorial: { enabled: true }, + editorial: true, admin: { list: { columns: ["title", "version"], defaultOrderBy: "version" }, }, diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts index 16544e7e6c..a5667da8b2 100644 --- a/packages/vitnode/src/content/errors.ts +++ b/packages/vitnode/src/content/errors.ts @@ -342,7 +342,7 @@ export class ContentDeliverySlugReserved extends ContentEngineError { export class ContentDeliveryNotEnabled extends ContentEngineError { constructor({ contentTypeId }: { contentTypeId: string }) { super( - "This content type has no `delivery` block, so it has no canonical URL, no slug history and no sitemap. Add `delivery: { enabled: true }` to generate them.", + "This content type has no `delivery` block, so it has no canonical URL, no slug history and no sitemap. Add `delivery: true` to generate them.", { contentTypeId }, ); diff --git a/packages/vitnode/src/content/events.ts b/packages/vitnode/src/content/events.ts index 935ff44155..43e8eb5acd 100644 --- a/packages/vitnode/src/content/events.ts +++ b/packages/vitnode/src/content/events.ts @@ -180,7 +180,7 @@ export interface ContentTranslationRestoredPayload< /** * The six events a localized content type adds. * - * Gated on `localization: { enabled: true }` exactly like the publication and + * Gated on `localization` exactly like the publication and * editorial pairs, so a content type without it gains no key at all and a * listener for one cannot be registered. That is what keeps every non-localized * payload byte-identical to what it was before Stage 5B. @@ -273,7 +273,7 @@ export interface ContentDeliveryRedirectCreatedPayload { /** * The two events the delivery layer adds. * - * Gated on `delivery: { enabled: true }` exactly like the publication, editorial + * Gated on `delivery: true` exactly like the publication, editorial * and localization groups, so a content type without it gains no key at all and a * listener for one cannot be registered - which is what keeps every Stage 1-7 * event map byte-identical. diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts index b87fc6f48c..bc4e4d18b5 100644 --- a/packages/vitnode/src/content/fields.ts +++ b/packages/vitnode/src/content/fields.ts @@ -12,6 +12,7 @@ import type { ContentOnDelete, ContentRelationField, ContentRepeatableField, + ContentRichTextField, ContentSlugField, ContentSlugRequired, ContentTextareaField, @@ -93,6 +94,22 @@ const textarea = < localized: localizedOf(args), }); +const richText = < + TRequired extends boolean = false, + TNullable extends boolean = false, + TLocalized extends boolean = false, +>( + args: LocalizableArgs<TLocalized> & + SharedArgs<TRequired, TNullable> & { + maxBytes?: number; + } = {}, +): ContentRichTextField<TRequired, TNullable, TLocalized> => ({ + ...args, + ...shared(args), + kind: "richText", + localized: localizedOf(args), +}); + const number = < TRequired extends boolean = false, TNullable extends boolean = false, @@ -360,6 +377,7 @@ export const field = { number, relation, repeatable, + richText, slug, text, textarea, diff --git a/packages/vitnode/src/content/file-collection.test.ts b/packages/vitnode/src/content/file-collection.test.ts index 8a7d3b165c..f9637733ee 100644 --- a/packages/vitnode/src/content/file-collection.test.ts +++ b/packages/vitnode/src/content/file-collection.test.ts @@ -368,7 +368,7 @@ describe("what a file collection may not be", () => { localized: true, } as unknown as typeof gallery, }, - { localization: { enabled: true, defaultLocale: "en" } }, + { localization: { defaultLocale: "en" } }, ), ).toThrow(/localized: true/); }); @@ -394,9 +394,8 @@ describe("what a file collection may not be", () => { galleryWith( { gallery, title: field.text({ required: true }) }, { - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, path: "gallery-articles", fields: ["title", "gallery"], orderableFields: ["gallery"], diff --git a/packages/vitnode/src/content/file-field.test.ts b/packages/vitnode/src/content/file-field.test.ts index d4c6b67f32..44e94012b7 100644 --- a/packages/vitnode/src/content/file-field.test.ts +++ b/packages/vitnode/src/content/file-field.test.ts @@ -102,7 +102,7 @@ describe("what a file field may not be", () => { localized: true, } as unknown as typeof coverImage, }, - { localization: { enabled: true, defaultLocale: "en" } }, + { localization: { defaultLocale: "en" } }, ), ).toThrow(/`localized: true`, which is not supported/); }); @@ -162,7 +162,6 @@ describe("what a file field may not be", () => { it("rejects it as a public filter or sort", () => { const publicApi = { - enabled: true as const, path: "articles", fields: ["slug", "coverImage", "publishedAt"] as never, }; @@ -175,7 +174,7 @@ describe("what a file field may not be", () => { coverImage, }, { - publication: { enabled: true }, + publication: true, publicApi: { ...publicApi, orderableFields: ["coverImage"] as never }, }, ), @@ -189,7 +188,7 @@ describe("what a file field may not be", () => { coverImage, }, { - publication: { enabled: true }, + publication: true, publicApi: { ...publicApi, filterableFields: ["coverImage"] as never, @@ -270,9 +269,8 @@ describe("the public projection", () => { slug: field.slug({ source: "title" }), coverImage, }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, path: "public-file-articles", fields: ["slug", "coverImage", "publishedAt"], }, diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index a4a3cb8e2e..c90c62355c 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -327,6 +327,25 @@ export type { ContentTranslationRevisionOperation, ContentTranslationRevisionSnapshot, } from "./revisions"; +export { + createRichTextDocumentSchema, + emptyRichTextDocument, + escapeRichTextHtml, + isRichTextDocument, + isRichTextEmpty, + RICH_TEXT_DEFAULT_MAX_BYTES, + RICH_TEXT_MAX_DEPTH, + richTextToHtml, + richTextToPlainText, + sanitizeRichTextHref, + zodRichTextDocument, +} from "./rich-text"; +export type { + RichTextDocument, + RichTextMark, + RichTextNode, + RichTextSchemaOptions, +} from "./rich-text"; export { contentScheduleTimingError } from "./schedules"; export type { ContentSchedule, @@ -364,18 +383,18 @@ export type { ContentDeliveryDescriptionField, ContentDeliveryEnabled, ContentDeliveryHreflangConfig, + ContentDeliveryInput, ContentDeliveryNoIndexField, ContentDeliveryOpenGraphConfig, - ContentDeliveryRedirectsConfig, ContentDeliverySeoConfig, ContentDeliverySitemapConfig, ContentDeliveryTitleField, ContentEditorialConfig, ContentEditorialEnabled, ContentEditorialField, + ContentEditorialInput, ContentEditorialPreviewConfig, ContentEditorialRevisionsConfig, - ContentEditorialSchedulingConfig, ContentEnumField, ContentFieldDescriptor, ContentFieldInput, @@ -397,7 +416,6 @@ export type { ContentOrderableFieldName, ContentPreviewEnabled, ContentPublicApiConfig, - ContentPublicationConfig, ContentPublicationField, ContentPublicationStatus, ContentPublicExposableField, @@ -410,6 +428,7 @@ export type { ContentReferenceField, ContentReferenceFieldName, ContentRelationField, + ContentRichTextField, ContentSchedulingEnabled, ContentSearchAuthorField, ContentSearchConfig, diff --git a/packages/vitnode/src/content/live/http.ts b/packages/vitnode/src/content/live/http.ts new file mode 100644 index 0000000000..76324381cd --- /dev/null +++ b/packages/vitnode/src/content/live/http.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +import { CONTENT_LOCALE_MAX_LENGTH } from "../const"; + +export const CONTENT_LIVE_CODES = { + draftInvalid: "CONTENT_DRAFT_INVALID", + draftScope: "CONTENT_DRAFT_WRONG_SCOPE", + locked: "CONTENT_FIELD_LOCKED", + notLocked: "CONTENT_FIELD_NOT_LOCKED", +} as const; + +const CONTENT_FIELD_LOCK_ACTIONS = ["acquire", "renew", "release"] as const; + +export type ContentFieldLockAction = + (typeof CONTENT_FIELD_LOCK_ACTIONS)[number]; + +const zodLocale = z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH).nullable(); + +export const zodContentFieldLock = z.object({ + expiresAt: z.string(), + field: z.string(), + locale: z.string().nullable(), + user: z.object({ id: z.number(), name: z.string() }), +}); + +export const zodContentFieldLockList = z.object({ + locks: z.array(zodContentFieldLock), +}); + +export const zodContentFieldLockRequest = z.strictObject({ + action: z.enum(CONTENT_FIELD_LOCK_ACTIONS), + field: z.string().min(1).max(100), + locale: zodLocale, +}); + +export const zodContentFieldLockResponse = z.object({ + lock: zodContentFieldLock.nullable(), +}); + +export const zodContentFieldLocked = z.object({ + code: z.literal(CONTENT_LIVE_CODES.locked), + lock: zodContentFieldLock.nullable(), +}); + +export const zodContentDraft = z.object({ + baseVersion: z.number(), + updatedAt: z.string(), + updatedBy: z.object({ id: z.number(), name: z.string() }).nullable(), + values: z.record(z.string(), z.unknown()), +}); + +export type ContentDraft = z.infer<typeof zodContentDraft>; + +export const zodContentDrafts = z.object({ + shared: zodContentDraft.nullable(), + translations: z.record(z.string(), zodContentDraft), +}); + +export type ContentDrafts = z.infer<typeof zodContentDrafts>; + +export const zodContentDraftWrite = z.strictObject({ + locale: zodLocale, + values: z + .record(z.string(), z.unknown()) + .refine(values => Object.keys(values).length > 0, { + message: "Provide at least one field.", + }), +}); + +export const zodContentDraftWritten = z.object({ updatedAt: z.string() }); + +export const zodContentDraftRejection = z.object({ + code: z.enum([ + CONTENT_LIVE_CODES.draftInvalid, + CONTENT_LIVE_CODES.draftScope, + CONTENT_LIVE_CODES.notLocked, + ]), + fields: z.array(z.string()), +}); + +export type ContentDraftRejection = z.infer<typeof zodContentDraftRejection>; diff --git a/packages/vitnode/src/content/live/protocol.ts b/packages/vitnode/src/content/live/protocol.ts new file mode 100644 index 0000000000..f928534268 --- /dev/null +++ b/packages/vitnode/src/content/live/protocol.ts @@ -0,0 +1,170 @@ +import { createWebSocketChannel } from "@/ws/types"; + +export interface ContentLiveRoomRef { + contentTypeId: string; + itemId: number; +} + +export interface ContentLiveDocRef extends ContentLiveRoomRef { + field: string; + locale: null | string; +} + +export interface ContentLiveMember { + avatarColor: null | string; + clientId: string; + field: null | string; + locale: null | string; + name: string; + nameCode: null | string; + userId: number; +} + +export interface ContentFieldLock { + expiresAt: string; + field: string; + locale: null | string; + user: { id: number; name: string }; +} + +export type ContentLiveClientMessage = + | { + clientId: string; + doc: ContentLiveDocRef; + stateVector: string; + type: "doc:open"; + } + | { + clientId: string; + doc: ContentLiveDocRef; + type: "doc:awareness" | "doc:update"; + update: string; + } + | { + clientId: string; + doc: ContentLiveDocRef; + type: "doc:close"; + } + | { + clientId: string; + field: null | string; + locale: null | string; + room: ContentLiveRoomRef; + type: "focus"; + } + | { + clientId: string; + locale: null | string; + room: ContentLiveRoomRef; + type: "join"; + } + | { + clientId: string; + room: ContentLiveRoomRef; + type: "heartbeat" | "leave"; + }; + +export type ContentLiveErrorCode = + | "FORBIDDEN" + | "INVALID_MESSAGE" + | "NOT_FOUND" + | "NOT_JOINED"; + +export type ContentLiveResetReason = "deleted" | "discarded" | "restored"; + +export type ContentLiveServerMessage = + | { + by: { id: number; name: string }; + locale: null | string; + room: ContentLiveRoomRef; + type: "draft"; + updatedAt: string; + values: Record<string, unknown>; + } + | { + clientId: string; + code: ContentLiveErrorCode; + room?: ContentLiveRoomRef; + type: "error"; + } + | { + clientId: string; + doc: ContentLiveDocRef; + stateVector: string; + type: "doc:state-vector"; + } + | { + clientId: string; + doc: ContentLiveDocRef; + type: "doc:seed"; + } + | { + clientId: string; + members: ContentLiveMember[]; + room: ContentLiveRoomRef; + type: "joined"; + } + | { + doc: ContentLiveDocRef; + from: null | string; + type: "doc:awareness" | "doc:update"; + update: string; + } + | { + locks: ContentFieldLock[]; + room: ContentLiveRoomRef; + type: "locks"; + } + | { + members: ContentLiveMember[]; + room: ContentLiveRoomRef; + type: "presence"; + } + | { + reason: ContentLiveResetReason; + room: ContentLiveRoomRef; + type: "reset"; + } + | { + room: ContentLiveRoomRef; + type: "committed"; + }; + +export const contentLiveChannel = createWebSocketChannel< + ContentLiveClientMessage, + ContentLiveServerMessage +>({ pluginId: "@vitnode/core", module: "content", id: "live" }); + +export const contentLiveRoom = ({ + contentTypeId, + itemId, +}: ContentLiveRoomRef): string => `content:${contentTypeId}:${itemId}`; + +export const contentLiveDocRoom = ({ + contentTypeId, + field, + itemId, + locale, +}: ContentLiveDocRef): string => + `content-doc:${contentTypeId}:${itemId}:${field}:${locale ?? "_"}`; + +export const sameContentLiveRoom = ( + a: ContentLiveRoomRef, + b: ContentLiveRoomRef, +): boolean => a.contentTypeId === b.contentTypeId && a.itemId === b.itemId; + +export const sameContentLiveDoc = ( + a: ContentLiveDocRef, + b: ContentLiveDocRef, +): boolean => + sameContentLiveRoom(a, b) && a.field === b.field && a.locale === b.locale; + +export const CONTENT_FIELD_LOCK_LEASE_MS = 60_000; +export const CONTENT_FIELD_LOCK_RENEW_MS = 20_000; +export const CONTENT_LIVE_HEARTBEAT_MS = 15_000; +export const CONTENT_LIVE_MEMBER_TIMEOUT_MS = 45_000; +export const CONTENT_DRAFT_AUTOSAVE_MS = 1_500; +export const CONTENT_LIVE_POLL_MS = 10_000; +export const CONTENT_DOCUMENT_PERSIST_MS = 2_000; +export const CONTENT_LIVE_STALE_DAYS = 30; +export const CONTENT_LIVE_SHARED_LOCALE = ""; diff --git a/packages/vitnode/src/content/localization.test-d.ts b/packages/vitnode/src/content/localization.test-d.ts index 575d221952..6a8a16745f 100644 --- a/packages/vitnode/src/content/localization.test-d.ts +++ b/packages/vitnode/src/content/localization.test-d.ts @@ -70,7 +70,7 @@ describe("localization", () => { const explicit = defineContentType({ id: "test.explicit", tableName: "test_explicits", - localization: { enabled: false }, + localization: false, fields: { title: field.text({ required: true }) }, }); @@ -262,7 +262,7 @@ describe("localization", () => { defineContentType({ id: "test.localizedcolumn", tableName: "test_localized_columns", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { title: field.text({ localized: true, required: true }), featured: field.boolean({ defaultValue: false }), @@ -277,7 +277,7 @@ describe("localization", () => { defineContentType({ id: "test.localizedtitle", tableName: "test_localized_titles", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { title: field.text({ localized: true, required: true }), featured: field.boolean({ defaultValue: false }), @@ -292,7 +292,7 @@ describe("localization", () => { defineContentType({ id: "test.badorder", tableName: "test_bad_orders", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { title: field.text({ localized: true, required: true }), featured: field.boolean({ defaultValue: false }), @@ -308,7 +308,7 @@ describe("localization", () => { defineContentType({ id: "test.localizedsearch", tableName: "test_localized_searches", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { title: field.text({ localized: true, required: true }), featured: field.boolean({ defaultValue: false }), @@ -323,7 +323,7 @@ describe("localization", () => { defineContentType({ id: "test.badindex", tableName: "test_bad_indexes", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { title: field.text({ localized: true, required: true }), featured: field.boolean({ defaultValue: false }), diff --git a/packages/vitnode/src/content/localization.test.ts b/packages/vitnode/src/content/localization.test.ts index 89380b0acd..4af72df074 100644 --- a/packages/vitnode/src/content/localization.test.ts +++ b/packages/vitnode/src/content/localization.test.ts @@ -25,7 +25,7 @@ const localized = ( defineContentType({ id: "test.subject", tableName: "test_subjects", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { title: field.text({ localized: true, required: true }), }, @@ -173,7 +173,7 @@ describe("localization validation", () => { tableName: "test_strays", fields: { title: field.text({ localized: true, required: true }) }, }), - ).toThrow(/no `localization: \{ enabled: true, defaultLocale \}` block/); + ).toThrow(/no `localization: \{ defaultLocale \}` block/); }); it("rejects localization with no localized field", () => { @@ -181,7 +181,7 @@ describe("localization validation", () => { defineContentType({ id: "test.empty", tableName: "test_empties", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { featured: field.boolean({ defaultValue: false }) }, }), ).toThrow(/no field is marked `localized: true`/); @@ -192,35 +192,35 @@ describe("localization validation", () => { ["empty", ""], ["whitespace only", " "], ])("rejects a %s default locale", (_label, defaultLocale) => { - expect(() => - localized({ localization: { defaultLocale, enabled: true } }), - ).toThrow(/localization.defaultLocale is required/); + expect(() => localized({ localization: { defaultLocale } })).toThrow( + /localization.defaultLocale is required/, + ); }); it("rejects a padded default locale rather than trimming it", () => { expect(() => - localized({ localization: { defaultLocale: " en ", enabled: true } }), + localized({ localization: { defaultLocale: " en " } }), ).toThrow(/leading or trailing whitespace/); }); it("rejects a default locale that is not shaped like one", () => { expect(() => - localized({ localization: { defaultLocale: "en_US!", enabled: true } }), + localized({ localization: { defaultLocale: "en_US!" } }), ).toThrow(/does not look like a locale code/); }); it("rejects a default locale wider than core_languages.code", () => { expect(() => localized({ - localization: { defaultLocale: "en-".repeat(20), enabled: true }, + localization: { defaultLocale: "en-".repeat(20) }, }), ).toThrow(/longer than 32 characters/); }); it.each(["pt-BR", "zh-Hans", "en"])("accepts the locale %s", locale => { expect( - localized({ localization: { defaultLocale: locale, enabled: true } }) - .localization.defaultLocale, + localized({ localization: { defaultLocale: locale } }).localization + .defaultLocale, ).toBe(locale); }); @@ -237,13 +237,15 @@ describe("localization validation", () => { defineContentType({ id: "test.badkind", tableName: "test_bad_kinds", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { title: field.text({ localized: true, required: true }), other: { ...fieldValue, localized: true } as never, }, }), - ).toThrow(/Only blocks, slug, text, textarea fields and `field.group`/); + ).toThrow( + /Only blocks, richText, slug, text, textarea fields and `field.group`/, + ); }); it("rejects a localized field named after a translation column", () => { @@ -251,7 +253,7 @@ describe("localization validation", () => { defineContentType({ id: "test.collide", tableName: "test_collides", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { itemId: field.text({ localized: true, required: true }), }, @@ -264,7 +266,7 @@ describe("localization validation", () => { defineContentType({ id: "test.sharedsource", tableName: "test_shared_sources", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { name: field.text({ required: true }), heading: field.text({ localized: true, required: true }), @@ -279,7 +281,7 @@ describe("localization validation", () => { defineContentType({ id: "test.localizedsource", tableName: "test_localized_sources", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { title: field.text({ localized: true, required: true }), slug: field.slug({ source: "title" }), @@ -294,7 +296,7 @@ describe("localization validation", () => { defineContentType({ id: "test.nosource", tableName: "test_no_sources", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { title: field.text({ localized: true, required: true }), slug: field.slug({ localized: true, source: "headline" }), @@ -350,7 +352,7 @@ describe("capability combinations", () => { defineContentType({ id: "test.boundary", tableName: "test_boundaries", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { title: field.text({ localized: true, required: true }), slug: field.slug({ localized: true, source: "title" }), @@ -359,7 +361,7 @@ describe("capability combinations", () => { } as never); it("allows localization plus publication from Stage 5B", () => { - const definition = withCapability({ publication: { enabled: true } }); + const definition = withCapability({ publication: true }); expect(definition.publication.enabled).toBe(true); // The translation table gains the pair the base table has, so a translation @@ -370,7 +372,7 @@ describe("capability combinations", () => { }); it("allows localization plus editorial from Stage 5B", () => { - const definition = withCapability({ editorial: { enabled: true } }); + const definition = withCapability({ editorial: true }); expect(definition.editorial.enabled).toBe(true); expect(definition.localization.enabled).toBe(true); @@ -378,8 +380,8 @@ describe("capability combinations", () => { it("allows localization plus publicApi from Stage 5C", () => { const definition = withCapability({ - publication: { enabled: true }, - publicApi: { enabled: true, fields: ["slug"], path: "boundaries" }, + publication: true, + publicApi: { fields: ["slug"], path: "boundaries" }, }); expect(definition.publicApi.enabled).toBe(true); @@ -393,15 +395,13 @@ describe("capability combinations", () => { it("allows localization plus search from Stage 5D", () => { const definition = withCapability({ - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, fields: ["title", "slug"], path: "boundaries", }, search: { contentFields: ["title"], - enabled: true, pathTemplate: "/{locale}/boundaries/{slug}", titleField: "title", }, @@ -413,15 +413,13 @@ describe("capability combinations", () => { const withSearchTemplate = (pathTemplate: string) => withCapability({ - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, fields: ["title", "slug"], path: "boundaries", }, search: { contentFields: ["title"], - enabled: true, pathTemplate, titleField: "title", }, diff --git a/packages/vitnode/src/content/localization.ts b/packages/vitnode/src/content/localization.ts index 52dc16ff0b..0cbd7ae966 100644 --- a/packages/vitnode/src/content/localization.ts +++ b/packages/vitnode/src/content/localization.ts @@ -197,17 +197,17 @@ export const resolveContentLocalization = ({ }: { fields: ContentFieldMap; id: string; - localization: ContentLocalizationConfig | undefined; + localization: ContentLocalizationConfig | false | undefined; publication: boolean; tableName: string; }): ResolvedContentLocalizationConfig => { const { localizedFields } = partitionContentFields(fields); - if (!localization?.enabled) { + if (!localization) { const stray = Object.keys(localizedFields)[0]; if (stray !== undefined) { throw new ContentEngineError( - `Field "${stray}" is \`localized: true\` but the content type has no \`localization: { enabled: true, defaultLocale }\` block, so there is no translation table for it to live in.`, + `Field "${stray}" is \`localized: true\` but the content type has no \`localization: { defaultLocale }\` block, so there is no translation table for it to live in.`, { contentTypeId: id }, ); } diff --git a/packages/vitnode/src/content/public-localization.test-d.ts b/packages/vitnode/src/content/public-localization.test-d.ts index 3149ef1ca0..4273033201 100644 --- a/packages/vitnode/src/content/public-localization.test-d.ts +++ b/packages/vitnode/src/content/public-localization.test-d.ts @@ -46,15 +46,14 @@ describe("definition-time rules", () => { defineContentType({ id: "test.locale-clash", tableName: "test_locale_clash", - localization: { defaultLocale: "en", enabled: true }, - publication: { enabled: true }, + localization: { defaultLocale: "en" }, + publication: true, fields: { title: field.text({ localized: true, required: true }), slug: field.slug({ localized: true, source: "title" }), locale: field.text({ nullable: true }), }, publicApi: { - enabled: true, // The runtime refuses this; the type cannot, because `locale` is a // declared field like any other. The message is what makes it fixable. fields: ["title", "slug", "locale"], diff --git a/packages/vitnode/src/content/public.test.ts b/packages/vitnode/src/content/public.test.ts index ec84ce0370..4c47a934e3 100644 --- a/packages/vitnode/src/content/public.test.ts +++ b/packages/vitnode/src/content/public.test.ts @@ -27,9 +27,8 @@ const define = ({ id: "test.widget", tableName: "test_widgets", fields: fields as typeof baseFields, - ...(publication ? { publication: { enabled: true as const } } : {}), + ...(publication ? { publication: true as const } : {}), publicApi: { - enabled: true, path: "widgets", fields: ["title", "slug"], ...publicApi, @@ -346,8 +345,8 @@ describe("publicApi on a localized content type", () => { defineContentType({ id: "test.public-localized", tableName: "test_public_localized", - localization: { defaultLocale: "en", enabled: true }, - publication: { enabled: true }, + localization: { defaultLocale: "en" }, + publication: true, fields: { title: field.text({ localized: true, required: true }), slug: field.slug({ localized: true, source: "title" }), @@ -358,7 +357,6 @@ describe("publicApi on a localized content type", () => { it("exposes a localized field alongside a shared one", () => { const definition = localized({ - enabled: true, fields: ["title", "slug", "featured"], path: "localized", }); @@ -375,7 +373,6 @@ describe("publicApi on a localized content type", () => { // cursor would mean two positions across a fallback set. expect(() => localized({ - enabled: true, fields: ["title", "slug"], orderableFields: ["title"], path: "localized", @@ -385,7 +382,6 @@ describe("publicApi on a localized content type", () => { it("allows a localized field in `filterableFields`", () => { const definition = localized({ - enabled: true, fields: ["title", "slug"], filterableFields: ["slug"], path: "localized", @@ -399,7 +395,6 @@ describe("publicApi on a localized content type", () => { it("allows a localized field in `searchableFields`", () => { expect( localized({ - enabled: true, fields: ["title", "slug"], path: "localized", searchableFields: ["title"], @@ -412,15 +407,14 @@ describe("publicApi on a localized content type", () => { defineContentType({ id: "test.public-locale-clash", tableName: "test_public_locale_clash", - localization: { defaultLocale: "en", enabled: true }, - publication: { enabled: true }, + localization: { defaultLocale: "en" }, + publication: true, fields: { title: field.text({ localized: true, required: true }), slug: field.slug({ localized: true, source: "title" }), locale: field.text({ nullable: true }), }, publicApi: { - enabled: true, fields: ["title", "slug", "locale"], path: "clash", }, @@ -435,13 +429,12 @@ describe("publicApi on a localized content type", () => { defineContentType({ id: "test.public-locale-plain", tableName: "test_public_locale_plain", - publication: { enabled: true }, + publication: true, fields: { slug: field.slug({}), locale: field.text({ nullable: true }), }, publicApi: { - enabled: true, fields: ["slug", "locale"], path: "plain", }, diff --git a/packages/vitnode/src/content/publication.test-d.ts b/packages/vitnode/src/content/publication.test-d.ts index 871d388f72..8db7afc0d5 100644 --- a/packages/vitnode/src/content/publication.test-d.ts +++ b/packages/vitnode/src/content/publication.test-d.ts @@ -120,7 +120,7 @@ describe("publication", () => { // @ts-expect-error - generated by `publication` status: field.enum({ values: ["a", "b"], defaultValue: "a" }), }, - publication: { enabled: true }, + publication: true, }); }); @@ -143,7 +143,7 @@ describe("publication", () => { id: "test.columns", tableName: "test_columns", fields: { title: field.text({ required: true }) }, - publication: { enabled: true }, + publication: true, admin: { list: { columns: ["status", "title", "publishedAt"], diff --git a/packages/vitnode/src/content/publication.test.ts b/packages/vitnode/src/content/publication.test.ts index d8e1c6af39..1bb803c6f9 100644 --- a/packages/vitnode/src/content/publication.test.ts +++ b/packages/vitnode/src/content/publication.test.ts @@ -48,7 +48,7 @@ describe("publication config", () => { title: field.text({ required: true }), [name]: field.text({ nullable: true }), }, - publication: { enabled: true }, + publication: true, }), ).toThrow(ContentEngineError); }, @@ -61,7 +61,7 @@ describe("publication config", () => { title: field.text({ required: true }), status: field.text({ nullable: true }), }, - publication: { enabled: true }, + publication: true, }), ).toThrow(/generated by `publication`/); }); @@ -84,7 +84,7 @@ describe("publication config", () => { describe("admin config", () => { it("puts the status column first by default", () => { - const definition = define({ publication: { enabled: true } }); + const definition = define({ publication: true }); expect(definition.admin.list.columns).toEqual([ "status", @@ -102,7 +102,7 @@ describe("publication config", () => { admin: { list: { columns: ["publishedAt", "title"] }, }, - publication: { enabled: true }, + publication: true, }); expect(definition.admin.list.columns).toEqual(["publishedAt", "title"]); @@ -123,7 +123,7 @@ describe("publication config", () => { admin: { list: { defaultOrderBy: "publishedAt" }, }, - publication: { enabled: true }, + publication: true, }); expect(definition.admin.list.defaultOrderBy).toBe("publishedAt"); @@ -135,7 +135,7 @@ describe("publication config", () => { admin: { list: { defaultOrderBy: "title" }, }, - publication: { enabled: true }, + publication: true, }), ).toThrow(/admin.list.orderableFields/); }); @@ -143,7 +143,7 @@ describe("publication config", () => { describe("generated indexes", () => { it("adds one composite index serving the published predicate", () => { - const names = define({ publication: { enabled: true } }).indexes.map( + const names = define({ publication: true }).indexes.map( index => index.name, ); @@ -159,7 +159,7 @@ describe("publication config", () => { it("collapses a declared index on the same columns", () => { const indexes = define({ indexes: [{ name: "things_live", on: ["status", "publishedAt"] }], - publication: { enabled: true }, + publication: true, }).indexes.filter(index => index.on.join() === "status,publishedAt"); expect(indexes).toEqual([ @@ -169,7 +169,7 @@ describe("publication config", () => { }); describe("generated schemas", () => { - const definition = define({ publication: { enabled: true } }); + const definition = define({ publication: true }); it("returns the two columns in select", () => { const parsed = definition.schemas.selectObject.parse({ @@ -221,9 +221,13 @@ describe("publication config", () => { describe("orderable columns", () => { it("always allows the generated columns", () => { - expect( - orderableColumns(define({ publication: { enabled: true } })), - ).toEqual(["id", "createdAt", "updatedAt", "status", "publishedAt"]); + expect(orderableColumns(define({ publication: true }))).toEqual([ + "id", + "createdAt", + "updatedAt", + "status", + "publishedAt", + ]); }); it("does not invent them otherwise", () => { @@ -244,7 +248,7 @@ describe("publication config", () => { ); it("adds can_publish for a publication content type", () => { - expect(permissions(define({ publication: { enabled: true } }))).toEqual([ + expect(permissions(define({ publication: true }))).toEqual([ "can_view", "can_create", "can_edit", @@ -265,7 +269,7 @@ describe("publication config", () => { it("depends on can_view, like every other generated permission", () => { const publish = contentPermissionEntries( - define({ publication: { enabled: true } }), + define({ publication: true }), ).find( entry => typeof entry !== "string" && diff --git a/packages/vitnode/src/content/registry.test.ts b/packages/vitnode/src/content/registry.test.ts index 3c35c4f0b9..ffa480bf03 100644 --- a/packages/vitnode/src/content/registry.test.ts +++ b/packages/vitnode/src/content/registry.test.ts @@ -30,13 +30,31 @@ import { // erases the inferred field map down to the bare constraint. Real call sites // keep their concrete map, so this widening only exists for the test helper. const entry = ( - definition: RegisteredContentType["definition"] | ReturnType<typeof widget>, + definition: + | RegisteredContentType["definition"] + | ReturnType<typeof related> + | ReturnType<typeof widget>, pluginId = "@vitnode/example", ): RegisteredContentType => ({ definition: definition as RegisteredContentType["definition"], pluginId, }); +const related = ( + id: string, + tableName: string, + fields: Parameters<typeof defineContentType>[0]["fields"], +) => + defineContentType({ + id, + tableName, + fields, + admin: { + permissionModule: tableName, + form: { fields: Object.keys(fields) }, + }, + }); + const widget = ( overrides: Partial<Parameters<typeof defineContentType>[0]> = {}, ) => @@ -389,8 +407,8 @@ describe("public paths", () => { title: field.text({ required: true }), slug: field.slug({ source: "title" }), }, - publication: { enabled: true }, - publicApi: { enabled: true, path, fields: ["title", "slug"] }, + publication: true, + publicApi: { path, fields: ["title", "slug"] }, admin: { // Distinct, so the permission-module check does not fire first and mask // the one this block is about. @@ -464,21 +482,6 @@ describe("public paths", () => { * to the `UNIQUE (itemId, position)` constraint each of them carries. */ describe("generated database identifiers", () => { - const related = ( - id: string, - tableName: string, - fields: Parameters<typeof defineContentType>[0]["fields"], - ) => - defineContentType({ - id, - tableName, - fields, - admin: { - permissionModule: tableName, - form: { fields: Object.keys(fields) }, - }, - }); - const withRelation = (id: string, tableName: string, field_: string) => related(id, tableName, { title: field.text({ required: true }), @@ -674,13 +677,12 @@ describe("delivery paths", () => { title: field.text({ required: true }), slug: field.slug({ source: "title" }), }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, path, fields: ["id", "title", "slug"], }, - ...(delivery ? { delivery: { enabled: true } } : {}), + ...(delivery ? { delivery: true } : {}), admin: { // Distinct, so the permission-module check does not fire first and mask the // one this block is about. diff --git a/packages/vitnode/src/content/revisions.ts b/packages/vitnode/src/content/revisions.ts index 14f5f4023f..6b0bd508ea 100644 --- a/packages/vitnode/src/content/revisions.ts +++ b/packages/vitnode/src/content/revisions.ts @@ -3,6 +3,7 @@ import type { CONTENT_REVISION_OPERATIONS, CONTENT_TRANSLATION_REVISION_OPERATIONS, } from "./const"; +import type { RichTextDocument } from "./rich-text/document"; export type ContentRevisionOperation = (typeof CONTENT_REVISION_OPERATIONS)[number]; @@ -23,7 +24,8 @@ export type ContentSnapshotValue = | ContentSnapshotScalar | number[] | Record<string, ContentSnapshotScalar> - | Record<string, ContentSnapshotScalar>[]; + | Record<string, ContentSnapshotScalar>[] + | RichTextDocument; export interface ContentRevisionSnapshot { contentTypeId: string; diff --git a/packages/vitnode/src/content/rich-text-field.test.ts b/packages/vitnode/src/content/rich-text-field.test.ts new file mode 100644 index 0000000000..e40a0ae790 --- /dev/null +++ b/packages/vitnode/src/content/rich-text-field.test.ts @@ -0,0 +1,299 @@ +// @vitest-environment node +import { PgDialect } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import type { ContentFormSpec } from "./admin/spec"; +import type { RichTextDocument } from "./rich-text/document"; + +import { testContentLocaleRouting } from "../tests/content-fixtures"; +import { + buildFormSchemaFromSpec, + contentFormInitialValues, + contentFormValuesToTranslations, +} from "./admin/spec"; +import { defineContentType } from "./define"; +import { contentDeliverySeo } from "./delivery"; +import { field } from "./fields"; +import { buildSearchCondition } from "./server/query"; +import { + contentRevisionSnapshot, + contentTranslationRevisionSnapshot, + projectTranslationRevisionSnapshot, +} from "./server/revision-snapshot"; +import { contentSearchDocument } from "./server/search-document"; +import { contentTableColumns, createContentTable } from "./server/table"; + +const paragraph = (text: string): RichTextDocument => ({ + content: [{ content: [{ text, type: "text" }], type: "paragraph" }], + type: "doc", +}); + +const EMPTY: RichTextDocument = { + content: [{ type: "paragraph" }], + type: "doc", +}; + +const noteType = defineContentType({ + id: "test.richtext", + tableName: "test_rich_text_notes", + publication: true, + editorial: true, + localization: { defaultLocale: "en", fallback: "default" }, + fields: { + title: field.text({ localized: true, required: true, maxLength: 200 }), + slug: field.slug({ localized: true, source: "title" }), + body: field.richText({ localized: true, required: true }), + summary: field.richText({ maxBytes: 300, nullable: true }), + }, + publicApi: { + path: "notes", + fields: ["id", "title", "slug", "body", "summary", "publishedAt"], + searchableFields: ["title", "body"], + defaultOrderBy: "publishedAt", + }, + search: { + titleField: "title", + contentFields: ["body"], + pathTemplate: "/notes/{slug}", + }, + delivery: { + seo: { + descriptionField: "summary", + fallbackDescriptionField: "body", + titleField: "title", + }, + }, +}); + +describe("field.richText", () => { + it("refuses a maxBytes that is not a positive whole number", () => { + expect(() => + defineContentType({ + id: "test.badrichtext", + tableName: "test_bad_rich_text", + fields: { body: field.richText({ maxBytes: 0, nullable: true }) }, + }), + ).toThrow(/maxBytes of 0/); + }); + + it("refuses a field that could never be written", () => { + expect(() => + defineContentType({ + id: "test.unwritable", + tableName: "test_unwritable", + fields: { body: field.richText() }, + }), + ).toThrow(/neither required nor nullable/); + }); +}); + +describe("rich text schemas", () => { + const translation = noteType.schemas.translation; + + it("puts a localized document in the translation, not the base row", () => { + expect( + translation?.create.safeParse({ + body: paragraph("Hello"), + title: "Hello", + }).success, + ).toBe(true); + expect( + noteType.schemas.create.safeParse({ body: paragraph("Hello") }).success, + ).toBe(false); + }); + + it("refuses HTML, and an empty document in a required field", () => { + expect( + translation?.create.safeParse({ body: "<p>Hello</p>", title: "Hello" }) + .success, + ).toBe(false); + expect( + translation?.create.safeParse({ body: EMPTY, title: "Hello" }).success, + ).toBe(false); + }); + + it("applies the field's own size limit", () => { + expect( + noteType.schemas.create.safeParse({ summary: paragraph("short") }) + .success, + ).toBe(true); + expect( + noteType.schemas.create.safeParse({ summary: paragraph("a".repeat(400)) }) + .success, + ).toBe(false); + }); + + it("describes the public projection as JSON Schema", () => { + expect(() => + z.toJSONSchema(noteType.schemas.publicSelectObject, { + unrepresentable: "any", + }), + ).not.toThrow(); + }); +}); + +describe("rich text in search, SEO and history", () => { + const routing = testContentLocaleRouting(); + + it("indexes the words of a document, never its JSON", () => { + const indexed = contentSearchDocument( + noteType, + { + body: { + content: [ + { + attrs: { level: 2 }, + content: [{ text: "Why", type: "text" }], + type: "heading", + }, + { + content: [{ text: "We rebuilt it.", type: "text" }], + type: "paragraph", + }, + ], + type: "doc", + }, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + id: 3, + publishedAt: new Date("2026-01-02T00:00:00.000Z"), + slug: "why", + status: "published", + title: "Why", + updatedAt: new Date("2026-01-03T00:00:00.000Z"), + }, + { locale: "en", routing }, + ); + + expect(indexed?.content).toBe("Why We rebuilt it."); + }); + + it("reads an SEO description as plain text, falling back past an empty document", () => { + expect( + contentDeliverySeo(noteType, { + body: paragraph("1 < 2, and that is fine."), + summary: EMPTY, + title: "Maths", + }), + ).toStrictEqual({ + description: "1 < 2, and that is fine.", + title: "Maths", + }); + }); + + it("keeps the whole document in a revision, so a restore puts it back", () => { + const body = paragraph("Version one"); + const snapshot = contentTranslationRevisionSnapshot( + noteType, + { + body, + createdAt: new Date(0), + itemId: 1, + slug: "one", + title: "One", + updatedAt: new Date(0), + version: 2, + }, + { languageId: 1, locale: "en" }, + ); + + expect(snapshot.fields.body).toStrictEqual(body); + expect(projectTranslationRevisionSnapshot(noteType, snapshot).body).toEqual( + body, + ); + expect( + contentRevisionSnapshot(noteType, { + createdAt: new Date(0), + id: 1, + summary: "not a document", + updatedAt: new Date(0), + }).fields.summary, + ).toBeNull(); + }); + + it("searches only the text nodes of a rich text column", () => { + const table = createContentTable(noteType); + const columns = contentTableColumns(noteType, table); + const condition = buildSearchCondition([columns.summary], "rebuilt"); + if (!condition) throw new Error("Expected a condition."); + + expect(new PgDialect().sqlToQuery(condition).sql).toContain( + "jsonb_path_query_array", + ); + }); +}); + +describe("rich text in the AdminCP form", () => { + const spec: ContentFormSpec = { + contentTypeId: noteType.id, + defaultLocale: "en", + fields: [ + { + kind: "richText", + label: "Body", + localized: true, + name: "body", + nullable: false, + required: true, + }, + ], + permissionModule: "notes", + pluginId: "test", + sections: [], + titleField: null, + }; + + it("opens every language on its stored document", () => { + const body = paragraph("Cześć"); + + expect( + contentFormInitialValues(spec, {}, [ + { locale: "pl", values: { body } }, + { locale: "de", values: { body: null } }, + ]), + ).toStrictEqual({ + body: [ + { languageCode: "pl", value: body }, + { languageCode: "de", value: null }, + ], + }); + }); + + it("sends a filled document and leaves an empty editor out", () => { + const body = paragraph("Hello"); + + expect( + contentFormValuesToTranslations(spec, { + body: [ + { languageCode: "en", value: body }, + { languageCode: "pl", value: EMPTY }, + ], + }), + ).toStrictEqual({ en: { body } }); + }); + + it("requires a document in the default language only", () => { + const schema = buildFormSchemaFromSpec(spec); + + expect( + schema.safeParse({ + body: [ + { languageCode: "en", value: paragraph("Hello") }, + { languageCode: "pl", value: null }, + ], + }).success, + ).toBe(true); + expect( + schema.safeParse({ + body: [ + { languageCode: "en", value: EMPTY }, + { languageCode: "pl", value: paragraph("Cześć") }, + ], + }).success, + ).toBe(false); + }); + + it("can be described as JSON Schema, which AutoForm runs", () => { + expect(() => z.toJSONSchema(buildFormSchemaFromSpec(spec))).not.toThrow(); + }); +}); diff --git a/packages/vitnode/src/content/rich-text/document.test.ts b/packages/vitnode/src/content/rich-text/document.test.ts new file mode 100644 index 0000000000..0fb5a9f5af --- /dev/null +++ b/packages/vitnode/src/content/rich-text/document.test.ts @@ -0,0 +1,207 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import type { RichTextDocument, RichTextNode } from "./document"; + +import { + createRichTextDocumentSchema, + isRichTextEmpty, + RICH_TEXT_MAX_DEPTH, + zodRichTextDocument, +} from "./document"; + +const article: RichTextDocument = { + content: [ + { + attrs: { level: 2, textAlign: null }, + content: [{ text: "Why", type: "text" }], + type: "heading", + }, + { + content: [ + { text: "We rebuilt ", type: "text" }, + { + marks: [ + { + attrs: { href: "https://vitnode.com", target: "_blank" }, + type: "link", + }, + ], + text: "the AdminCP", + type: "text", + }, + ], + type: "paragraph", + }, + { + content: [ + { + content: [ + { + attrs: { colspan: 1, colwidth: [120], rowspan: 1 }, + content: [{ type: "paragraph" }], + type: "tableHeader", + }, + ], + type: "tableRow", + }, + ], + type: "table", + }, + ], + type: "doc", +}; + +const nested = (depth: number): RichTextNode => { + let node: RichTextNode = { text: "deep", type: "text" }; + for (let level = 0; level < depth; level += 1) { + node = { content: [node], type: "blockquote" }; + } + + return { content: node.content, type: "doc" }; +}; + +const paragraph = (text: string): RichTextDocument => ({ + content: [{ content: [{ text, type: "text" }], type: "paragraph" }], + type: "doc", +}); + +describe("zodRichTextDocument", () => { + it("accepts what the editor produces", () => { + expect(zodRichTextDocument.parse(article)).toEqual(article); + }); + + it("accepts an empty document", () => { + expect(zodRichTextDocument.safeParse({ type: "doc" }).success).toBe(true); + }); + + it.each([ + ["a string", "<p>Hello</p>"], + ["null", null], + ["an array", []], + ["another root", { content: [], type: "paragraph" }], + ["a node without a type", { content: [{ text: "x" }], type: "doc" }], + [ + "a type with markup in it", + { content: [{ type: "<script>" }], type: "doc" }, + ], + [ + "an unknown key", + { content: [{ onclick: "alert(1)", type: "paragraph" }], type: "doc" }, + ], + [ + "an object attribute", + { attrs: { style: { color: "red" } }, type: "doc" }, + ], + [ + "text that is not a string", + { content: [{ text: 42, type: "text" }], type: "doc" }, + ], + [ + "a mark without a type", + { + content: [{ marks: [{ attrs: {} }], text: "x", type: "text" }], + type: "doc", + }, + ], + ])("refuses %s", (_label, value) => { + expect(zodRichTextDocument.safeParse(value).success).toBe(false); + }); + + it("refuses a document nested past the depth limit", () => { + expect( + zodRichTextDocument.safeParse(nested(RICH_TEXT_MAX_DEPTH - 2)).success, + ).toBe(true); + + const result = zodRichTextDocument.safeParse(nested(RICH_TEXT_MAX_DEPTH)); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toMatch(/deeper than 64/); + }); + + it("measures a thousand levels without walking them recursively", () => { + expect(zodRichTextDocument.safeParse(nested(5000)).success).toBe(false); + }); + + it("refuses a document larger than maxBytes, counted as UTF-8", () => { + const schema = createRichTextDocumentSchema({ maxBytes: 200 }); + + expect(schema.safeParse(paragraph("a".repeat(60))).success).toBe(true); + expect(schema.safeParse(paragraph("😀".repeat(30))).success).toBe(false); + }); + + it("defaults to a 1 MB ceiling", () => { + expect( + zodRichTextDocument.safeParse(paragraph("a".repeat(1024 * 1024))).success, + ).toBe(false); + }); + + it("refuses an empty document when required", () => { + const schema = createRichTextDocumentSchema({ required: true }); + + expect( + schema.safeParse({ content: [{ type: "paragraph" }], type: "doc" }) + .success, + ).toBe(false); + expect(schema.safeParse(paragraph(" ")).success).toBe(false); + expect(schema.safeParse(paragraph("Hello")).success).toBe(true); + }); + + it("can be described as JSON Schema, which AutoForm and OpenAPI need", () => { + expect(() => z.toJSONSchema(zodRichTextDocument)).not.toThrow(); + expect(z.toJSONSchema(zodRichTextDocument, { io: "input" })).toMatchObject({ + properties: { type: { const: "doc" } }, + type: "object", + }); + }); +}); + +describe("isRichTextEmpty", () => { + it.each<[string, null | RichTextDocument]>([ + ["nothing", null], + ["a bare document", { type: "doc" }], + ["an empty paragraph", { content: [{ type: "paragraph" }], type: "doc" }], + ["whitespace", paragraph(" \n ")], + [ + "an empty list and a rule", + { + content: [ + { + content: [{ content: [{ type: "paragraph" }], type: "listItem" }], + type: "bulletList", + }, + { type: "horizontalRule" }, + ], + type: "doc", + }, + ], + ])("is true for %s", (_label, doc) => { + expect(isRichTextEmpty(doc)).toBe(true); + }); + + it.each<[string, RichTextDocument]>([ + ["text", paragraph("Hi")], + [ + "an emoji", + { + content: [ + { + content: [{ attrs: { name: "smile" }, type: "emoji" }], + type: "paragraph", + }, + ], + type: "doc", + }, + ], + [ + "an audio clip", + { + content: [{ attrs: { src: "/a.mp3" }, type: "audio" }], + type: "doc", + }, + ], + ])("is false for %s", (_label, doc) => { + expect(isRichTextEmpty(doc)).toBe(false); + }); +}); diff --git a/packages/vitnode/src/content/rich-text/document.ts b/packages/vitnode/src/content/rich-text/document.ts new file mode 100644 index 0000000000..b793d7a27d --- /dev/null +++ b/packages/vitnode/src/content/rich-text/document.ts @@ -0,0 +1,203 @@ +import { z } from "zod"; + +export type RichTextAttrPrimitive = boolean | null | number | string; + +export type RichTextAttrValue = RichTextAttrPrimitive | RichTextAttrPrimitive[]; + +export type RichTextAttrs = Record<string, RichTextAttrValue>; + +export interface RichTextMark { + attrs?: RichTextAttrs; + type: string; +} + +export interface RichTextNode { + attrs?: RichTextAttrs; + content?: RichTextNode[]; + marks?: RichTextMark[]; + text?: string; + type: string; +} + +export interface RichTextDocument extends RichTextNode { + type: "doc"; +} + +export const RICH_TEXT_MAX_DEPTH = 64; + +export const RICH_TEXT_DEFAULT_MAX_BYTES = 1024 * 1024; + +export const RICH_TEXT_ABSOLUTE_MAX_BYTES = 16 * 1024 * 1024; + +const RICH_TEXT_TYPE_PATTERN = /^[A-Za-z][\w-]{0,63}$/; + +const zodType = z + .string() + .regex( + RICH_TEXT_TYPE_PATTERN, + "A node or mark type is 1-64 letters, digits, hyphens or underscores, starting with a letter.", + ); + +const zodAttrPrimitive = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), +]); + +const zodAttrs = z.record( + z.string().max(64), + z.union([zodAttrPrimitive, z.array(zodAttrPrimitive).max(1000)]), +); + +const zodMark = z.strictObject({ + attrs: zodAttrs.optional(), + type: zodType, +}); + +const zodNode: z.ZodType<RichTextNode> = z.strictObject({ + attrs: zodAttrs.optional(), + get content(): z.ZodOptional<z.ZodArray<z.ZodType<RichTextNode>>> { + return z.array(zodNode).optional(); + }, + marks: z.array(zodMark).optional(), + text: z.string().optional(), + type: zodType, +}); + +const zodDocumentShape = z.strictObject({ + attrs: zodAttrs.optional(), + content: z.array(zodNode).optional(), + type: z.literal("doc"), +}); + +const utf8Length = (value: string): number => + new TextEncoder().encode(value).length; + +export const richTextLimitIssue = ( + value: unknown, + { + maxBytes = RICH_TEXT_DEFAULT_MAX_BYTES, + maxDepth = RICH_TEXT_MAX_DEPTH, + }: { maxBytes?: number; maxDepth?: number } = {}, +): null | string => { + const stack: [unknown, number][] = [[value, 1]]; + + for (let entry = stack.pop(); entry; entry = stack.pop()) { + const [node, depth] = entry; + if (depth > maxDepth) { + return `The document nests deeper than ${maxDepth} levels.`; + } + + if ( + typeof node === "object" && + node !== null && + "content" in node && + Array.isArray(node.content) + ) { + for (const child of node.content) stack.push([child, depth + 1]); + } + } + + let serialized: string | undefined; + try { + serialized = JSON.stringify(value); + } catch { + return "The document is not plain JSON."; + } + + if (serialized !== undefined && utf8Length(serialized) > maxBytes) { + return `The document is larger than ${maxBytes} bytes.`; + } + + return null; +}; + +export interface RichTextSchemaOptions { + maxBytes?: number; + maxDepth?: number; + required?: boolean; +} + +export const createRichTextDocumentSchema = ({ + maxBytes = RICH_TEXT_DEFAULT_MAX_BYTES, + maxDepth = RICH_TEXT_MAX_DEPTH, + required = false, +}: RichTextSchemaOptions = {}): z.ZodType<RichTextDocument> => { + const schema = z + .looseObject({ + content: z.array(z.unknown()).optional(), + type: z.literal("doc"), + }) + .superRefine((value, ctx) => { + const issue = richTextLimitIssue(value, { maxBytes, maxDepth }); + if (issue !== null) ctx.addIssue({ code: "custom", message: issue }); + }) + .pipe(zodDocumentShape); + + return required + ? schema.refine(doc => !isRichTextEmpty(doc), { + message: "The document is empty.", + }) + : schema; +}; + +export const zodRichTextDocument = createRichTextDocumentSchema(); + +export const isRichTextDocument = (value: unknown): value is RichTextDocument => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "type" in value && + value.type === "doc"; + +export const emptyRichTextDocument = (): RichTextDocument => ({ + content: [{ type: "paragraph" }], + type: "doc", +}); + +const STRUCTURAL_TYPES: ReadonlySet<string> = new Set([ + "blockquote", + "bulletList", + "codeBlock", + "doc", + "hardBreak", + "heading", + "horizontalRule", + "listItem", + "orderedList", + "panel", + "paragraph", + "table", + "tableCell", + "tableHeader", + "tableRow", + "taskItem", + "taskList", +]); + +export const isRichTextEmpty = ( + doc: null | RichTextNode | undefined, +): boolean => { + if (!doc) return true; + + const stack: RichTextNode[] = [doc]; + while (stack.length > 0) { + const node = stack.pop(); + if (!node || typeof node !== "object") continue; + + if (node.type === "text") { + if (typeof node.text === "string" && node.text.trim() !== "") { + return false; + } + continue; + } + + const content = Array.isArray(node.content) ? node.content : []; + if (content.length === 0 && !STRUCTURAL_TYPES.has(node.type)) return false; + + stack.push(...content); + } + + return true; +}; diff --git a/packages/vitnode/src/content/rich-text/elements.ts b/packages/vitnode/src/content/rich-text/elements.ts new file mode 100644 index 0000000000..29d5fb5913 --- /dev/null +++ b/packages/vitnode/src/content/rich-text/elements.ts @@ -0,0 +1,400 @@ +import type { RichTextAttrs, RichTextMark, RichTextNode } from "./document"; + +import { richTextToPlainText } from "./plain-text"; + +export const RICH_TEXT_SLOT = 0; + +export type RichTextElementAttrValue = boolean | number | string; + +export interface RichTextElement { + attrs?: Record<string, RichTextElementAttrValue>; + children?: RichTextElementChild[]; + style?: Record<string, string>; + tag: string; +} + +export type RichTextElementChild = + | RichTextElement + | string + | typeof RICH_TEXT_SLOT; + +export const RICH_TEXT_VOID_TAGS: ReadonlySet<string> = new Set([ + "audio", + "br", + "col", + "hr", + "img", + "input", +]); + +const SAFE_PROTOCOLS: ReadonlySet<string> = new Set([ + "http:", + "https:", + "mailto:", + "tel:", +]); + +// eslint-disable-next-line no-control-regex +const IGNORED_IN_SCHEME = /[\u0000- \u007F-\u009F]/g; + +export const sanitizeRichTextHref = (value: unknown): null | string => { + if (typeof value !== "string") return null; + + const href = value.trim(); + if (href === "") return null; + + const compact = href.replace(IGNORED_IN_SCHEME, ""); + const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(compact)?.[1]; + if (scheme === undefined) { + return href; + } + + return SAFE_PROTOCOLS.has(`${scheme.toLowerCase()}:`) ? href : null; +}; + +export const sanitizeRichTextSrc = (value: unknown): null | string => { + const href = sanitizeRichTextHref(value); + if (href === null) return null; + + return /^(?:mailto|tel):/i.test(href) ? null : href; +}; + +const CSS_COLOR = + /^(?:#[0-9a-f]{3,8}|(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\([\d\s.,%/+a-z-]*\)|[a-z]{3,32})$/i; + +const CSS_FONT_SIZE = /^\d{1,3}(?:\.\d{1,3})?(?:px|em|rem|%|pt)$/; + +const TEXT_ALIGNS: ReadonlySet<string> = new Set([ + "center", + "end", + "justify", + "left", + "right", + "start", +]); + +export const sanitizeRichTextColor = (value: unknown): null | string => + typeof value === "string" && CSS_COLOR.test(value.trim()) + ? value.trim() + : null; + +export const sanitizeRichTextFontSize = (value: unknown): null | string => + typeof value === "string" && CSS_FONT_SIZE.test(value.trim()) + ? value.trim() + : null; + +export const sanitizeRichTextAlign = (value: unknown): null | string => + typeof value === "string" && TEXT_ALIGNS.has(value) ? value : null; + +const PANEL_KINDS: ReadonlySet<string> = new Set([ + "error", + "info", + "success", + "warning", +]); + +const CODE_LANGUAGE = /^[a-z0-9][\w#+.-]{0,31}$/i; + +const HEADING_LEVELS: ReadonlySet<number> = new Set([1, 2, 3, 4, 5, 6]); + +const positiveInteger = (value: unknown): null | number => + typeof value === "number" && Number.isInteger(value) && value > 0 + ? value + : null; + +const alignStyle = ( + attrs: RichTextAttrs | undefined, +): Record<string, string> | undefined => { + const align = sanitizeRichTextAlign(attrs?.textAlign); + + return align === null ? undefined : { textAlign: align }; +}; + +const withStyle = ( + element: RichTextElement, + style: Record<string, string> | undefined, +): RichTextElement => (style ? { ...element, style } : element); + +const cellElement = (tag: "td" | "th", node: RichTextNode): RichTextElement => { + const attrs: Record<string, RichTextElementAttrValue> = {}; + const colspan = positiveInteger(node.attrs?.colspan); + const rowspan = positiveInteger(node.attrs?.rowspan); + if (colspan !== null && colspan > 1) attrs.colspan = colspan; + if (rowspan !== null && rowspan > 1) attrs.rowspan = rowspan; + + return { attrs, children: [RICH_TEXT_SLOT], tag }; +}; + +const columnWidths = (table: RichTextNode): (null | number)[] => { + const firstRow = table.content?.[0]; + const widths: (null | number)[] = []; + + for (const cell of firstRow?.content ?? []) { + const colspan = positiveInteger(cell.attrs?.colspan) ?? 1; + const colwidth = Array.isArray(cell.attrs?.colwidth) + ? cell.attrs.colwidth + : []; + + for (let index = 0; index < colspan; index += 1) { + widths.push(positiveInteger(colwidth[index])); + } + } + + return widths; +}; + +const tableElement = (node: RichTextNode): RichTextElement => { + const widths = columnWidths(node); + const sized = widths.some(width => width !== null); + + return { + attrs: { class: "tableWrapper" }, + children: [ + { + children: [ + ...(sized + ? [ + { + children: widths.map((width): RichTextElement => + width === null + ? { tag: "col" } + : { style: { width: `${width}px` }, tag: "col" }, + ), + tag: "colgroup", + }, + ] + : []), + { children: [RICH_TEXT_SLOT], tag: "tbody" }, + ], + tag: "table", + }, + ], + tag: "div", + }; +}; + +const taskItemElement = (node: RichTextNode): RichTextElement => { + const checked = node.attrs?.checked === true; + const label = richTextToPlainText({ content: node.content, type: "doc" }) + .replace(/\s+/g, " ") + .slice(0, 200); + + return { + attrs: { + "data-checked": checked ? "true" : "false", + "data-type": "taskItem", + }, + children: [ + { + children: [ + { + attrs: { + ...(label === "" ? {} : { "aria-label": label }), + checked, + disabled: true, + type: "checkbox", + }, + tag: "input", + }, + { tag: "span" }, + ], + tag: "label", + }, + { children: [RICH_TEXT_SLOT], tag: "div" }, + ], + tag: "li", + }; +}; + +const emojiElement = (node: RichTextNode): RichTextElement => { + const name = typeof node.attrs?.name === "string" ? node.attrs.name : ""; + const glyph = typeof node.attrs?.emoji === "string" ? node.attrs.emoji : ""; + const src = sanitizeRichTextSrc(node.attrs?.src); + const attrs = { + "data-type": "emoji", + ...(name === "" ? {} : { "data-name": name }), + }; + + if (glyph !== "") return { attrs, children: [glyph], tag: "span" }; + + if (src !== null) { + return { + attrs, + children: [ + { + attrs: { alt: `:${name}:`, draggable: "false", loading: "lazy", src }, + tag: "img", + }, + ], + tag: "span", + }; + } + + return { attrs, children: [name === "" ? "" : `:${name}:`], tag: "span" }; +}; + +export const richTextNodeElement = ( + node: RichTextNode, +): null | RichTextElement => { + switch (node.type) { + case "audio": { + const src = sanitizeRichTextSrc(node.attrs?.src); + if (src === null) return null; + + return { + attrs: { + class: "tiptap-audio", + controls: true, + preload: "metadata", + src, + }, + tag: "audio", + }; + } + case "blockquote": + return { children: [RICH_TEXT_SLOT], tag: "blockquote" }; + case "bulletList": + return { + attrs: { class: "list-disc" }, + children: [RICH_TEXT_SLOT], + tag: "ul", + }; + case "codeBlock": { + const language = node.attrs?.language; + + return { + children: [ + { + attrs: + typeof language === "string" && CODE_LANGUAGE.test(language) + ? { class: `language-${language}` } + : {}, + children: [RICH_TEXT_SLOT], + tag: "code", + }, + ], + tag: "pre", + }; + } + case "doc": + return null; + case "emoji": + return emojiElement(node); + case "hardBreak": + return { tag: "br" }; + case "heading": { + const level = node.attrs?.level; + const tag = + typeof level === "number" && HEADING_LEVELS.has(level) + ? `h${level}` + : "h2"; + + return withStyle( + { children: [RICH_TEXT_SLOT], tag }, + alignStyle(node.attrs), + ); + } + case "horizontalRule": + return { tag: "hr" }; + case "listItem": + return { children: [RICH_TEXT_SLOT], tag: "li" }; + case "orderedList": { + const start = positiveInteger(node.attrs?.start); + + return { + attrs: { + class: "list-decimal", + ...(start !== null && start !== 1 ? { start } : {}), + }, + children: [RICH_TEXT_SLOT], + tag: "ol", + }; + } + case "panel": { + const kind = node.attrs?.kind; + + return { + attrs: { + class: "tiptap-panel", + "data-panel": + typeof kind === "string" && PANEL_KINDS.has(kind) ? kind : "info", + }, + children: [RICH_TEXT_SLOT], + tag: "div", + }; + } + case "paragraph": + return withStyle( + { children: [RICH_TEXT_SLOT], tag: "p" }, + alignStyle(node.attrs), + ); + case "table": + return tableElement(node); + case "tableCell": + return cellElement("td", node); + case "tableHeader": + return cellElement("th", node); + case "tableRow": + return { children: [RICH_TEXT_SLOT], tag: "tr" }; + case "taskItem": + return taskItemElement(node); + case "taskList": + return { + attrs: { "data-type": "taskList" }, + children: [RICH_TEXT_SLOT], + tag: "ul", + }; + default: + return null; + } +}; + +const SIMPLE_MARKS: Readonly<Record<string, string>> = { + bold: "strong", + code: "code", + italic: "em", + strike: "s", + subscript: "sub", + superscript: "sup", + underline: "u", +}; + +export const richTextMarkElement = ( + mark: RichTextMark, +): null | RichTextElement => { + const simple = SIMPLE_MARKS[mark.type]; + if (simple !== undefined) return { children: [RICH_TEXT_SLOT], tag: simple }; + + if (mark.type === "link") { + const href = sanitizeRichTextHref(mark.attrs?.href); + if (href === null) return null; + + const target = mark.attrs?.target; + + return { + attrs: + typeof target === "string" && target !== "" + ? { href, rel: "noopener noreferrer nofollow", target: "_blank" } + : { href }, + children: [RICH_TEXT_SLOT], + tag: "a", + }; + } + + if (mark.type === "textStyle") { + const color = sanitizeRichTextColor(mark.attrs?.color); + const fontSize = sanitizeRichTextFontSize(mark.attrs?.fontSize); + if (color === null && fontSize === null) return null; + + return { + children: [RICH_TEXT_SLOT], + style: { + ...(color === null ? {} : { color }), + ...(fontSize === null ? {} : { fontSize }), + }, + tag: "span", + }; + } + + return null; +}; diff --git a/packages/vitnode/src/content/rich-text/html.ts b/packages/vitnode/src/content/rich-text/html.ts new file mode 100644 index 0000000000..84567f92ae --- /dev/null +++ b/packages/vitnode/src/content/rich-text/html.ts @@ -0,0 +1,83 @@ +import type { RichTextNode } from "./document"; + +import { + RICH_TEXT_SLOT, + RICH_TEXT_VOID_TAGS, + type RichTextElement, + type RichTextElementChild, + richTextMarkElement, + richTextNodeElement, +} from "./elements"; + +const HTML_MAX_DEPTH = 256; + +const ESCAPES: Readonly<Record<string, string>> = { + '"': """, + "&": "&", + "'": "'", + "<": "<", + ">": ">", +}; + +export const escapeRichTextHtml = (value: string): string => + value.replace(/["&'<>]/g, char => ESCAPES[char] ?? char); + +const kebab = (name: string): string => + name.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`); + +const openTag = (element: RichTextElement): string => { + const attrs = Object.entries(element.attrs ?? {}) + .filter(([, value]) => value !== false) + .map(([name, value]) => + value === true + ? ` ${name}` + : ` ${name}="${escapeRichTextHtml(String(value))}"`, + ) + .join(""); + const style = Object.entries(element.style ?? {}) + .map(([name, value]) => `${kebab(name)}: ${value}`) + .join("; "); + + return `<${element.tag}${attrs}${style === "" ? "" : ` style="${escapeRichTextHtml(style)}"`}>`; +}; + +const renderElement = (element: RichTextElement, slot: string): string => { + if (RICH_TEXT_VOID_TAGS.has(element.tag)) return openTag(element); + + const inner = (element.children ?? []) + .map((child: RichTextElementChild) => { + if (child === RICH_TEXT_SLOT) return slot; + if (typeof child === "string") return escapeRichTextHtml(child); + + return renderElement(child, slot); + }) + .join(""); + + return `${openTag(element)}${inner}</${element.tag}>`; +}; + +const renderText = (node: RichTextNode): string => { + let html = escapeRichTextHtml(typeof node.text === "string" ? node.text : ""); + + for (const mark of [...(node.marks ?? [])].reverse()) { + const element = richTextMarkElement(mark); + if (element) html = renderElement(element, html); + } + + return html; +}; + +const renderNode = (node: RichTextNode, depth: number): string => { + if (depth > HTML_MAX_DEPTH || typeof node !== "object") return ""; + if (node.type === "text") return renderText(node); + + const inner = (Array.isArray(node.content) ? node.content : []) + .map(child => renderNode(child, depth + 1)) + .join(""); + const element = richTextNodeElement(node); + + return element ? renderElement(element, inner) : inner; +}; + +export const richTextToHtml = (doc: null | RichTextNode | undefined): string => + doc && typeof doc === "object" ? renderNode(doc, 0) : ""; diff --git a/packages/vitnode/src/content/rich-text/index.ts b/packages/vitnode/src/content/rich-text/index.ts new file mode 100644 index 0000000000..d8297583fd --- /dev/null +++ b/packages/vitnode/src/content/rich-text/index.ts @@ -0,0 +1,38 @@ +export { + createRichTextDocumentSchema, + emptyRichTextDocument, + isRichTextDocument, + isRichTextEmpty, + RICH_TEXT_ABSOLUTE_MAX_BYTES, + RICH_TEXT_DEFAULT_MAX_BYTES, + RICH_TEXT_MAX_DEPTH, + richTextLimitIssue, + zodRichTextDocument, +} from "./document"; +export type { + RichTextAttrPrimitive, + RichTextAttrs, + RichTextAttrValue, + RichTextDocument, + RichTextMark, + RichTextNode, + RichTextSchemaOptions, +} from "./document"; +export { + RICH_TEXT_SLOT, + RICH_TEXT_VOID_TAGS, + richTextMarkElement, + richTextNodeElement, + sanitizeRichTextAlign, + sanitizeRichTextColor, + sanitizeRichTextFontSize, + sanitizeRichTextHref, + sanitizeRichTextSrc, +} from "./elements"; +export type { + RichTextElement, + RichTextElementAttrValue, + RichTextElementChild, +} from "./elements"; +export { escapeRichTextHtml, richTextToHtml } from "./html"; +export { richTextToPlainText } from "./plain-text"; diff --git a/packages/vitnode/src/content/rich-text/plain-text.ts b/packages/vitnode/src/content/rich-text/plain-text.ts new file mode 100644 index 0000000000..6a9f1eb656 --- /dev/null +++ b/packages/vitnode/src/content/rich-text/plain-text.ts @@ -0,0 +1,54 @@ +import type { RichTextNode } from "./document"; + +const PLAIN_TEXT_MAX_DEPTH = 256; + +const ROW_TYPES: ReadonlySet<string> = new Set(["tableRow"]); + +const inlineText = (node: RichTextNode): string => { + switch (node.type) { + case "emoji": { + const emoji = node.attrs?.emoji; + const name = node.attrs?.name; + if (typeof emoji === "string" && emoji !== "") return emoji; + + return typeof name === "string" && name !== "" ? `:${name}:` : ""; + } + case "hardBreak": + return "\n"; + case "text": + return typeof node.text === "string" ? node.text : ""; + default: { + const label = node.attrs?.label; + + return typeof label === "string" ? label : ""; + } + } +}; + +const textOf = (node: RichTextNode, depth: number): string => { + if (depth > PLAIN_TEXT_MAX_DEPTH) return ""; + + const content = Array.isArray(node.content) ? node.content : []; + if (content.length === 0) return inlineText(node); + + if (content.some(child => child.type === "text")) { + return content + .map(child => + child.content?.length ? textOf(child, depth + 1) : inlineText(child), + ) + .join(""); + } + + return content + .map(child => textOf(child, depth + 1).trim()) + .filter(text => text !== "") + .join(ROW_TYPES.has(node.type) ? " " : "\n"); +}; + +export const richTextToPlainText = ( + doc: null | RichTextNode | undefined, +): string => { + if (!doc || typeof doc !== "object") return ""; + + return textOf(doc, 0).trim(); +}; diff --git a/packages/vitnode/src/content/rich-text/text.test.ts b/packages/vitnode/src/content/rich-text/text.test.ts new file mode 100644 index 0000000000..a029b05d3b --- /dev/null +++ b/packages/vitnode/src/content/rich-text/text.test.ts @@ -0,0 +1,214 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import type { RichTextDocument, RichTextNode } from "./document"; + +import { sanitizeRichTextHref } from "./elements"; +import { richTextToHtml } from "./html"; +import { richTextToPlainText } from "./plain-text"; + +const text = (value: string, marks?: RichTextNode["marks"]): RichTextNode => + marks ? { marks, text: value, type: "text" } : { text: value, type: "text" }; + +const p = (...content: RichTextNode[]): RichTextNode => ({ + content, + type: "paragraph", +}); + +const doc = (...content: RichTextNode[]): RichTextDocument => ({ + content, + type: "doc", +}); + +describe("richTextToPlainText", () => { + it("puts every block on its own line and runs inline content on", () => { + expect( + richTextToPlainText( + doc( + { + attrs: { level: 2 }, + content: [text("Why")], + type: "heading", + }, + p( + text("We rebuilt "), + text("the AdminCP", [{ type: "bold" }]), + text("."), + ), + p(), + { + content: [ + { content: [p(text("One"))], type: "listItem" }, + { content: [p(text("Two"))], type: "listItem" }, + ], + type: "bulletList", + }, + ), + ), + ).toBe("Why\nWe rebuilt the AdminCP.\nOne\nTwo"); + }); + + it("keeps a hard break and puts table cells side by side", () => { + expect( + richTextToPlainText( + doc(p(text("a"), { type: "hardBreak" }, text("b")), { + content: [ + { + content: [ + { content: [p(text("Name"))], type: "tableHeader" }, + { content: [p(text("Role"))], type: "tableHeader" }, + ], + type: "tableRow", + }, + ], + type: "table", + }), + ), + ).toBe("a\nb\nName Role"); + }); + + it("reads an emoji as its glyph, or its shortcode without one", () => { + expect( + richTextToPlainText( + doc( + p( + text("Hi "), + { attrs: { emoji: "😄", name: "smile" }, type: "emoji" }, + text(" "), + { attrs: { name: "party_parrot" }, type: "emoji" }, + ), + ), + ), + ).toBe("Hi 😄 :party_parrot:"); + }); + + it("is empty for nothing", () => { + expect(richTextToPlainText(null)).toBe(""); + expect(richTextToPlainText({ type: "doc" })).toBe(""); + }); + + it("never returns markup or entities", () => { + expect(richTextToPlainText(doc(p(text("1 < 2 & <b>"))))).toBe( + "1 < 2 & <b>", + ); + }); +}); + +describe("richTextToHtml", () => { + it("escapes text and attribute values", () => { + expect( + richTextToHtml( + doc( + p( + text('<img src=x onerror="alert(1)">', [ + { attrs: { href: 'https://a.test/?q="x"&y' }, type: "link" }, + ]), + ), + ), + ), + ).toBe( + '<p><a href="https://a.test/?q="x"&y"><img src=x onerror="alert(1)"></a></p>', + ); + }); + + it("drops a link to javascript: but keeps its text", () => { + expect( + richTextToHtml( + doc( + p( + text("click", [ + { attrs: { href: "javascript:alert(1)" }, type: "link" }, + ]), + ), + ), + ), + ).toBe("<p>click</p>"); + }); + + it("nests marks with the first one outermost", () => { + expect( + richTextToHtml(doc(p(text("x", [{ type: "bold" }, { type: "italic" }])))), + ).toBe("<p><strong><em>x</em></strong></p>"); + }); + + it("writes the classes and data attributes the editor parses back", () => { + expect( + richTextToHtml( + doc( + { + attrs: { kind: "warning" }, + content: [p(text("Careful"))], + type: "panel", + }, + { + content: [ + { + attrs: { checked: true }, + content: [p(text("Ship it"))], + type: "taskItem", + }, + ], + type: "taskList", + }, + ), + ), + ).toBe( + '<div class="tiptap-panel" data-panel="warning"><p>Careful</p></div>' + + '<ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input aria-label="Ship it" checked disabled type="checkbox"><span></span></label><div><p>Ship it</p></div></li></ul>', + ); + }); + + it("keeps only allowlisted styles", () => { + expect( + richTextToHtml( + doc({ + attrs: { textAlign: "center" }, + content: [ + text("a", [ + { + attrs: { color: "red; background: url(x)", fontSize: "18px" }, + type: "textStyle", + }, + ]), + ], + type: "paragraph", + }), + ), + ).toBe( + '<p style="text-align: center"><span style="font-size: 18px">a</span></p>', + ); + }); + + it("renders the children of a node it does not know", () => { + expect( + richTextToHtml(doc({ content: [p(text("kept"))], type: "spoiler" })), + ).toBe("<p>kept</p>"); + }); +}); + +describe("sanitizeRichTextHref", () => { + it.each([ + "https://vitnode.com", + "http://vitnode.com/a?b=c", + "mailto:hi@vitnode.com", + "tel:+48123", + "/docs", + "docs/page", + "#section", + "//cdn.vitnode.com/a", + ])("keeps %s", href => { + expect(sanitizeRichTextHref(href)).toBe(href); + }); + + it.each([ + "javascript:alert(1)", + "JavaScript:alert(1)", + " java\tscript:alert(1)", + "data:text/html,<script>alert(1)</script>", + "vbscript:msgbox(1)", + "file:///etc/passwd", + "", + ])("refuses %j", href => { + expect(sanitizeRichTextHref(href)).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/content/schemas.test.ts b/packages/vitnode/src/content/schemas.test.ts index c8291c8481..81922178ce 100644 --- a/packages/vitnode/src/content/schemas.test.ts +++ b/packages/vitnode/src/content/schemas.test.ts @@ -146,7 +146,7 @@ describe("generated schemas", () => { id: "test.schema-version", tableName: "test_schema_version", fields: { title: field.text({ required: true }) }, - editorial: { enabled: true }, + editorial: true, }); const row = { diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index ea886c4b47..b2da282c1e 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -44,6 +44,10 @@ import { isContentReferenceCollection, splitContentFieldPath, } from "./paths"; +import { + createRichTextDocumentSchema, + RICH_TEXT_ABSOLUTE_MAX_BYTES, +} from "./rich-text/document"; /** What a content type without `publicApi` carries: nothing exposed at all. */ const DISABLED_PUBLIC_API: ResolvedContentPublicApiConfig = { @@ -230,6 +234,10 @@ const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { }), ); } + case "richText": + return createRichTextDocumentSchema({ + maxBytes: RICH_TEXT_ABSOLUTE_MAX_BYTES, + }); case "slug": // Never empty: the service normalises before writing, and a value that // folds to nothing is rejected rather than stored. @@ -250,6 +258,12 @@ const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { /** The value as it arrives from a client. `dateTime` is an ISO 8601 string. */ const baseInputSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { if (fieldValue.kind === "dateTime") return z.iso.datetime(); + if (fieldValue.kind === "richText") { + return createRichTextDocumentSchema({ + maxBytes: fieldValue.maxBytes, + required: fieldValue.required, + }); + } return baseSelectSchema(fieldValue); }; @@ -274,6 +288,7 @@ const applyPresence = ( fieldValue.kind !== "group" && fieldValue.kind !== "relation" && fieldValue.kind !== "repeatable" && + fieldValue.kind !== "richText" && fieldValue.kind !== "slug" && fieldValue.kind !== "user" && fieldValue.defaultValue !== undefined @@ -431,6 +446,14 @@ const updateShape = ( }), ); +export const contentFieldUpdateSchema = ( + fields: ContentFieldMap, + name: string, +): undefined | z.ZodType => + fields[name] === undefined + ? undefined + : (updateShape(fields, [name])[name] as z.ZodType); + const filterShape = (fields: ContentFieldMap): z.ZodRawShape => Object.fromEntries( Object.entries(fields) diff --git a/packages/vitnode/src/content/search.test-d.ts b/packages/vitnode/src/content/search.test-d.ts index e4205447ae..f63464bfe1 100644 --- a/packages/vitnode/src/content/search.test-d.ts +++ b/packages/vitnode/src/content/search.test-d.ts @@ -31,7 +31,6 @@ const fields = { }; const publicApi = { - enabled: true, fields: [ "title", "slug", @@ -55,11 +54,10 @@ describe("search configuration types", () => { fields, id: "test.valid", publicApi, - publication: { enabled: true }, + publication: true, search: { contentFields: ["excerpt", "body"], descriptionField: "excerpt", - enabled: true, pathTemplate: "/articles/{slug}", titleField: "title", }, @@ -85,8 +83,8 @@ describe("search configuration types", () => { fields, id: "test.off", publicApi, - publication: { enabled: true }, - search: { enabled: false }, + publication: true, + search: false, tableName: "test_off", }); @@ -100,7 +98,7 @@ describe("search configuration types", () => { fields, id: "test.absent", publicApi, - publication: { enabled: true }, + publication: true, tableName: "test_absent", }); @@ -115,10 +113,9 @@ describe("search configuration types", () => { fields, id: "test.nullable.title", publicApi, - publication: { enabled: true }, + publication: true, search: { contentFields: ["excerpt"], - enabled: true, pathTemplate: "/articles/{slug}", // @ts-expect-error - a nullable field can never be a result heading. titleField: "subtitle", @@ -137,7 +134,7 @@ describe("search configuration types", () => { fields, id: "test.widened", publicApi, - publication: { enabled: true }, + publication: true, // @ts-expect-error - `enabled` must stay a literal, or every conditional // in the engine silently resolves to the disabled branch. search: { enabled }, @@ -153,10 +150,9 @@ describe("search configuration types", () => { fields, id: "test.title.kind", publicApi, - publication: { enabled: true }, + publication: true, search: { contentFields: ["excerpt"], - enabled: true, pathTemplate: "/articles/{slug}", // @ts-expect-error - `views` is a number field. titleField: "views", @@ -173,10 +169,9 @@ describe("search configuration types", () => { fields, id: "test.title.textarea", publicApi, - publication: { enabled: true }, + publication: true, search: { contentFields: ["excerpt"], - enabled: true, pathTemplate: "/articles/{slug}", // @ts-expect-error - prose does not belong in the title slot. titleField: "excerpt", @@ -193,12 +188,11 @@ describe("search configuration types", () => { fields, id: "test.desc.kind", publicApi, - publication: { enabled: true }, + publication: true, search: { contentFields: ["excerpt"], // @ts-expect-error - `featured` is a boolean field. descriptionField: "featured", - enabled: true, pathTemplate: "/articles/{slug}", titleField: "title", }, @@ -214,12 +208,11 @@ describe("search configuration types", () => { fields, id: "test.private", publicApi, - publication: { enabled: true }, + publication: true, search: { // @ts-expect-error - `code` is a text field, but it is not in // `publicApi.fields`, so indexing it would leak it. contentFields: ["code"], - enabled: true, pathTemplate: "/articles/{slug}", titleField: "title", }, @@ -235,12 +228,11 @@ describe("search configuration types", () => { fields, id: "test.user", publicApi, - publication: { enabled: true }, + publication: true, search: { // @ts-expect-error - a user field can never be public, so it can never // be indexed either. contentFields: ["author"], - enabled: true, pathTemplate: "/articles/{slug}", titleField: "title", }, @@ -256,11 +248,10 @@ describe("search configuration types", () => { fields, id: "test.author", publicApi, - publication: { enabled: true }, + publication: true, search: { authorField: "author", contentFields: ["body"], - enabled: true, pathTemplate: "/articles/{slug}", titleField: "title", }, @@ -276,12 +267,11 @@ describe("search configuration types", () => { fields, id: "test.author_title", publicApi, - publication: { enabled: true }, + publication: true, search: { // @ts-expect-error - only a user field credits a person. authorField: "title", contentFields: ["body"], - enabled: true, pathTemplate: "/articles/{slug}", titleField: "title", }, @@ -296,12 +286,11 @@ describe("search configuration types", () => { admin, fields, id: "test.nopublic", - publication: { enabled: true }, + publication: true, search: { // @ts-expect-error - with no `publicApi` there is no allowlist, so no // field name is indexable. contentFields: ["excerpt"], - enabled: true, pathTemplate: "/articles/{slug}", // @ts-expect-error - same reason. titleField: "title", @@ -318,11 +307,10 @@ describe("search configuration types", () => { fields, id: "test.empty", publicApi, - publication: { enabled: true }, + publication: true, search: { // @ts-expect-error - the tuple type requires at least one entry. contentFields: [], - enabled: true, pathTemplate: "/articles/{slug}", titleField: "title", }, diff --git a/packages/vitnode/src/content/search.test.ts b/packages/vitnode/src/content/search.test.ts index d8fb67a022..d1d3e530d9 100644 --- a/packages/vitnode/src/content/search.test.ts +++ b/packages/vitnode/src/content/search.test.ts @@ -30,7 +30,6 @@ const admin = { } as const; const publicApi = { - enabled: true, fields: ["title", "slug", "excerpt", "body", "featured", "publishedAt"], path: "articles", } as const; @@ -38,7 +37,6 @@ const publicApi = { const validSearch = { contentFields: ["excerpt", "body"], descriptionField: "excerpt", - enabled: true, pathTemplate: "/articles/{slug}", titleField: "title", } as const; @@ -57,7 +55,7 @@ const define = ({ fields, id: "test.article", ...(withPublicApi ? { publicApi } : {}), - ...(publication ? { publication: { enabled: true as const } } : {}), + ...(publication ? { publication: true as const } : {}), // `never` because the point of every case below is a value the types reject: // a widened TypeScript value or a plain-JavaScript caller. The compile-time // rules are asserted in `search.test-d.ts`. @@ -90,8 +88,8 @@ describe("search configuration", () => { }); }); - it("stays disabled for an explicit `enabled: false`", () => { - expect(define({ search: { enabled: false } }).search.enabled).toBe(false); + it("stays disabled for an explicit `search: false`", () => { + expect(define({ search: false }).search.enabled).toBe(false); }); it("leaves every Stage 2 content type untouched", () => { @@ -172,7 +170,7 @@ describe("search configuration", () => { expect(() => define({ search: { ...validSearch, contentFields: ["featured"] } }), ).toThrow( - /search.contentFields names "featured" of kind "boolean"\. Expected one of: slug, text, textarea\./, + /search.contentFields names "featured" of kind "boolean"\. Expected one of: richText, slug, text, textarea\./, ); }); @@ -285,7 +283,7 @@ describe("search configuration", () => { fields, id: `test.${"a".repeat(100)}`, publicApi, - publication: { enabled: true }, + publication: true, search: validSearch, tableName: "test_long_id", }), diff --git a/packages/vitnode/src/content/server/advanced-projection.test.ts b/packages/vitnode/src/content/server/advanced-projection.test.ts index 34bc173598..704d04cf1b 100644 --- a/packages/vitnode/src/content/server/advanced-projection.test.ts +++ b/packages/vitnode/src/content/server/advanced-projection.test.ts @@ -29,7 +29,7 @@ const articleContentType = defineContentType({ list: { columns: ["title"] }, titleField: "title", }, - editorial: { enabled: true }, + editorial: true, fields: { categories: field.relation({ multiple: true, @@ -57,7 +57,6 @@ const articleContentType = defineContentType({ }, id: "test.proj-article", publicApi: { - enabled: true, fields: [ "title", "slug", @@ -68,10 +67,9 @@ const articleContentType = defineContentType({ ], path: "proj-articles", }, - publication: { enabled: true }, + publication: true, search: { contentFields: ["title", "seo.title", "faq.question"], - enabled: true, pathTemplate: "/proj-articles/{slug}", titleField: "title", }, diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index 2e0af23465..38da0cc93c 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -12,6 +12,7 @@ import { } from "drizzle-orm/pg-core"; import type { AnyBlockInstance } from "../../blocks/types"; +import type { RichTextDocument } from "../rich-text/document"; import type { ContentFieldDescriptor } from "../types"; import { @@ -207,6 +208,11 @@ export const buildContentColumn = ({ return nullable ? column : column.notNull(); } + case "richText": { + const column = jsonb().$type<RichTextDocument>(); + + return nullable ? column : column.notNull(); + } case "slug": // Always NOT NULL and never defaulted: a row nobody can address by URL // is not worth allowing, and there is no sensible default URL. diff --git a/packages/vitnode/src/content/server/delivery-effects.test.ts b/packages/vitnode/src/content/server/delivery-effects.test.ts index 0d4a962469..afd5f02ec5 100644 --- a/packages/vitnode/src/content/server/delivery-effects.test.ts +++ b/packages/vitnode/src/content/server/delivery-effects.test.ts @@ -13,15 +13,14 @@ import { const articleType = defineContentType({ id: "effects.article", - editorial: { enabled: true }, - delivery: { enabled: true, redirects: { enabled: true } }, + editorial: true, + delivery: { redirects: true }, fields: { slug: field.slug({ source: "title" }), title: field.text({ required: true }), }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, fields: ["id", "title", "slug"], path: "articles", }, @@ -34,9 +33,8 @@ const plainType = defineContentType({ slug: field.slug({ source: "title" }), title: field.text({ required: true }), }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, fields: ["id", "title", "slug"], path: "articles", }, diff --git a/packages/vitnode/src/content/server/delivery-service.test.ts b/packages/vitnode/src/content/server/delivery-service.test.ts index 2cb225ee94..c117297732 100644 --- a/packages/vitnode/src/content/server/delivery-service.test.ts +++ b/packages/vitnode/src/content/server/delivery-service.test.ts @@ -15,21 +15,19 @@ const PLUGIN = "@vitnode/test"; const articleType = defineContentType({ id: "delivery.article", - editorial: { enabled: true }, + editorial: true, delivery: { - enabled: true, - redirects: { enabled: true }, + redirects: true, seo: { descriptionField: "excerpt", titleField: "title" }, - sitemap: { enabled: true, priority: 0.7 }, + sitemap: { priority: 0.7 }, }, fields: { excerpt: field.textarea({ nullable: true }), slug: field.slug({ source: "title" }), title: field.text({ required: true }), }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, fields: ["id", "title", "slug", "excerpt"], path: "articles", }, @@ -38,14 +36,13 @@ const articleType = defineContentType({ const withoutRedirects = defineContentType({ id: "delivery.no-redirects", - delivery: { enabled: true, sitemap: { enabled: true } }, + delivery: { sitemap: true }, fields: { slug: field.slug({ source: "title" }), title: field.text({ required: true }), }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, fields: ["id", "title", "slug"], path: "articles", }, @@ -54,13 +51,12 @@ const withoutRedirects = defineContentType({ const localizedType = defineContentType({ id: "delivery.localized", - editorial: { enabled: true }, + editorial: true, delivery: { - enabled: true, hreflang: { xDefault: "defaultLocale" }, - redirects: { enabled: true }, + redirects: true, seo: { fallbackTitleField: "title", titleField: "seo.title" }, - sitemap: { enabled: true }, + sitemap: true, }, fields: { seo: field.group({ @@ -71,10 +67,9 @@ const localizedType = defineContentType({ slug: field.slug({ localized: true, source: "title" }), title: field.text({ localized: true, required: true }), }, - localization: { defaultLocale: "en", enabled: true, fallback: "default" }, - publication: { enabled: true }, + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, publicApi: { - enabled: true, fields: ["id", "title", "slug", "seo.title"], path: "articles", }, @@ -83,15 +78,14 @@ const localizedType = defineContentType({ const strictLocalizedType = defineContentType({ id: "delivery.strict-localized", - delivery: { enabled: true }, + delivery: true, fields: { slug: field.slug({ localized: true, source: "title" }), title: field.text({ localized: true, required: true }), }, - localization: { defaultLocale: "en", enabled: true, fallback: "none" }, - publication: { enabled: true }, + localization: { defaultLocale: "en", fallback: "none" }, + publication: true, publicApi: { - enabled: true, fields: ["id", "title", "slug"], path: "strict-articles", }, @@ -264,8 +258,8 @@ describe("createContentDeliveryService", () => { slug: field.slug({ source: "title" }), title: field.text({ required: true }), }, - publication: { enabled: true }, - publicApi: { enabled: true, fields: ["title", "slug"], path: "p" }, + publication: true, + publicApi: { fields: ["title", "slug"], path: "p" }, tableName: "delivery_none", }); @@ -659,13 +653,13 @@ describe("sitemap", () => { it("is an empty page for a content type that lists nothing", async () => { const noSitemap = defineContentType({ id: "delivery.no-sitemap", - delivery: { enabled: true }, + delivery: true, fields: { slug: field.slug({ source: "title" }), title: field.text({ required: true }), }, - publication: { enabled: true }, - publicApi: { enabled: true, fields: ["id", "title", "slug"], path: "a" }, + publication: true, + publicApi: { fields: ["id", "title", "slug"], path: "a" }, tableName: "delivery_no_sitemap", }); diff --git a/packages/vitnode/src/content/server/delivery-urls.test.ts b/packages/vitnode/src/content/server/delivery-urls.test.ts index 08ecee7ea7..cafd698c31 100644 --- a/packages/vitnode/src/content/server/delivery-urls.test.ts +++ b/packages/vitnode/src/content/server/delivery-urls.test.ts @@ -19,21 +19,20 @@ import { createContentModel } from "./model"; const postType = defineContentType({ id: "deliveryurls.post", - editorial: { enabled: true }, + editorial: true, delivery: { - enabled: true, hreflang: { xDefault: "defaultLocale" }, path: "/blog/:slug", - redirects: { enabled: true }, - sitemap: { enabled: true }, + redirects: true, + sitemap: true, }, fields: { slug: field.slug({ localized: true, source: "title" }), title: field.text({ localized: true, required: true }), }, - localization: { defaultLocale: "en", enabled: true, fallback: "default" }, - publication: { enabled: true }, - publicApi: { enabled: true, fields: ["id", "title", "slug"], path: "posts" }, + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, + publicApi: { fields: ["id", "title", "slug"], path: "posts" }, tableName: "delivery_urls_posts", }); diff --git a/packages/vitnode/src/content/server/delivery-writes.test.ts b/packages/vitnode/src/content/server/delivery-writes.test.ts index 535647b6ec..7cd5432d7a 100644 --- a/packages/vitnode/src/content/server/delivery-writes.test.ts +++ b/packages/vitnode/src/content/server/delivery-writes.test.ts @@ -16,15 +16,14 @@ import { applyContentDeliveryWrite } from "./delivery-writes"; const articleType = defineContentType({ id: "writes.article", - editorial: { enabled: true }, - delivery: { enabled: true, redirects: { enabled: true } }, + editorial: true, + delivery: { redirects: true }, fields: { slug: field.slug({ source: "title" }), title: field.text({ required: true }), }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, fields: ["id", "title", "slug"], path: "articles", }, @@ -33,16 +32,15 @@ const articleType = defineContentType({ const localizedType = defineContentType({ id: "writes.localized", - editorial: { enabled: true }, - delivery: { enabled: true, redirects: { enabled: true } }, + editorial: true, + delivery: { redirects: true }, fields: { slug: field.slug({ localized: true, source: "title" }), title: field.text({ localized: true, required: true }), }, - localization: { defaultLocale: "en", enabled: true }, - publication: { enabled: true }, + localization: { defaultLocale: "en" }, + publication: true, publicApi: { - enabled: true, fields: ["id", "title", "slug"], path: "articles", }, @@ -679,13 +677,13 @@ describe("delivery without redirects", () => { it("reports the paths and writes no history at all", async () => { const withoutRedirects = defineContentType({ id: "writes.no-redirects", - delivery: { enabled: true, sitemap: { enabled: true } }, + delivery: { sitemap: true }, fields: { slug: field.slug({ source: "title" }), title: field.text({ required: true }), }, - publication: { enabled: true }, - publicApi: { enabled: true, fields: ["id", "title", "slug"], path: "a" }, + publication: true, + publicApi: { fields: ["id", "title", "slug"], path: "a" }, tableName: "writes_no_redirects", }); diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index e3c5745e8e..f014c04269 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -248,7 +248,7 @@ export const createContentEditorialService = < }): ContentEditorialService<TDefinition> => { if (!definition.editorial.enabled) { throw new ContentEngineError( - "The editorial service needs `editorial: { enabled: true }` on the content type.", + "The editorial service needs `editorial: true` on the content type.", { contentTypeId: definition.id }, ); } diff --git a/packages/vitnode/src/content/server/file-reference-http-errors.test.ts b/packages/vitnode/src/content/server/file-reference-http-errors.test.ts index 01d6b05817..67174d79f8 100644 --- a/packages/vitnode/src/content/server/file-reference-http-errors.test.ts +++ b/packages/vitnode/src/content/server/file-reference-http-errors.test.ts @@ -24,7 +24,7 @@ import { buildContentRoutes } from "./routes"; const localizedFileContentType = defineContentType({ id: "test.localized-file", tableName: "test_localized_files", - localization: { enabled: true, defaultLocale: "en", fallback: "default" }, + localization: { defaultLocale: "en", fallback: "default" }, fields: { title: field.text({ localized: true, required: true, maxLength: 200 }), slug: field.slug({ localized: true, source: "title" }), diff --git a/packages/vitnode/src/content/server/live-routes.test.ts b/packages/vitnode/src/content/server/live-routes.test.ts new file mode 100644 index 0000000000..8207b1d452 --- /dev/null +++ b/packages/vitnode/src/content/server/live-routes.test.ts @@ -0,0 +1,611 @@ +// @vitest-environment node +import type { Context } from "hono"; +import type { MockInstance } from "vitest"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; + +import { + core_content_drafts, + core_content_field_locks, +} from "@/database/content-drafts"; +import { core_languages } from "@/database/languages"; +import { core_users } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { + testCategoryContentType, + testEditorialNoteContentType, + testEditorialPostContentType, + testLocalizedGuideContentType, +} from "@/tests/content-fixtures"; +import { createMemoryDb } from "@/tests/memory-db"; +import { + grantStaffPermissions, + ROOT_STAFF_PERMISSIONS, +} from "@/tests/staff-permissions"; +import { realtime } from "@/ws/registry"; + +import { CONTENT_PERMISSIONS } from "../const"; +import { CONTENT_FIELD_LOCK_LEASE_MS } from "../live/protocol"; +import { cleanupStaleContentDrafts, commitContentDrafts } from "./live-store"; +import { contentLiveUserLeft, resetContentLiveRecord } from "./live/hooks"; +import { createContentModel } from "./model"; +import { buildContentRoutes } from "./routes"; + +const PLUGIN_ID = "@vitnode/example"; +const guides = createContentModel(testLocalizedGuideContentType); +const notes = createContentModel(testEditorialNoteContentType); + +const ANNA = 1; +const BEN = 2; +const VIEWER = 3; +const ROOM = { contentTypeId: guides.definition.id, itemId: 7 }; + +const users = [ + { id: ANNA, name: "Anna" }, + { id: BEN, name: "Ben" }, + { id: VIEWER, name: "Vic" }, +]; + +const viewOnly = (): PermissionsStaffArgs[] => [ + { + module: guides.definition.permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: PLUGIN_ID, + }, +]; + +const harness = async ({ core }: { core?: Record<string, unknown> } = {}) => { + const memory = createMemoryDb([ + [core_users, users], + [ + core_languages, + [ + { code: "en", default: true, id: 1 }, + { code: "pl", default: false, id: 2 }, + ], + ], + ]); + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: ROOT_STAFF_PERMISSIONS, + userId: ANNA, + }); + await grantStaffPermissions(cache, { + permissions: ROOT_STAFF_PERMISSIONS, + userId: BEN, + }); + await grantStaffPermissions(cache, { + permissions: viewOnly(), + userId: VIEWER, + }); + + vi.spyOn(guides, "service").mockReturnValue({ + findRowById: async (id: number) => + await Promise.resolve(id === 7 ? { id, version: 3 } : null), + } as never); + vi.spyOn(guides, "translationService", "get").mockReturnValue( + () => + ({ + findByLocale: async (_id: number, locale: string) => + await Promise.resolve(locale === "pl" ? { version: 5 } : null), + }) as never, + ); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + const id = Number(c.req.header("x-user") ?? ANNA); + c.set("admin", { + user: users.find(user => user.id === id), + } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", memory.db as unknown as Context["var"]["db"]); + if (core) c.set("core", core as never); + await next(); + }); + for (const { handler, route } of buildContentRoutes(guides, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + const send = async ( + method: string, + path: string, + { body, user = ANNA }: { body?: unknown; user?: number } = {}, + ) => + await app.request(path, { + method, + headers: { + "content-type": "application/json", + "x-user": String(user), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + const lock = async ( + action: "acquire" | "release" | "renew", + { + field = "title", + locale = "pl", + user = ANNA, + }: { field?: string; locale?: null | string; user?: number } = {}, + ) => + await send("POST", "/7/locks", { + body: { action, field, locale }, + user, + }); + + const context = { + get: (key: string) => { + if (key === "db") return memory.db; + if (key === "core") return core; + + return undefined; + }, + } as unknown as Context; + + return { context, lock, memory, send }; +}; + +let toRoom: MockInstance<typeof realtime.toRoom>; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-08T12:00:00Z")); + toRoom = vi.spyOn(realtime, "toRoom").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +const broadcasts = (type: string) => + toRoom.mock.calls + .map(([, , data]) => data as { type: string }) + .filter(message => message.type === type); + +describe("field locks", () => { + it("gives a free field to the first editor and tells the room", async () => { + const { lock } = await harness(); + + const response = await lock("acquire"); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + lock: { field: "title", locale: "pl", user: { id: ANNA, name: "Anna" } }, + }); + expect(toRoom).toHaveBeenCalledWith( + "content:test.localized-guide:7", + expect.anything(), + expect.objectContaining({ + locks: [expect.objectContaining({ field: "title", locale: "pl" })], + room: ROOM, + type: "locks", + }), + ); + }); + + it("refuses a held field with a 409 naming the holder", async () => { + const { lock } = await harness(); + await lock("acquire"); + + const response = await lock("acquire", { user: BEN }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "CONTENT_FIELD_LOCKED", + lock: { user: { id: ANNA, name: "Anna" } }, + }); + }); + + it("locks the same field separately in each language", async () => { + const { lock } = await harness(); + await lock("acquire", { locale: "pl" }); + + const response = await lock("acquire", { locale: "en", user: BEN }); + + expect(response.status).toBe(200); + }); + + it("renews only the holder's lease", async () => { + const { lock } = await harness(); + await lock("acquire"); + vi.setSystemTime(new Date("2026-10-08T12:00:30Z")); + + const renewed = await lock("renew"); + const stolen = await lock("renew", { user: BEN }); + + expect(renewed.status).toBe(200); + const body = (await renewed.json()) as { lock: { expiresAt: string } }; + expect(body.lock.expiresAt).toBe( + new Date( + Date.parse("2026-10-08T12:00:30Z") + CONTENT_FIELD_LOCK_LEASE_MS, + ).toISOString(), + ); + expect(stolen.status).toBe(409); + }); + + it("lets someone else take an expired lease", async () => { + const { lock, send } = await harness(); + await lock("acquire"); + vi.setSystemTime( + new Date( + Date.parse("2026-10-08T12:00:01Z") + CONTENT_FIELD_LOCK_LEASE_MS, + ), + ); + + const response = await lock("acquire", { user: BEN }); + const list = await send("GET", "/7/locks"); + + expect(response.status).toBe(200); + await expect(list.json()).resolves.toMatchObject({ + locks: [{ field: "title", user: { name: "Ben" } }], + }); + }); + + it("lists only unexpired locks", async () => { + const { lock, send } = await harness(); + await lock("acquire"); + vi.setSystemTime( + new Date( + Date.parse("2026-10-08T12:00:01Z") + CONTENT_FIELD_LOCK_LEASE_MS, + ), + ); + + const response = await send("GET", "/7/locks"); + + await expect(response.json()).resolves.toEqual({ locks: [] }); + }); + + it("releases only the holder's lock, idempotently", async () => { + const { lock, send } = await harness(); + await lock("acquire"); + + const byOther = await lock("release", { user: BEN }); + const held = await send("GET", "/7/locks"); + const byHolder = await lock("release"); + const again = await lock("release"); + const after = await send("GET", "/7/locks"); + + expect(byOther.status).toBe(200); + await expect(held.json()).resolves.toMatchObject({ + locks: [{ user: { id: ANNA } }], + }); + expect(byHolder.status).toBe(200); + expect(again.status).toBe(200); + await expect(after.json()).resolves.toEqual({ locks: [] }); + expect(broadcasts("locks")).toHaveLength(2); + }); + + it.each([ + ["a localized field without a locale", { field: "title", locale: null }], + ["a shared field with a locale", { field: "featured", locale: "pl" }], + ["an unknown locale", { field: "title", locale: "xx" }], + ["an unknown field", { field: "nope", locale: null }], + ])("refuses %s", async (_name, target) => { + const { lock } = await harness(); + + const response = await lock("acquire", target); + + expect(response.status).toBe(400); + }); + + it("answers 404 for a record that is not there", async () => { + const { send } = await harness(); + + const response = await send("POST", "/8/locks", { + body: { action: "acquire", field: "featured", locale: null }, + }); + + expect(response.status).toBe(404); + }); + + it.each([ + ["GET", "/7/locks", undefined], + [ + "POST", + "/7/locks", + { action: "acquire", field: "featured", locale: null }, + ], + ["GET", "/7/draft", undefined], + ["PUT", "/7/draft", { locale: null, values: { featured: true } }], + ["POST", "/7/draft/discard", {}], + ])("refuses %s %s without can_edit", async (method, path, body) => { + const { send } = await harness(); + + const response = await send(method, path, { body, user: VIEWER }); + + expect(response.status).toBe(403); + }); +}); + +describe("the shared draft", () => { + it("refuses fields the caller holds no lock on", async () => { + const { send } = await harness(); + + const response = await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "Cześć" } }, + }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + code: "CONTENT_FIELD_NOT_LOCKED", + fields: ["title"], + }); + }); + + it("refuses a lock held in another language", async () => { + const { lock, send } = await harness(); + await lock("acquire", { locale: "en" }); + + const response = await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "Cześć" } }, + }); + + expect(response.status).toBe(409); + }); + + it.each([ + ["a shared field with a locale", "pl", { featured: true }], + ["a localized field without one", null, { title: "Hello" }], + ])("refuses %s", async (_name, locale, values) => { + const { send } = await harness(); + + const response = await send("PUT", "/7/draft", { + body: { locale, values }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + code: "CONTENT_DRAFT_WRONG_SCOPE", + }); + }); + + it("refuses a value the field itself would refuse", async () => { + const { lock, send } = await harness(); + await lock("acquire"); + + const response = await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "x".repeat(201) } }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + code: "CONTENT_DRAFT_INVALID", + fields: ["title"], + }); + }); + + it("merges two editors' fields into one draft and tells the room", async () => { + const { lock, send } = await harness(); + await lock("acquire", { field: "title" }); + await lock("acquire", { field: "summary", user: BEN }); + + const first = await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "Cześć" } }, + }); + const second = await send("PUT", "/7/draft", { + body: { locale: "pl", values: { summary: "Krótko" } }, + user: BEN, + }); + const read = await send("GET", "/7/draft"); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + await expect(read.json()).resolves.toEqual({ + shared: null, + translations: { + pl: { + baseVersion: 5, + updatedAt: "2026-10-08T12:00:00.000Z", + updatedBy: { id: BEN, name: "Ben" }, + values: { summary: "Krótko", title: "Cześć" }, + }, + }, + }); + expect(broadcasts("draft")).toEqual([ + expect.objectContaining({ + by: { id: ANNA, name: "Anna" }, + locale: "pl", + room: ROOM, + values: { title: "Cześć" }, + }), + expect.objectContaining({ + by: { id: BEN, name: "Ben" }, + values: { summary: "Krótko" }, + }), + ]); + }); + + it("keeps the shared draft apart, on the record's version", async () => { + const { lock, send } = await harness(); + await lock("acquire", { field: "featured", locale: null }); + + await send("PUT", "/7/draft", { + body: { locale: null, values: { featured: true } }, + }); + const read = await send("GET", "/7/draft"); + + await expect(read.json()).resolves.toMatchObject({ + shared: { baseVersion: 3, values: { featured: true } }, + translations: {}, + }); + }); +}); + +describe("lifecycle", () => { + it("drops a record's drafts and locks when it is reset", async () => { + const { context, lock, memory, send } = await harness(); + await lock("acquire"); + await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "Cześć" } }, + }); + + await resetContentLiveRecord(context, ROOM, "restored"); + + expect(memory.rows(core_content_drafts)).toEqual([]); + expect(memory.rows(core_content_field_locks)).toEqual([]); + expect(broadcasts("reset")).toEqual([ + { reason: "restored", room: ROOM, type: "reset" }, + ]); + }); + + it("discards the shared draft on request and tells the room", async () => { + const { lock, memory, send } = await harness(); + await lock("acquire"); + await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "Cześć" } }, + }); + + const response = await send("POST", "/7/draft/discard", { body: {} }); + + expect(response.status).toBe(200); + expect(memory.rows(core_content_drafts)).toEqual([]); + expect(broadcasts("reset")).toEqual([ + { reason: "discarded", room: ROOM, type: "reset" }, + ]); + }); + + it("does not discard the draft of a record that does not exist", async () => { + const { send } = await harness(); + + const response = await send("POST", "/404/draft/discard", { body: {} }); + + expect(response.status).toBe(404); + expect(broadcasts("reset")).toEqual([]); + }); + + it("drops the drafts a save committed and tells the room", async () => { + const { context, lock, memory, send } = await harness(); + await lock("acquire"); + await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "Cześć" } }, + }); + + await commitContentDrafts(context, ROOM, { + locales: ["pl"], + savedFrom: new Date(), + }); + + expect(memory.rows(core_content_drafts)).toEqual([]); + expect(broadcasts("committed")).toEqual([ + { room: ROOM, type: "committed" }, + ]); + }); + + it("keeps other languages and drafts written after the save began", async () => { + const { context, lock, memory, send } = await harness(); + await lock("acquire"); + await lock("acquire", { field: "featured", locale: null }); + await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "Cześć" } }, + }); + await send("PUT", "/7/draft", { + body: { locale: null, values: { featured: true } }, + }); + + await commitContentDrafts(context, ROOM, { + locales: ["pl"], + savedFrom: new Date(Date.now() - 1000), + }); + await commitContentDrafts(context, ROOM, { + locales: ["en"], + savedFrom: new Date(), + }); + + expect(memory.rows(core_content_drafts)).toHaveLength(2); + expect(broadcasts("committed")).toEqual([]); + }); + + it("releases the locks of someone who left, and only theirs", async () => { + const { context, lock, send } = await harness(); + await lock("acquire", { field: "title" }); + await lock("acquire", { field: "summary", user: BEN }); + + await contentLiveUserLeft({ c: context, room: ROOM, userId: ANNA }); + const list = await send("GET", "/7/locks"); + + await expect(list.json()).resolves.toMatchObject({ + locks: [{ field: "summary", user: { id: BEN } }], + }); + expect(broadcasts("locks").at(-1)).toMatchObject({ + locks: [{ field: "summary" }], + }); + }); + + it("sweeps expired locks and stale drafts the record moved past", async () => { + const core = { + contentModels: [{ model: guides, pluginId: PLUGIN_ID }], + }; + const { context, lock, memory, send } = await harness({ core }); + await lock("acquire", { field: "featured", locale: null }); + await send("PUT", "/7/draft", { + body: { locale: null, values: { featured: true } }, + }); + await lock("acquire"); + await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "Cześć" } }, + }); + vi.spyOn(guides, "service").mockReturnValue({ + findRowById: async () => await Promise.resolve({ id: 7, version: 4 }), + } as never); + vi.setSystemTime(new Date("2026-11-20T12:00:00Z")); + + const swept = await cleanupStaleContentDrafts(context); + + expect(swept).toEqual({ drafts: 1, locks: 2 }); + expect(memory.rows(core_content_drafts)).toEqual([ + expect.objectContaining({ language: "pl" }), + ]); + }); +}); + +describe("route registration", () => { + it("mounts the live routes for editorial content types only", () => { + const paths = buildContentRoutes( + createContentModel(testCategoryContentType), + { pluginId: PLUGIN_ID }, + ).map(entry => entry.route.path); + + expect(paths).not.toContain("/{id}/locks"); + expect( + buildContentRoutes(notes, { pluginId: PLUGIN_ID }).map( + entry => `${entry.route.method} ${entry.route.path}`, + ), + ).toEqual( + expect.arrayContaining([ + "get /{id}/locks", + "post /{id}/locks", + "get /{id}/draft", + "put /{id}/draft", + ]), + ); + }); +}); + +describe("mounting", () => { + const livePaths = (routes: { route: { method: string; path: string } }[]) => + routes + .map(({ route }) => `${route.method.toUpperCase()} ${route.path}`) + .filter(path => /\/(locks|draft)/.test(path)); + + it("leaves an editorial content type without `liveEditing` alone", () => { + expect( + livePaths( + buildContentRoutes(createContentModel(testEditorialPostContentType), { + pluginId: PLUGIN_ID, + }), + ), + ).toEqual([]); + }); + + it("adds the lock and draft routes once `liveEditing` is on", () => { + expect( + livePaths(buildContentRoutes(notes, { pluginId: PLUGIN_ID })), + ).toEqual(expect.arrayContaining(["GET /{id}/locks", "PUT /{id}/draft"])); + }); +}); diff --git a/packages/vitnode/src/content/server/live-routes.ts b/packages/vitnode/src/content/server/live-routes.ts new file mode 100644 index 0000000000..2cb1d8c47a --- /dev/null +++ b/packages/vitnode/src/content/server/live-routes.ts @@ -0,0 +1,362 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import type { ContentLiveRoomRef } from "../live/protocol"; +import type { AnyContentTypeDefinition, ContentFieldMap } from "../types"; +import type { ContentModel } from "./model"; +import type { ContentDatabase } from "./service"; + +import { buildRoute } from "../../api/lib/route"; +import { contentTypeName } from "../admin/labels"; +import { CONTENT_PERMISSIONS } from "../const"; +import { + CONTENT_LIVE_CODES, + zodContentDraftRejection, + zodContentDrafts, + zodContentDraftWrite, + zodContentDraftWritten, + zodContentFieldLocked, + zodContentFieldLockList, + zodContentFieldLockRequest, + zodContentFieldLockResponse, +} from "../live/http"; +import { partitionContentFields } from "../localization"; +import { contentFieldUpdateSchema } from "../schemas"; +import { findContentLanguage } from "./language-resolver"; +import { + acquireContentFieldLock, + broadcastContentFieldLocks, + findContentFieldLock, + heldContentFieldLocks, + listContentFieldLocks, + readContentDrafts, + releaseContentFieldLock, + renewContentFieldLock, + writeContentDraft, +} from "./live-store"; +import { broadcastContentLive } from "./live/broadcast"; +import { resetContentLiveRecord } from "./live/hooks"; +import { identifier, jsonBody, jsonResponse, readJson } from "./route-helpers"; + +export const buildContentLiveRoutes = < + TDefinition extends AnyContentTypeDefinition, + P extends string, +>( + model: ContentModel<TDefinition>, + { pluginId }: { pluginId: P }, +) => { + const { definition } = model; + const module = definition.permissionModule; + const name = contentTypeName(definition.id); + const permission = { module, permission: CONTENT_PERMISSIONS.edit }; + + const { collectionFields, localizedFields, sharedFields } = + partitionContentFields(definition.fields); + const sharedWritable: ContentFieldMap = { + ...sharedFields, + ...collectionFields, + }; + const localized = definition.localization.enabled; + + const params = z.object({ id: z.coerce.number() }); + const notFound = { description: `${name} not found` }; + + const db = (c: Context): ContentDatabase => c.get("db"); + + const roomOf = (id: number): ContentLiveRoomRef => ({ + contentTypeId: definition.id, + itemId: id, + }); + + const findRecord = async ( + c: Context, + id: number, + ): Promise<Record<string, unknown>> => { + const row = await model.service(c).findRowById(id); + if (!row) { + throw new HTTPException(404, { message: `${name} not found.` }); + } + + return row; + }; + + const editor = (c: Context): { id: number; name: string } => { + const user = c.get("admin")?.user; + if (!user) { + throw new HTTPException(401, { message: "Sign in to the AdminCP." }); + } + + return { id: user.id, name: user.name }; + }; + + const resolveLocale = async (c: Context, locale: string): Promise<string> => { + const language = await findContentLanguage(c, locale); + if (!language?.isEnabled) { + throw new HTTPException(400, { message: "Unknown locale." }); + } + + return language.locale; + }; + + const scopeOf = async ( + c: Context, + field: string, + locale: null | string, + ): Promise<null | string> => { + const isLocalized = localizedFields[field] !== undefined; + if (!isLocalized && sharedWritable[field] === undefined) { + throw new HTTPException(400, { message: "Unknown field." }); + } + if (!isLocalized) { + if (locale !== null) { + throw new HTTPException(400, { + message: "A shared field takes no locale.", + }); + } + + return null; + } + if (locale === null) { + throw new HTTPException(400, { + message: "A localized field needs a locale.", + }); + } + + return await resolveLocale(c, locale); + }; + + const listLocks = buildRoute({ + pluginId, + adminStaffPermission: permission, + route: { + method: "get", + path: "/{id}/locks", + description: `Field locks held on one ${name}`, + request: { params }, + responses: { + 200: jsonResponse(zodContentFieldLockList, "Unexpired locks"), + 400: { description: "Invalid identifier" }, + 404: notFound, + }, + }, + handler: async c => { + const id = identifier(c); + await findRecord(c, id); + + return c.json( + { locks: await listContentFieldLocks(db(c), roomOf(id)) }, + 200, + ); + }, + }); + + const changeLock = buildRoute({ + pluginId, + adminStaffPermission: permission, + route: { + method: "post", + path: "/{id}/locks", + description: `Acquire, renew or release a field lock on one ${name}`, + request: { params, body: jsonBody(zodContentFieldLockRequest) }, + responses: { + 200: jsonResponse(zodContentFieldLockResponse, "The caller's lock"), + 400: { description: "Unknown field, or a locale that does not fit it" }, + 404: notFound, + 409: jsonResponse( + zodContentFieldLocked, + "Someone else holds the lock, or the caller no longer does", + ), + }, + }, + handler: async c => { + const id = identifier(c); + const body = await readJson(c, zodContentFieldLockRequest); + const user = editor(c); + const locale = await scopeOf(c, body.field, body.locale); + await findRecord(c, id); + + const room = roomOf(id); + const key = { ...room, field: body.field, locale }; + + if (body.action === "release") { + if (await releaseContentFieldLock(db(c), key, user.id)) { + await broadcastContentFieldLocks(db(c), room); + } + + return c.json({ lock: null }, 200); + } + + const taken = + body.action === "acquire" + ? await acquireContentFieldLock(db(c), key, user.id) + : await renewContentFieldLock(db(c), key, user.id); + const lock = await findContentFieldLock(db(c), key); + + if (!taken || lock?.user.id !== user.id) { + return c.json({ code: CONTENT_LIVE_CODES.locked, lock }, 409); + } + + await broadcastContentFieldLocks(db(c), room); + + return c.json({ lock }, 200); + }, + }); + + const readDraft = buildRoute({ + pluginId, + adminStaffPermission: permission, + route: { + method: "get", + path: "/{id}/draft", + description: `The shared working copy of one ${name}`, + request: { params }, + responses: { + 200: jsonResponse( + zodContentDrafts, + "The shared and per-language drafts", + ), + 400: { description: "Invalid identifier" }, + 404: notFound, + }, + }, + handler: async c => { + const id = identifier(c); + await findRecord(c, id); + + return c.json(await readContentDrafts(db(c), roomOf(id)), 200); + }, + }); + + const writeDraft = buildRoute({ + pluginId, + adminStaffPermission: permission, + route: { + method: "put", + path: "/{id}/draft", + description: `Autosave fields of one ${name} into its shared draft`, + request: { params, body: jsonBody(zodContentDraftWrite) }, + responses: { + 200: jsonResponse(zodContentDraftWritten, "Draft saved"), + 400: jsonResponse( + zodContentDraftRejection, + "A field outside the locale's scope, or a value the field refuses", + ), + 404: notFound, + 409: jsonResponse( + zodContentDraftRejection, + "The caller holds no lock on some of the fields", + ), + }, + }, + handler: async c => { + const id = identifier(c); + const body = await readJson(c, zodContentDraftWrite); + const user = editor(c); + const names = Object.keys(body.values); + + const scope = body.locale === null ? sharedWritable : localizedFields; + const outOfScope = names.filter(field => scope[field] === undefined); + if (outOfScope.length > 0 || (body.locale !== null && !localized)) { + return c.json( + { + code: CONTENT_LIVE_CODES.draftScope, + fields: outOfScope.length > 0 ? outOfScope : names, + }, + 400, + ); + } + + const locale = + body.locale === null ? null : await resolveLocale(c, body.locale); + const record = await findRecord(c, id); + const room = roomOf(id); + + const held = await heldContentFieldLocks(db(c), { + locale, + names, + room, + userId: user.id, + }); + const unlocked = names.filter(field => !held.has(field)); + if (unlocked.length > 0) { + return c.json( + { code: CONTENT_LIVE_CODES.notLocked, fields: unlocked }, + 409, + ); + } + + const values: Record<string, unknown> = {}; + const invalid: string[] = []; + for (const field of names) { + const parsed = contentFieldUpdateSchema(scope, field)?.safeParse( + body.values[field], + ); + if (parsed?.success) values[field] = parsed.data; + else invalid.push(field); + } + if (invalid.length > 0) { + return c.json( + { code: CONTENT_LIVE_CODES.draftInvalid, fields: invalid }, + 400, + ); + } + + const baseVersion = + locale === null + ? typeof record.version === "number" + ? record.version + : 0 + : ((await model.translationService?.(c).findByLocale(id, locale)) + ?.version ?? 0); + + const updatedAt = await writeContentDraft(db(c), { + baseVersion, + locale, + room, + userId: user.id, + values, + }); + + broadcastContentLive(room, { + by: user, + locale, + room, + type: "draft", + updatedAt: updatedAt.toISOString(), + values, + }); + + return c.json({ updatedAt: updatedAt.toISOString() }, 200); + }, + }); + + const discardDraft = buildRoute({ + pluginId, + adminStaffPermission: permission, + route: { + method: "post", + path: "/{id}/draft/discard", + description: `Throw away the shared draft of one ${name}, so everyone editing it goes back to the saved version`, + request: { params, body: jsonBody(z.object({})) }, + responses: { + 200: jsonResponse( + z.object({ discarded: z.literal(true) }), + "Draft discarded", + ), + 400: { description: "Invalid identifier" }, + 404: notFound, + }, + }, + handler: async c => { + const id = identifier(c); + await findRecord(c, id); + await resetContentLiveRecord(c, roomOf(id), "discarded"); + + return c.json({ discarded: true as const }, 200); + }, + }); + + return [listLocks, changeLock, readDraft, writeDraft, discardDraft]; +}; diff --git a/packages/vitnode/src/content/server/live-store.ts b/packages/vitnode/src/content/server/live-store.ts new file mode 100644 index 0000000000..5f97f1b362 --- /dev/null +++ b/packages/vitnode/src/content/server/live-store.ts @@ -0,0 +1,412 @@ +import type { Context } from "hono"; + +import { and, eq, gt, inArray, lt, lte, or, sql } from "drizzle-orm"; + +import type { ContentDraft, ContentDrafts } from "../live/http"; +import type { ContentFieldLock, ContentLiveRoomRef } from "../live/protocol"; +import type { ContentDatabase } from "./service"; + +import { + core_content_drafts, + core_content_field_locks, +} from "../../database/content-drafts"; +import { core_users } from "../../database/users"; +import { + CONTENT_FIELD_LOCK_LEASE_MS, + CONTENT_LIVE_SHARED_LOCALE, + CONTENT_LIVE_STALE_DAYS, +} from "../live/protocol"; +import { broadcastContentLive } from "./live/broadcast"; +import { onContentLiveReset, onContentLiveUserLeft } from "./live/hooks"; +import { findContentModel } from "./model"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +const locks = core_content_field_locks; +const drafts = core_content_drafts; + +const contentLiveLanguage = (locale: null | string): string => + locale ?? CONTENT_LIVE_SHARED_LOCALE; + +const localeOf = (language: string): null | string => + language === CONTENT_LIVE_SHARED_LOCALE ? null : language; + +const inRoom = ( + table: typeof core_content_drafts | typeof core_content_field_locks, + { contentTypeId, itemId }: ContentLiveRoomRef, +) => and(eq(table.contentTypeId, contentTypeId), eq(table.itemId, itemId)); + +export interface ContentFieldLockKey extends ContentLiveRoomRef { + field: string; + locale: null | string; +} + +const lockKey = (key: ContentFieldLockKey) => + and( + inRoom(locks, key), + eq(locks.field, key.field), + eq(locks.language, contentLiveLanguage(key.locale)), + ); + +const lockSelection = { + expiresAt: locks.expiresAt, + field: locks.field, + language: locks.language, + userId: locks.userId, + userName: core_users.name, +}; + +const toLock = (row: { + expiresAt: Date; + field: string; + language: string; + userId: number; + userName: null | string; +}): ContentFieldLock => ({ + expiresAt: new Date(row.expiresAt).toISOString(), + field: row.field, + locale: localeOf(row.language), + user: { id: row.userId, name: row.userName ?? "" }, +}); + +export const listContentFieldLocks = async ( + db: ContentDatabase, + room: ContentLiveRoomRef, + now = new Date(), +): Promise<ContentFieldLock[]> => { + const rows = await db + .select(lockSelection) + .from(locks) + .leftJoin(core_users, eq(core_users.id, locks.userId)) + .where(and(inRoom(locks, room), gt(locks.expiresAt, now))) + .orderBy(locks.field, locks.language); + + return rows.map(toLock); +}; + +export const findContentFieldLock = async ( + db: ContentDatabase, + key: ContentFieldLockKey, + now = new Date(), +): Promise<ContentFieldLock | null> => { + const [row] = await db + .select(lockSelection) + .from(locks) + .leftJoin(core_users, eq(core_users.id, locks.userId)) + .where(and(lockKey(key), gt(locks.expiresAt, now))) + .limit(1); + + return row ? toLock(row) : null; +}; + +export const acquireContentFieldLock = async ( + db: ContentDatabase, + key: ContentFieldLockKey, + userId: number, + now = new Date(), +): Promise<boolean> => { + const expiresAt = new Date(now.getTime() + CONTENT_FIELD_LOCK_LEASE_MS); + + const rows = await db + .insert(locks) + .values({ + acquiredAt: now, + contentTypeId: key.contentTypeId, + expiresAt, + field: key.field, + itemId: key.itemId, + language: contentLiveLanguage(key.locale), + userId, + }) + .onConflictDoUpdate({ + set: { acquiredAt: now, expiresAt, userId }, + target: [locks.contentTypeId, locks.itemId, locks.field, locks.language], + setWhere: or(lt(locks.expiresAt, now), eq(locks.userId, userId)), + }) + .returning({ id: locks.id }); + + return rows.length > 0; +}; + +export const renewContentFieldLock = async ( + db: ContentDatabase, + key: ContentFieldLockKey, + userId: number, + now = new Date(), +): Promise<boolean> => { + const rows = await db + .update(locks) + .set({ expiresAt: new Date(now.getTime() + CONTENT_FIELD_LOCK_LEASE_MS) }) + .where(and(lockKey(key), eq(locks.userId, userId))) + .returning({ id: locks.id }); + + return rows.length > 0; +}; + +export const releaseContentFieldLock = async ( + db: ContentDatabase, + key: ContentFieldLockKey, + userId: number, +): Promise<boolean> => { + const rows = await db + .delete(locks) + .where(and(lockKey(key), eq(locks.userId, userId))) + .returning({ id: locks.id }); + + return rows.length > 0; +}; + +export const heldContentFieldLocks = async ( + db: ContentDatabase, + { + locale, + names, + room, + userId, + }: { + locale: null | string; + names: readonly string[]; + room: ContentLiveRoomRef; + userId: number; + }, + now = new Date(), +): Promise<Set<string>> => { + if (names.length === 0) return new Set(); + + const rows = await db + .select({ field: locks.field }) + .from(locks) + .where( + and( + inRoom(locks, room), + eq(locks.language, contentLiveLanguage(locale)), + eq(locks.userId, userId), + gt(locks.expiresAt, now), + inArray(locks.field, [...names]), + ), + ); + + return new Set(rows.map(row => row.field)); +}; + +export const broadcastContentFieldLocks = async ( + db: ContentDatabase, + room: ContentLiveRoomRef, +): Promise<ContentFieldLock[]> => { + const list = await listContentFieldLocks(db, room); + broadcastContentLive(room, { locks: list, room, type: "locks" }); + + return list; +}; + +export const readContentDrafts = async ( + db: ContentDatabase, + room: ContentLiveRoomRef, +): Promise<ContentDrafts> => { + const rows = await db + .select({ + baseVersion: drafts.baseVersion, + language: drafts.language, + updatedAt: drafts.updatedAt, + updatedById: drafts.updatedBy, + updatedByName: core_users.name, + values: drafts.values, + }) + .from(drafts) + .leftJoin(core_users, eq(core_users.id, drafts.updatedBy)) + .where(inRoom(drafts, room)); + + const result: ContentDrafts = { shared: null, translations: {} }; + for (const row of rows) { + const draft: ContentDraft = { + baseVersion: row.baseVersion, + updatedAt: new Date(row.updatedAt).toISOString(), + updatedBy: + row.updatedById === null + ? null + : { id: row.updatedById, name: row.updatedByName ?? "" }, + values: row.values, + }; + const locale = localeOf(row.language); + + if (locale === null) result.shared = draft; + else result.translations[locale] = draft; + } + + return result; +}; + +export const writeContentDraft = async ( + db: ContentDatabase, + { + baseVersion, + locale, + room, + userId, + values, + }: { + baseVersion: number; + locale: null | string; + room: ContentLiveRoomRef; + userId: number; + values: Record<string, unknown>; + }, + now = new Date(), +): Promise<Date> => { + await db + .insert(drafts) + .values({ + baseVersion, + contentTypeId: room.contentTypeId, + itemId: room.itemId, + language: contentLiveLanguage(locale), + updatedAt: now, + updatedBy: userId, + values, + }) + .onConflictDoUpdate({ + set: { + baseVersion, + updatedAt: now, + updatedBy: userId, + values: sql`${drafts.values} || ${JSON.stringify(values)}::jsonb`, + }, + target: [drafts.contentTypeId, drafts.itemId, drafts.language], + }); + + return now; +}; + +export const commitContentDrafts = async ( + c: Context, + room: ContentLiveRoomRef, + { + locales, + savedFrom, + }: { locales: readonly (null | string)[]; savedFrom: Date }, +): Promise<void> => { + if (locales.length === 0) return; + + const db: ContentDatabase = c.get("db"); + try { + const committed = await db + .delete(drafts) + .where( + and( + inRoom(drafts, room), + inArray(drafts.language, locales.map(contentLiveLanguage)), + lte(drafts.updatedAt, savedFrom), + ), + ) + .returning({ id: drafts.id }); + + if (committed.length > 0) { + broadcastContentLive(room, { room, type: "committed" }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error("Content draft commit failed:", error); + } +}; + +const deleteContentLiveState = async ( + db: ContentDatabase, + room: ContentLiveRoomRef, +): Promise<void> => { + await db.delete(drafts).where(inRoom(drafts, room)); + await db.delete(locks).where(inRoom(locks, room)); +}; + +const deleteUserContentFieldLocks = async ( + db: ContentDatabase, + room: ContentLiveRoomRef, + userId: number, +): Promise<number> => { + const rows = await db + .delete(locks) + .where(and(inRoom(locks, room), eq(locks.userId, userId))) + .returning({ id: locks.id }); + + return rows.length; +}; + +export const cleanupStaleContentDrafts = async ( + c: Context, + now = new Date(), +): Promise<{ drafts: number; locks: number }> => { + const db: ContentDatabase = c.get("db"); + + const expired = await db + .delete(locks) + .where(lt(locks.expiresAt, now)) + .returning({ id: locks.id }); + + const stale = await db + .select({ + baseVersion: drafts.baseVersion, + contentTypeId: drafts.contentTypeId, + id: drafts.id, + itemId: drafts.itemId, + language: drafts.language, + }) + .from(drafts) + .where( + lt( + drafts.updatedAt, + new Date(now.getTime() - CONTENT_LIVE_STALE_DAYS * DAY_MS), + ), + ); + + const models = c.get("core")?.contentModels ?? []; + const doomed: number[] = []; + + for (const draft of stale) { + const entry = findContentModel(models, draft.contentTypeId); + if (!entry) { + doomed.push(draft.id); + continue; + } + + const locale = localeOf(draft.language); + const record = await entry.model.service(c).findRowById(draft.itemId); + if (!record) { + doomed.push(draft.id); + continue; + } + + if (locale === null) { + const version: unknown = Reflect.get(record, "version"); + if (typeof version === "number" && version > draft.baseVersion) { + doomed.push(draft.id); + } + continue; + } + + const translation = await entry.model + .translationService?.(c) + .findByLocale(draft.itemId, locale); + if ( + translation + ? translation.version > draft.baseVersion + : draft.baseVersion > 0 + ) { + doomed.push(draft.id); + } + } + + if (doomed.length > 0) { + await db.delete(drafts).where(inArray(drafts.id, doomed)); + } + + return { drafts: doomed.length, locks: expired.length }; +}; + +onContentLiveUserLeft(async ({ c, room, userId }) => { + const db: ContentDatabase = c.get("db"); + const removed = await deleteUserContentFieldLocks(db, room, userId); + if (removed > 0) await broadcastContentFieldLocks(db, room); +}); + +onContentLiveReset(async ({ c, room }) => { + await deleteContentLiveState(c.get("db"), room); +}); diff --git a/packages/vitnode/src/content/server/live/auth.test.ts b/packages/vitnode/src/content/server/live/auth.test.ts new file mode 100644 index 0000000000..3256c27649 --- /dev/null +++ b/packages/vitnode/src/content/server/live/auth.test.ts @@ -0,0 +1,296 @@ +// @vitest-environment node +import type { Context, Next } from "hono"; + +import { describe, expect, it } from "vitest"; + +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; +import type { ContentLiveDocRef } from "@/content/live/protocol"; +import type { AnyContentTypeDefinition } from "@/content/types"; + +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { createContentModel } from "@/content/server/model"; +import { core_languages } from "@/database/languages"; +import { + testCategoryContentType, + testEditorialNoteContentType, + testLocalizedGuideContentType, +} from "@/tests/content-fixtures"; +import { + createSessionWorld, + fakeSession, + sessionCookies, +} from "@/tests/sessions"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { + authorizeContentLive, + createContentLiveAuthCache, + isContentLiveDocValid, +} from "./auth"; + +const PLUGIN_ID = "@vitnode/example"; +const notes = createContentModel(testEditorialNoteContentType); +const categories = createContentModel(testCategoryContentType); + +const guideWithRichText = { + ...testLocalizedGuideContentType, + fields: { + ...testLocalizedGuideContentType.fields, + content: { kind: "richText", localized: true, nullable: true }, + intro: { kind: "richText", nullable: true }, + }, +} as unknown as AnyContentTypeDefinition; + +const DEVICE = { id: 3, publicId: "laptop" }; +const editor = fakeSession(7, DEVICE); +const userOnly = fakeSession(8, DEVICE, ["user"]); + +const CAN_EDIT = { + module: testEditorialNoteContentType.permissionModule, + permission: CONTENT_PERMISSIONS.edit, + plugin: PLUGIN_ID, +}; + +const setup = async () => { + const world = await createSessionWorld({ + extraTables: [ + [ + notes.table, + [ + { + body: null, + createdAt: new Date(), + id: 1, + title: "Hello", + updatedAt: new Date(), + version: 4, + }, + ], + ], + [ + core_languages, + [ + { code: "en", default: true, id: 1, name: "English" }, + { code: "pl", default: false, id: 2, name: "Polski" }, + ], + ], + ], + sessions: [editor, userOnly], + }); + + world.app.use("*", async (c: Context, next: Next) => { + c.set("core", { + ...c.get("core"), + contentModels: [ + { model: notes, pluginId: PLUGIN_ID }, + { model: categories, pluginId: PLUGIN_ID }, + ], + contentTypes: [ + { definition: testEditorialNoteContentType, pluginId: PLUGIN_ID }, + { definition: testCategoryContentType, pluginId: PLUGIN_ID }, + { definition: guideWithRichText, pluginId: PLUGIN_ID }, + ], + i18n: { locales: [] }, + } as unknown as EnvVariablesVitNode["core"]); + await next(); + }); + world.app.get("/authorize/:type/:id", async c => + c.json( + await authorizeContentLive(c, { + contentTypeId: c.req.param("type"), + itemId: Number(c.req.param("id")), + }), + ), + ); + world.app.post("/doc", async c => + c.json({ + valid: await isContentLiveDocValid(c, await c.req.json()), + }), + ); + + const authorize = async ( + session: null | ReturnType<typeof fakeSession>, + room = { contentTypeId: "test.note", itemId: 1 }, + ) => { + const response = await world.app.request( + `/authorize/${room.contentTypeId}/${room.itemId}`, + session + ? { headers: { cookie: sessionCookies(session, "admin") } } + : undefined, + ); + + return (await response.json()) as Awaited< + ReturnType<typeof authorizeContentLive> + >; + }; + + const isValid = async (doc: ContentLiveDocRef) => { + const response = await world.app.request("/doc", { + body: JSON.stringify(doc), + method: "POST", + }); + + return ((await response.json()) as { valid: boolean }).valid; + }; + + return { authorize, isValid, world }; +}; + +describe("authorizing a live room", () => { + it("refuses a socket without an AdminCP session", async () => { + const { authorize } = await setup(); + + expect(await authorize(null)).toEqual({ code: "FORBIDDEN", ok: false }); + expect(await authorize(userOnly)).toEqual({ + code: "FORBIDDEN", + ok: false, + }); + }); + + it("refuses an admin without can_edit on the content type", async () => { + const { authorize, world } = await setup(); + await grantStaffPermissions(world.cache, { + permissions: [{ ...CAN_EDIT, permission: CONTENT_PERMISSIONS.view }], + userId: editor.userId, + }); + + expect(await authorize(editor)).toEqual({ code: "FORBIDDEN", ok: false }); + }); + + it("accepts an admin with can_edit, naming the editor and the version", async () => { + const { authorize, world } = await setup(); + await grantStaffPermissions(world.cache, { + permissions: [CAN_EDIT], + userId: editor.userId, + }); + + expect(await authorize(editor)).toMatchObject({ + ok: true, + user: { id: editor.userId, name: `user-${editor.userId}` }, + version: 4, + }); + }); + + it("answers NOT_FOUND for a missing record", async () => { + const { authorize, world } = await setup(); + await grantStaffPermissions(world.cache, { + permissions: [CAN_EDIT], + userId: editor.userId, + }); + + expect( + await authorize(editor, { contentTypeId: "test.note", itemId: 99 }), + ).toEqual({ code: "NOT_FOUND", ok: false }); + }); + + it("has no room for a content type without editorial workflow", async () => { + const { authorize, world } = await setup(); + await grantStaffPermissions(world.cache, { + permissions: [ + { ...CAN_EDIT, module: testCategoryContentType.permissionModule }, + ], + userId: editor.userId, + }); + + expect( + await authorize(editor, { contentTypeId: "test.category", itemId: 1 }), + ).toEqual({ code: "NOT_FOUND", ok: false }); + }); +}); + +describe("the authorization cache", () => { + const room = { contentTypeId: "test.note", itemId: 1 }; + const granted = { + ok: true as const, + user: { avatarColor: null, id: 1, name: "Anna", nameCode: null }, + version: 1, + }; + + it("checks again once a positive answer is older than the TTL", async () => { + let at = 0; + let calls = 0; + const cache = createContentLiveAuthCache({ + authorize: async () => { + calls++; + + return Promise.resolve(granted); + }, + now: () => at, + ttlMs: 60_000, + }); + const socket = {}; + const c = {} as Context; + + await cache.authorize(c, socket, room); + at = 59_000; + await cache.authorize(c, socket, room); + + expect(calls).toBe(1); + + at = 60_000; + await cache.authorize(c, socket, room); + + expect(calls).toBe(2); + }); + + it("never remembers a refusal, and keeps sockets apart", async () => { + const answers = [ + { code: "FORBIDDEN" as const, ok: false as const }, + granted, + granted, + ]; + const cache = createContentLiveAuthCache({ + authorize: async () => Promise.resolve(answers.shift() ?? granted), + }); + const c = {} as Context; + const socket = {}; + + expect((await cache.authorize(c, socket, room)).ok).toBe(false); + expect((await cache.authorize(c, socket, room)).ok).toBe(true); + expect(answers).toHaveLength(1); + + await cache.authorize(c, {}, room); + + expect(answers).toHaveLength(0); + }); +}); + +describe("a collaborative document reference", () => { + const doc = (overrides: Partial<ContentLiveDocRef>): ContentLiveDocRef => ({ + contentTypeId: "test.localized-guide", + field: "content", + itemId: 1, + locale: "en", + ...overrides, + }); + + it("accepts a localized rich text field in an existing language", async () => { + const { isValid } = await setup(); + + expect(await isValid(doc({}))).toBe(true); + expect(await isValid(doc({ locale: "pl" }))).toBe(true); + }); + + it("refuses an unknown or a non-canonical language, or none", async () => { + const { isValid } = await setup(); + + expect(await isValid(doc({ locale: "de" }))).toBe(false); + expect(await isValid(doc({ locale: "EN" }))).toBe(false); + expect(await isValid(doc({ locale: null }))).toBe(false); + }); + + it("wants no language for a shared rich text field", async () => { + const { isValid } = await setup(); + + expect(await isValid(doc({ field: "intro", locale: null }))).toBe(true); + expect(await isValid(doc({ field: "intro", locale: "en" }))).toBe(false); + }); + + it("refuses fields that are not rich text, or do not exist", async () => { + const { isValid } = await setup(); + + expect(await isValid(doc({ field: "body" }))).toBe(false); + expect(await isValid(doc({ field: "constructor" }))).toBe(false); + expect(await isValid(doc({ contentTypeId: "test.note" }))).toBe(false); + }); +}); diff --git a/packages/vitnode/src/content/server/live/auth.ts b/packages/vitnode/src/content/server/live/auth.ts new file mode 100644 index 0000000000..77f2b9c5c6 --- /dev/null +++ b/packages/vitnode/src/content/server/live/auth.ts @@ -0,0 +1,151 @@ +import type { Context } from "hono"; + +import type { + ContentLiveDocRef, + ContentLiveRoomRef, +} from "@/content/live/protocol"; +import type { RegisteredContentType } from "@/content/registry"; + +import { checkStaffPermissionOfUser } from "@/api/lib/check-staff-permission"; +import { SessionAdminModel } from "@/api/models/session-admin"; +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { contentLiveRoom } from "@/content/live/protocol"; +import { findContentLanguage } from "@/content/server/language-resolver"; +import { findContentModel } from "@/content/server/model"; + +const CONTENT_LIVE_AUTH_TTL_MS = 60_000; + +const CONTENT_LIVE_DOCUMENT_FIELD_KIND = "richText"; + +export interface ContentLiveUser { + avatarColor: null | string; + id: number; + name: string; + nameCode: null | string; +} + +export type ContentLiveAuthorization = + | { code: "FORBIDDEN" | "NOT_FOUND"; ok: false } + | { + ok: true; + user: ContentLiveUser; + version: null | number; + }; + +const findLiveContentType = ( + c: Context, + contentTypeId: string, +): RegisteredContentType | undefined => + c + .get("core") + .contentTypes.find( + entry => + entry.definition.id === contentTypeId && + entry.definition.liveEditing.enabled, + ); + +const versionOf = (row: object): null | number => { + const version: unknown = Reflect.get(row, "version"); + + return typeof version === "number" ? version : null; +}; + +export const authorizeContentLive = async ( + c: Context, + room: ContentLiveRoomRef, +): Promise<ContentLiveAuthorization> => { + const entry = findLiveContentType(c, room.contentTypeId); + if (!entry) return { code: "NOT_FOUND", ok: false }; + + const session = await new SessionAdminModel(c).getSession({ extend: false }); + if (!session) return { code: "FORBIDDEN", ok: false }; + + const allowed = await checkStaffPermissionOfUser(c, session.user, { + module: entry.definition.permissionModule, + permission: CONTENT_PERMISSIONS.edit, + plugin: entry.pluginId, + type: "admin", + }); + if (!allowed) return { code: "FORBIDDEN", ok: false }; + + const model = findContentModel( + c.get("core").contentModels, + room.contentTypeId, + ); + const row = model + ? await model.model.service(c).findById(room.itemId) + : undefined; + if (!row) return { code: "NOT_FOUND", ok: false }; + + const { avatarColor, id, name, nameCode } = session.user; + + return { + ok: true, + user: { avatarColor, id, name, nameCode }, + version: versionOf(row), + }; +}; + +export const createContentLiveAuthCache = ({ + authorize = authorizeContentLive, + now = Date.now, + ttlMs = CONTENT_LIVE_AUTH_TTL_MS, +}: { + authorize?: ( + c: Context, + room: ContentLiveRoomRef, + ) => Promise<ContentLiveAuthorization>; + now?: () => number; + ttlMs?: number; +} = {}) => { + type Remembered = Map< + string, + { expiresAt: number; result: ContentLiveAuthorization } + >; + const bySocket = new WeakMap<object, Remembered>(); + + return { + authorize: async ( + c: Context, + socket: object, + room: ContentLiveRoomRef, + ): Promise<ContentLiveAuthorization> => { + const key = contentLiveRoom(room); + const rooms: Remembered = bySocket.get(socket) ?? new Map(); + bySocket.set(socket, rooms); + + const cached = rooms.get(key); + if (cached && cached.expiresAt > now()) return cached.result; + + const result = await authorize(c, room); + if (result.ok) rooms.set(key, { expiresAt: now() + ttlMs, result }); + else rooms.delete(key); + + return result; + }, + forget: (socket: object, room: ContentLiveRoomRef): void => { + bySocket.get(socket)?.delete(contentLiveRoom(room)); + }, + }; +}; + +export const isContentLiveDocValid = async ( + c: Context, + doc: ContentLiveDocRef, +): Promise<boolean> => { + const entry = findLiveContentType(c, doc.contentTypeId); + if (!entry || !Object.hasOwn(entry.definition.fields, doc.field)) { + return false; + } + + const descriptor = entry.definition.fields[doc.field]; + const kind: string = descriptor.kind; + if (kind !== CONTENT_LIVE_DOCUMENT_FIELD_KIND) return false; + + const localized = + entry.definition.localization.enabled && descriptor.localized === true; + if (!localized) return doc.locale === null; + if (doc.locale === null) return false; + + return (await findContentLanguage(c, doc.locale))?.locale === doc.locale; +}; diff --git a/packages/vitnode/src/content/server/live/broadcast.ts b/packages/vitnode/src/content/server/live/broadcast.ts new file mode 100644 index 0000000000..071c149daa --- /dev/null +++ b/packages/vitnode/src/content/server/live/broadcast.ts @@ -0,0 +1,14 @@ +import type { + ContentLiveRoomRef, + ContentLiveServerMessage, +} from "@/content/live/protocol"; + +import { contentLiveChannel, contentLiveRoom } from "@/content/live/protocol"; +import { realtime } from "@/ws/registry"; + +export const broadcastContentLive = ( + room: ContentLiveRoomRef, + message: ContentLiveServerMessage, +): void => { + realtime.toRoom(contentLiveRoom(room), contentLiveChannel, message); +}; diff --git a/packages/vitnode/src/content/server/live/document-store.ts b/packages/vitnode/src/content/server/live/document-store.ts new file mode 100644 index 0000000000..9baf436a3d --- /dev/null +++ b/packages/vitnode/src/content/server/live/document-store.ts @@ -0,0 +1,126 @@ +import type { Context } from "hono"; + +import { and, eq, lt, sql } from "drizzle-orm"; + +import type { ContentLiveDocRef } from "@/content/live/protocol"; + +import { + CONTENT_LIVE_SHARED_LOCALE, + CONTENT_LIVE_STALE_DAYS, +} from "@/content/live/protocol"; +import { core_content_documents } from "@/database/content-documents"; + +import type { ContentDocumentStore } from "./documents"; + +const CONTENT_DOCUMENT_SEED_CLAIM_MS = 30_000; + +const table = core_content_documents; + +const languageOf = (doc: ContentLiveDocRef): string => + doc.locale ?? CONTENT_LIVE_SHARED_LOCALE; + +const whereDoc = (doc: ContentLiveDocRef) => + and( + eq(table.contentTypeId, doc.contentTypeId), + eq(table.itemId, doc.itemId), + eq(table.field, doc.field), + eq(table.language, languageOf(doc)), + ); + +const isUnseeded = sql`octet_length(${table.state}) = 0`; + +const keyOf = (doc: ContentLiveDocRef) => ({ + contentTypeId: doc.contentTypeId, + field: doc.field, + itemId: doc.itemId, + language: languageOf(doc), +}); + +const conflictTarget = [ + table.contentTypeId, + table.itemId, + table.field, + table.language, +]; + +export const createContentDocumentStore = ( + c: Context, +): ContentDocumentStore => ({ + claimSeed: async doc => { + const now = new Date(); + const claimed = await c + .get("db") + .insert(table) + .values({ ...keyOf(doc), state: new Uint8Array(), updatedAt: now }) + .onConflictDoUpdate({ + set: { updatedAt: now }, + setWhere: and( + isUnseeded, + lt( + table.updatedAt, + new Date(now.getTime() - CONTENT_DOCUMENT_SEED_CLAIM_MS), + ), + ), + target: conflictTarget, + }) + .returning({ id: table.id }); + + return claimed.length > 0; + }, + deleteRecord: async room => { + await c + .get("db") + .delete(table) + .where( + and( + eq(table.contentTypeId, room.contentTypeId), + eq(table.itemId, room.itemId), + ), + ); + }, + load: async doc => { + const [row] = await c + .get("db") + .select({ state: table.state }) + .from(table) + .where(whereDoc(doc)) + .limit(1); + + return row && row.state.byteLength > 0 ? row.state : null; + }, + releaseSeed: async doc => { + await c + .get("db") + .delete(table) + .where(and(whereDoc(doc), isUnseeded)); + }, + save: async (doc, state, { baseVersion, userId }) => { + const values = { + baseVersion, + state, + updatedAt: new Date(), + updatedBy: userId, + }; + + await c + .get("db") + .insert(table) + .values({ ...keyOf(doc), ...values }) + .onConflictDoUpdate({ set: values, target: conflictTarget }); + }, +}); + +export const cleanupStaleContentDocuments = async ( + c: Context, +): Promise<number> => { + const before = new Date( + Date.now() - CONTENT_LIVE_STALE_DAYS * 24 * 60 * 60 * 1000, + ); + const removed = await c + .get("db") + .delete(table) + .where(lt(table.updatedAt, before)) + .returning({ id: table.id }); + + return removed.length; +}; diff --git a/packages/vitnode/src/content/server/live/documents.test.ts b/packages/vitnode/src/content/server/live/documents.test.ts new file mode 100644 index 0000000000..6bff2180b2 --- /dev/null +++ b/packages/vitnode/src/content/server/live/documents.test.ts @@ -0,0 +1,441 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + applyAwarenessUpdate, + Awareness, + encodeAwarenessUpdate, +} from "y-protocols/awareness"; +import * as Y from "yjs"; + +import type { ContentLiveDocRef } from "@/content/live/protocol"; + +import { + CONTENT_DOCUMENT_PERSIST_MS, + contentLiveChannel, + contentLiveDocRoom, + contentLiveRoom, +} from "@/content/live/protocol"; +import { + createLiveHarness, + type FakeLiveSocket, + ofType, +} from "@/tests/content-live"; + +import { decodeContentLiveBytes, encodeContentLiveBytes } from "./messages"; + +const room = { contentTypeId: "test.guide", itemId: 1 }; +const doc: ContentLiveDocRef = { ...room, field: "content", locale: "en" }; +const otherLocale: ContentLiveDocRef = { ...doc, locale: "pl" }; + +const harnesses: ReturnType<typeof createLiveHarness>[] = []; + +afterEach(() => { + harnesses.splice(0).forEach(live => live.stop()); + vi.useRealTimers(); +}); + +const setup = () => { + const live = createLiveHarness(); + harnesses.push(live); + + const tab = async ( + user: number, + clientId: string, + socket?: FakeLiveSocket, + ) => { + const target = socket ?? live.socket({ id: user }); + const ydoc = new Y.Doc(); + let seen = 0; + + await live.send(target, { clientId, locale: "en", room, type: "join" }); + + const sync = () => { + const received = target.received(); + for (const message of received.slice(seen)) { + if (message.type !== "doc:update" || message.from === clientId) { + continue; + } + Y.applyUpdate(ydoc, decodeContentLiveBytes(message.update), "server"); + } + seen = received.length; + }; + + return { + clientId, + close: async (ref = doc) => { + await live.send(target, { clientId, doc: ref, type: "doc:close" }); + }, + edit: async (change: (text: Y.Text) => void, ref = doc) => { + sync(); + const before = Y.encodeStateVector(ydoc); + change(ydoc.getText("content")); + await live.send(target, { + clientId, + doc: ref, + type: "doc:update", + update: encodeContentLiveBytes(Y.encodeStateAsUpdate(ydoc, before)), + }); + }, + open: async (ref = doc) => { + await live.send(target, { + clientId, + doc: ref, + stateVector: encodeContentLiveBytes(Y.encodeStateVector(ydoc)), + type: "doc:open", + }); + sync(); + }, + seeds: () => + ofType(target.received(), "doc:seed").filter( + seed => seed.clientId === clientId, + ), + socket: target, + sync, + text: () => { + sync(); + + return ydoc.getText("content").toJSON(); + }, + ydoc, + }; + }; + + const serverText = (ref = doc) => + live.server.documents.loaded(ref)?.getText("content").toJSON(); + + return { live, serverText, tab }; +}; + +describe("opening a document", () => { + it("requires the tab to be in the record's room", async () => { + const { live } = setup(); + const stranger = live.socket({ id: 9 }); + + await live.send(stranger, { + clientId: "x", + doc, + stateVector: "", + type: "doc:open", + }); + + expect(ofType(stranger.received(), "error")).toEqual([ + { clientId: "x", code: "NOT_JOINED", room, type: "error" }, + ]); + }); + + it("refuses a field that is not a collaborative document", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + + await anna.open({ ...doc, field: "title" }); + + expect(ofType(anna.socket.received(), "error").at(-1)?.code).toBe( + "NOT_FOUND", + ); + expect(live.server.documents.loaded({ ...doc, field: "title" })).toBe( + undefined, + ); + }); + + it("answers with what the tab misses and the server's state vector", async () => { + const { tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + await anna.edit(text => text.insert(0, "Hello")); + const ben = await tab(2, "ben-1"); + + await ben.open(); + + expect(ben.text()).toBe("Hello"); + expect(ofType(ben.socket.received(), "doc:state-vector")).toEqual([ + { + clientId: "ben-1", + doc, + stateVector: encodeContentLiveBytes(Y.encodeStateVector(anna.ydoc)), + type: "doc:state-vector", + }, + ]); + }); +}); + +describe("editing together", () => { + it("converges two tabs and the server on the same text", async () => { + const { serverText, tab } = setup(); + const anna = await tab(1, "anna-1"); + const ben = await tab(2, "ben-1"); + await anna.open(); + await ben.open(); + + await anna.edit(text => text.insert(0, "Hello")); + await ben.edit(text => text.insert(text.length, " world")); + await Promise.all([ + anna.edit(text => text.insert(0, "> ")), + ben.edit(text => text.insert(text.length, "!")), + ]); + + expect(anna.text()).toBe("> Hello world!"); + expect(ben.text()).toBe(anna.text()); + expect(serverText()).toBe(anna.text()); + }); + + it("relays updates only to the document's room, marked with the sender", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + const ben = await tab(2, "ben-1"); + const elsewhere = await tab(3, "cleo-1"); + await anna.open(); + await ben.open(); + await elsewhere.open(otherLocale); + const outsider = live.socket({ id: 4 }); + + await anna.edit(text => text.insert(0, "Hi")); + + const relayed = ofType(ben.socket.received(), "doc:update").at(-1); + expect(relayed).toMatchObject({ doc, from: "anna-1" }); + expect( + ofType(elsewhere.socket.received(), "doc:update").filter( + update => update.from === "anna-1", + ), + ).toEqual([]); + expect(outsider.received()).toEqual([]); + }); + + it("rejects an update from a tab that did not open the document", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + + await anna.edit(text => text.insert(0, "Hi")); + + expect(ofType(anna.socket.received(), "error").at(-1)?.code).toBe( + "NOT_JOINED", + ); + expect(live.server.documents.loaded(doc)).toBe(undefined); + }); + + it("applies an update another instance relayed", async () => { + const { live, serverText, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + await anna.edit(text => text.insert(0, "Hello")); + + const remote = new Y.Doc(); + Y.applyUpdate(remote, Y.encodeStateAsUpdate(anna.ydoc)); + const before = Y.encodeStateVector(remote); + remote.getText("content").insert(5, " from afar"); + live.server.onRemoteRoomMessage( + contentLiveDocRoom(doc), + contentLiveChannel.id, + { + doc, + from: "remote-tab", + type: "doc:update", + update: encodeContentLiveBytes(Y.encodeStateAsUpdate(remote, before)), + }, + ); + + expect(serverText()).toBe("Hello from afar"); + }); + + it("gives a newcomer everyone's carets and drops a leaver's", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + const annaAwareness = new Awareness(anna.ydoc); + annaAwareness.setLocalState({ user: { name: "Anna" } }); + await live.send(anna.socket, { + clientId: "anna-1", + doc, + type: "doc:awareness", + update: encodeContentLiveBytes( + encodeAwarenessUpdate(annaAwareness, [anna.ydoc.clientID]), + ), + }); + annaAwareness.destroy(); + const ben = await tab(2, "ben-1"); + + await ben.open(); + + const benAwareness = new Awareness(ben.ydoc); + const apply = () => { + for (const message of ofType(ben.socket.received(), "doc:awareness")) { + applyAwarenessUpdate( + benAwareness, + decodeContentLiveBytes(message.update), + "server", + ); + } + }; + apply(); + + expect(benAwareness.getStates().get(anna.ydoc.clientID)).toEqual({ + user: { name: "Anna" }, + }); + + await anna.close(); + apply(); + + expect(benAwareness.getStates().has(anna.ydoc.clientID)).toBe(false); + benAwareness.destroy(); + }); +}); + +describe("seeding an empty document", () => { + it("asks exactly one tab, the first to open", async () => { + const { tab } = setup(); + const anna = await tab(1, "anna-1"); + const ben = await tab(2, "ben-1"); + + await anna.open(); + await ben.open(); + + expect(anna.seeds()).toEqual([ + { clientId: "anna-1", doc, type: "doc:seed" }, + ]); + expect(ben.seeds()).toEqual([]); + }); + + it("asks the next tab when the seeder leaves before seeding", async () => { + const { tab } = setup(); + const anna = await tab(1, "anna-1"); + const ben = await tab(2, "ben-1"); + await anna.open(); + await ben.open(); + + await anna.close(); + + expect(ben.seeds()).toHaveLength(1); + }); + + it("asks nobody once the document has content", async () => { + const { tab } = setup(); + const anna = await tab(1, "anna-1"); + const ben = await tab(2, "ben-1"); + const cleo = await tab(3, "cleo-1"); + await anna.open(); + await ben.open(); + await anna.edit(text => text.insert(0, "Seeded")); + + await anna.close(); + await cleo.open(); + + expect(ben.seeds()).toEqual([]); + expect(cleo.seeds()).toEqual([]); + }); + + it("asks nobody when the seed is claimed elsewhere", async () => { + const { live, tab } = setup(); + await live.store.store.claimSeed(doc); + const anna = await tab(1, "anna-1"); + + await anna.open(); + + expect(anna.seeds()).toEqual([]); + + await live.store.store.releaseSeed(doc); + await live.server.sweep(); + + expect(anna.seeds()).toHaveLength(1); + }); +}); + +describe("persistence", () => { + it("writes once after a quiet period, not per keystroke", async () => { + vi.useFakeTimers(); + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + + await anna.edit(text => text.insert(0, "H")); + await anna.edit(text => text.insert(1, "i")); + await vi.advanceTimersByTimeAsync(CONTENT_DOCUMENT_PERSIST_MS - 1); + + expect(live.store.saves).toEqual([]); + + await vi.advanceTimersByTimeAsync(1); + + expect(live.store.saves).toEqual([{ doc, userId: 1 }]); + }); + + it("writes when the last tab closes, unloads, and reloads the same text", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + await anna.edit(text => text.insert(0, "Kept")); + + await anna.close(); + + expect(live.store.has(doc)).toBe(true); + expect(live.server.documents.loaded(doc)).toBe(undefined); + + const ben = await tab(2, "ben-1"); + await ben.open(); + + expect(ben.text()).toBe("Kept"); + expect(ben.seeds()).toEqual([]); + }); + + it("stores nothing for a document nobody seeded, and frees its claim", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + + await anna.close(); + + expect(live.store.has(doc)).toBe(false); + expect(live.store.claims.size).toBe(0); + }); + + it("closes a socket's documents when it disconnects", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + await anna.edit(text => text.insert(0, "Saved on close")); + + live.disconnect(anna.socket); + await vi.waitFor(() => { + expect(live.server.documents.loaded(doc)).toBe(undefined); + }); + + expect(live.store.has(doc)).toBe(true); + }); +}); + +describe("resetting a record", () => { + it("deletes and unloads its documents, so tabs must open them again", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + await anna.edit(text => text.insert(0, "Old")); + await anna.close(); + await anna.open(); + + await live.server.onReset({ c: anna.socket.c, room }); + + expect(live.store.has(doc)).toBe(false); + expect(live.server.documents.loaded(doc)).toBe(undefined); + + await anna.edit(text => text.insert(0, "x")); + + expect(ofType(anna.socket.received(), "error").at(-1)?.code).toBe( + "NOT_JOINED", + ); + }); + + it("unloads documents when another instance resets the record", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + + live.server.onRemoteRoomMessage( + contentLiveRoom(room), + contentLiveChannel.id, + { + reason: "restored", + room, + type: "reset", + }, + ); + + expect(live.server.documents.loaded(doc)).toBe(undefined); + }); +}); diff --git a/packages/vitnode/src/content/server/live/documents.ts b/packages/vitnode/src/content/server/live/documents.ts new file mode 100644 index 0000000000..243ab1b4ac --- /dev/null +++ b/packages/vitnode/src/content/server/live/documents.ts @@ -0,0 +1,512 @@ +import { + applyAwarenessUpdate, + Awareness, + encodeAwarenessUpdate, + removeAwarenessStates, +} from "y-protocols/awareness"; +import * as Y from "yjs"; + +import type { + ContentLiveDocRef, + ContentLiveRoomRef, + ContentLiveServerMessage, +} from "@/content/live/protocol"; + +import { + CONTENT_DOCUMENT_PERSIST_MS, + contentLiveDocRoom, + sameContentLiveRoom, +} from "@/content/live/protocol"; + +import { + CONTENT_LIVE_MAX_UPDATE_BYTES, + decodeContentLiveBytes, + encodeContentLiveBytes, +} from "./messages"; + +export interface ContentDocumentStore { + claimSeed: (doc: ContentLiveDocRef) => Promise<boolean>; + deleteRecord: (room: ContentLiveRoomRef) => Promise<void>; + load: (doc: ContentLiveDocRef) => Promise<null | Uint8Array>; + releaseSeed: (doc: ContentLiveDocRef) => Promise<void>; + save: ( + doc: ContentLiveDocRef, + state: Uint8Array, + meta: { baseVersion: null | number; userId: null | number }, + ) => Promise<void>; +} + +export type ContentDocumentResult = "INVALID_MESSAGE" | "NOT_JOINED" | "ok"; + +const REMOTE_ORIGIN = Symbol("content-live-remote"); +const SERVER_ORIGIN = Symbol("content-live-server"); + +interface DocMember<TConnection> { + awarenessIds: Set<number>; + connection: TConnection; +} + +interface LoadedDoc<TConnection> { + awareness: Awareness; + baseVersion: null | number; + claimed: boolean; + dirty: boolean; + doc: Y.Doc; + members: Map<string, DocMember<TConnection>>; + needsSeed: boolean; + persistTimer: ReturnType<typeof setTimeout> | undefined; + ref: ContentLiveDocRef; + room: string; + seeder: null | string; + store: ContentDocumentStore; + updatedBy: null | number; +} + +const isEmptyDoc = (doc: Y.Doc): boolean => doc.store.clients.size === 0; + +const unrefTimer = (timer: unknown): void => { + if ( + typeof timer === "object" && + timer !== null && + "unref" in timer && + typeof timer.unref === "function" + ) { + timer.unref(); + } +}; + +const logError = (message: string, error: unknown): void => { + // eslint-disable-next-line no-console + console.error(message, error); +}; + +const asRelayedUpdate = ( + data: unknown, +): null | { type: string; update: string } => { + if (typeof data !== "object" || data === null) return null; + const type: unknown = Reflect.get(data, "type"); + const update: unknown = Reflect.get(data, "update"); + if (typeof type !== "string" || typeof update !== "string") return null; + + return { type, update }; +}; + +export const createContentDocuments = <TConnection>({ + join, + leave, + relay, + send, +}: { + join: (connection: TConnection, room: string, clientId: string) => void; + leave: (connection: TConnection, room: string, clientId: string) => void; + relay: (room: string, message: ContentLiveServerMessage) => void; + send: (connection: TConnection, message: ContentLiveServerMessage) => void; +}) => { + const docs = new Map<string, LoadedDoc<TConnection>>(); + const loading = new Map<string, Promise<LoadedDoc<TConnection>>>(); + + const trackAwareness = (entry: LoadedDoc<TConnection>): void => { + entry.awareness.on( + "update", + ( + { + added, + removed, + updated, + }: { added: number[]; removed: number[]; updated: number[] }, + origin: unknown, + ) => { + if (typeof origin !== "string") return; + const member = entry.members.get(origin); + if (!member) return; + for (const id of [...added, ...updated]) member.awarenessIds.add(id); + for (const id of removed) member.awarenessIds.delete(id); + }, + ); + }; + + const load = async ( + ref: ContentLiveDocRef, + store: ContentDocumentStore, + baseVersion: null | number, + ): Promise<LoadedDoc<TConnection>> => { + const room = contentLiveDocRoom(ref); + const existing = docs.get(room); + if (existing) return existing; + + const pending = loading.get(room); + if (pending) return await pending; + + const promise = (async () => { + const state = await store.load(ref); + const doc = new Y.Doc({ gc: true }); + if (state && state.byteLength > 0) Y.applyUpdate(doc, state); + + const awareness = new Awareness(doc); + awareness.setLocalState(null); + unrefTimer(awareness._checkInterval); + + const entry: LoadedDoc<TConnection> = { + awareness, + baseVersion, + claimed: false, + dirty: false, + doc, + members: new Map(), + needsSeed: isEmptyDoc(doc), + persistTimer: undefined, + ref, + room, + seeder: null, + store, + updatedBy: null, + }; + trackAwareness(entry); + docs.set(room, entry); + + return entry; + })(); + + loading.set(room, promise); + try { + return await promise; + } finally { + loading.delete(room); + } + }; + + const persist = async (entry: LoadedDoc<TConnection>): Promise<void> => { + clearTimeout(entry.persistTimer); + entry.persistTimer = undefined; + if (!entry.dirty || isEmptyDoc(entry.doc)) return; + + entry.dirty = false; + try { + await entry.store.save(entry.ref, Y.encodeStateAsUpdate(entry.doc), { + baseVersion: entry.baseVersion, + userId: entry.updatedBy, + }); + entry.claimed = false; + } catch (error) { + entry.dirty = true; + logError("Content document persist error:", error); + } + }; + + const schedulePersist = (entry: LoadedDoc<TConnection>): void => { + entry.dirty = true; + clearTimeout(entry.persistTimer); + entry.persistTimer = setTimeout(() => { + void persist(entry); + }, CONTENT_DOCUMENT_PERSIST_MS); + unrefTimer(entry.persistTimer); + }; + + const unload = (entry: LoadedDoc<TConnection>): void => { + clearTimeout(entry.persistTimer); + entry.persistTimer = undefined; + entry.awareness.destroy(); + entry.doc.destroy(); + if (docs.get(entry.room) === entry) docs.delete(entry.room); + }; + + const markSeeded = (entry: LoadedDoc<TConnection>): void => { + if (!entry.needsSeed || isEmptyDoc(entry.doc)) return; + entry.needsSeed = false; + entry.seeder = null; + }; + + const pickSeeder = async (entry: LoadedDoc<TConnection>): Promise<void> => { + if (!entry.needsSeed || entry.seeder !== null) return; + if (entry.members.size === 0) return; + + if (!entry.claimed) { + try { + entry.claimed = await entry.store.claimSeed(entry.ref); + } catch (error) { + logError("Content document seed claim error:", error); + } + } + if (!entry.claimed || !entry.needsSeed || entry.seeder !== null) return; + + const [first] = entry.members; + if (!first) return; + const [clientId, member] = first; + entry.seeder = clientId; + send(member.connection, { clientId, doc: entry.ref, type: "doc:seed" }); + }; + + const memberOf = ( + ref: ContentLiveDocRef, + clientId: string, + connection: TConnection, + ): LoadedDoc<TConnection> | undefined => { + const entry = docs.get(contentLiveDocRoom(ref)); + + return entry?.members.get(clientId)?.connection === connection + ? entry + : undefined; + }; + + const decode = (value: string): null | Uint8Array => { + const bytes = decodeContentLiveBytes(value); + + return bytes.byteLength > CONTENT_LIVE_MAX_UPDATE_BYTES ? null : bytes; + }; + + const closeEntry = async ( + entry: LoadedDoc<TConnection>, + clientId: string, + ): Promise<void> => { + const member = entry.members.get(clientId); + if (!member) return; + + entry.members.delete(clientId); + leave(member.connection, entry.room, clientId); + + const ids = [...member.awarenessIds].filter(id => + entry.awareness.states.has(id), + ); + if (ids.length > 0) { + removeAwarenessStates(entry.awareness, ids, SERVER_ORIGIN); + relay(entry.room, { + doc: entry.ref, + from: null, + type: "doc:awareness", + update: encodeContentLiveBytes( + encodeAwarenessUpdate(entry.awareness, ids), + ), + }); + } + + if (entry.seeder === clientId) { + entry.seeder = null; + await pickSeeder(entry); + } + + if (entry.members.size > 0) return; + + await persist(entry); + if (entry.members.size > 0) return; + + if (entry.claimed && entry.needsSeed) { + entry.claimed = false; + try { + await entry.store.releaseSeed(entry.ref); + } catch (error) { + logError("Content document seed release error:", error); + } + } + if (entry.members.size === 0) unload(entry); + }; + + return { + applyRemote: (room: string, data: unknown): void => { + const entry = docs.get(room); + const message = asRelayedUpdate(data); + if (!entry || !message) return; + + try { + const bytes = decodeContentLiveBytes(message.update); + if (message.type === "doc:update") { + Y.applyUpdate(entry.doc, bytes, REMOTE_ORIGIN); + markSeeded(entry); + schedulePersist(entry); + } else if (message.type === "doc:awareness") { + applyAwarenessUpdate(entry.awareness, bytes, REMOTE_ORIGIN); + } + } catch (error) { + logError("Content document remote update error:", error); + } + }, + awareness: ({ + clientId, + connection, + doc, + update, + }: { + clientId: string; + connection: TConnection; + doc: ContentLiveDocRef; + update: string; + }): ContentDocumentResult => { + const entry = memberOf(doc, clientId, connection); + if (!entry) return "NOT_JOINED"; + const bytes = decode(update); + if (!bytes) return "INVALID_MESSAGE"; + + try { + applyAwarenessUpdate(entry.awareness, bytes, clientId); + } catch { + return "INVALID_MESSAGE"; + } + relay(entry.room, { + doc: entry.ref, + from: clientId, + type: "doc:awareness", + update, + }); + + return "ok"; + }, + close: async ({ + clientId, + connection, + doc, + }: { + clientId: string; + connection: TConnection; + doc: ContentLiveDocRef; + }): Promise<void> => { + const entry = memberOf(doc, clientId, connection); + if (entry) await closeEntry(entry, clientId); + }, + closeClient: async ( + room: ContentLiveRoomRef, + clientId: string, + ): Promise<void> => { + const entries = [...docs.values()].filter( + entry => + sameContentLiveRoom(entry.ref, room) && entry.members.has(clientId), + ); + await Promise.all( + entries.map(async entry => await closeEntry(entry, clientId)), + ); + }, + closeRoom: async ( + room: string, + clientId: string, + connection: TConnection, + ): Promise<void> => { + const entry = docs.get(room); + if (entry?.members.get(clientId)?.connection === connection) { + await closeEntry(entry, clientId); + } + }, + loaded: (doc: ContentLiveDocRef): undefined | Y.Doc => + docs.get(contentLiveDocRoom(doc))?.doc, + open: async ({ + baseVersion, + clientId, + connection, + doc, + stateVector, + store, + userId, + }: { + baseVersion: null | number; + clientId: string; + connection: TConnection; + doc: ContentLiveDocRef; + stateVector: string; + store: ContentDocumentStore; + userId: number; + }): Promise<ContentDocumentResult> => { + const vector = decode(stateVector); + if (!vector) return "INVALID_MESSAGE"; + if (vector.byteLength > 0) { + try { + Y.decodeStateVector(vector); + } catch { + return "INVALID_MESSAGE"; + } + } + + const entry = await load(doc, store, baseVersion); + const missing = Y.encodeStateAsUpdate( + entry.doc, + vector.byteLength > 0 ? vector : undefined, + ); + + const previous = entry.members.get(clientId); + if (previous && previous.connection !== connection) { + leave(previous.connection, entry.room, clientId); + } + entry.members.set(clientId, { + awarenessIds: previous?.awarenessIds ?? new Set(), + connection, + }); + entry.baseVersion = baseVersion ?? entry.baseVersion; + entry.updatedBy ??= userId; + join(connection, entry.room, clientId); + + send(connection, { + doc: entry.ref, + from: null, + type: "doc:update", + update: encodeContentLiveBytes(missing), + }); + send(connection, { + clientId, + doc: entry.ref, + stateVector: encodeContentLiveBytes(Y.encodeStateVector(entry.doc)), + type: "doc:state-vector", + }); + const peers = [...entry.awareness.states.keys()]; + if (peers.length > 0) { + send(connection, { + doc: entry.ref, + from: null, + type: "doc:awareness", + update: encodeContentLiveBytes( + encodeAwarenessUpdate(entry.awareness, peers), + ), + }); + } + + await pickSeeder(entry); + + return "ok"; + }, + resetRecord: (room: ContentLiveRoomRef): void => { + for (const entry of [...docs.values()]) { + if (!sameContentLiveRoom(entry.ref, room)) continue; + for (const [clientId, member] of entry.members) { + leave(member.connection, entry.room, clientId); + } + entry.members.clear(); + unload(entry); + } + }, + retrySeeding: async (): Promise<void> => { + await Promise.all( + [...docs.values()].map(async entry => await pickSeeder(entry)), + ); + }, + update: ({ + clientId, + connection, + doc, + update, + userId, + }: { + clientId: string; + connection: TConnection; + doc: ContentLiveDocRef; + update: string; + userId: number; + }): ContentDocumentResult => { + const entry = memberOf(doc, clientId, connection); + if (!entry) return "NOT_JOINED"; + const bytes = decode(update); + if (!bytes) return "INVALID_MESSAGE"; + + try { + Y.applyUpdate(entry.doc, bytes, clientId); + } catch { + return "INVALID_MESSAGE"; + } + markSeeded(entry); + entry.updatedBy = userId; + relay(entry.room, { + doc: entry.ref, + from: clientId, + type: "doc:update", + update, + }); + schedulePersist(entry); + + return "ok"; + }, + }; +}; diff --git a/packages/vitnode/src/content/server/live/hooks.ts b/packages/vitnode/src/content/server/live/hooks.ts new file mode 100644 index 0000000000..4ddefdf046 --- /dev/null +++ b/packages/vitnode/src/content/server/live/hooks.ts @@ -0,0 +1,74 @@ +import type { Context } from "hono"; + +import type { + ContentLiveResetReason, + ContentLiveRoomRef, +} from "@/content/live/protocol"; + +import { broadcastContentLive } from "./broadcast"; + +type UserLeftListener = (args: { + c: Context; + room: ContentLiveRoomRef; + userId: number; +}) => Promise<void> | void; + +type ResetListener = (args: { + c: Context; + reason: ContentLiveResetReason; + room: ContentLiveRoomRef; +}) => Promise<void> | void; + +const userLeftListeners = new Set<UserLeftListener>(); +const resetListeners = new Set<ResetListener>(); + +export const onContentLiveUserLeft = ( + listener: UserLeftListener, +): (() => void) => { + userLeftListeners.add(listener); + + return () => { + userLeftListeners.delete(listener); + }; +}; + +export const onContentLiveReset = (listener: ResetListener): (() => void) => { + resetListeners.add(listener); + + return () => { + resetListeners.delete(listener); + }; +}; + +const runAll = async <T>( + listeners: Set<(args: T) => Promise<void> | void>, + args: T, +): Promise<void> => { + const results = await Promise.allSettled( + [...listeners].map(async listener => { + await listener(args); + }), + ); + + for (const result of results) { + if (result.status === "rejected") { + // eslint-disable-next-line no-console + console.error("Content live listener error:", result.reason); + } + } +}; + +export const contentLiveUserLeft = async ( + args: Parameters<UserLeftListener>[0], +): Promise<void> => { + await runAll(userLeftListeners, args); +}; + +export const resetContentLiveRecord = async ( + c: Context, + room: ContentLiveRoomRef, + reason: ContentLiveResetReason, +): Promise<void> => { + await runAll(resetListeners, { c, reason, room }); + broadcastContentLive(room, { reason, room, type: "reset" }); +}; diff --git a/packages/vitnode/src/content/server/live/messages.ts b/packages/vitnode/src/content/server/live/messages.ts new file mode 100644 index 0000000000..c0e5fc75d8 --- /dev/null +++ b/packages/vitnode/src/content/server/live/messages.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +import type { ContentLiveClientMessage } from "@/content/live/protocol"; + +export const CONTENT_LIVE_MAX_UPDATE_BYTES = 2 * 1024 * 1024; + +const MAX_BASE64_LENGTH = Math.ceil(CONTENT_LIVE_MAX_UPDATE_BYTES / 3) * 4; + +const clientId = z.string().min(1).max(64); +const locale = z.string().min(1).max(35).nullable(); +const base64 = z + .string() + .max(MAX_BASE64_LENGTH) + .regex(/^[A-Za-z0-9+/]*={0,2}$/); + +const room = z.strictObject({ + contentTypeId: z.string().min(1).max(100), + itemId: z.number().int().positive(), +}); + +const doc = z.strictObject({ + contentTypeId: z.string().min(1).max(100), + field: z.string().min(1).max(100), + itemId: z.number().int().positive(), + locale, +}); + +export const contentLiveClientMessageSchema = z.discriminatedUnion("type", [ + z.strictObject({ clientId, locale, room, type: z.literal("join") }), + z.strictObject({ + clientId, + room, + type: z.enum(["heartbeat", "leave"]), + }), + z.strictObject({ + clientId, + field: z.string().min(1).max(100).nullable(), + locale, + room, + type: z.literal("focus"), + }), + z.strictObject({ + clientId, + doc, + stateVector: base64, + type: z.literal("doc:open"), + }), + z.strictObject({ + clientId, + doc, + type: z.enum(["doc:awareness", "doc:update"]), + update: base64, + }), + z.strictObject({ clientId, doc, type: z.literal("doc:close") }), +]) satisfies z.ZodType<ContentLiveClientMessage>; + +export const clientIdOf = (data: unknown): string => { + if (typeof data !== "object" || data === null || !("clientId" in data)) { + return ""; + } + const { success, data: parsed } = clientId.safeParse(data.clientId); + + return success ? parsed : ""; +}; + +export const decodeContentLiveBytes = (value: string): Uint8Array => + new Uint8Array(Buffer.from(value, "base64")); + +export const encodeContentLiveBytes = (value: Uint8Array): string => + Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString( + "base64", + ); diff --git a/packages/vitnode/src/content/server/live/presence.test.ts b/packages/vitnode/src/content/server/live/presence.test.ts new file mode 100644 index 0000000000..fab1310599 --- /dev/null +++ b/packages/vitnode/src/content/server/live/presence.test.ts @@ -0,0 +1,527 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from "vitest"; + +import type { ContentLiveMember } from "@/content/live/protocol"; + +import { CONTENT_LIVE_MEMBER_TIMEOUT_MS } from "@/content/live/protocol"; +import { createLiveHarness, ofType } from "@/tests/content-live"; + +import type { ContentLivePresenceSnapshot } from "./presence"; + +import { CONTENT_LIVE_PRESENCE_TOPIC } from "./server"; + +const room = { contentTypeId: "test.note", itemId: 1 }; + +const harnesses: ReturnType<typeof createLiveHarness>[] = []; +const harness = (options?: Parameters<typeof createLiveHarness>[0]) => { + const created = createLiveHarness(options); + harnesses.push(created); + + return created; +}; + +afterEach(() => { + harnesses.splice(0).forEach(created => created.stop()); +}); + +const lastPresence = (messages: Parameters<typeof ofType>[0]) => + ofType(messages, "presence") + .at(-1) + ?.members.map(({ clientId, field, locale }) => ({ + clientId, + field, + locale, + })); + +describe("joining a record", () => { + it("replies joined to the tab and shows everyone the new member", async () => { + const live = harness(); + const anna = live.socket({ id: 1, name: "Anna" }); + const ben = live.socket({ id: 2, name: "Ben" }); + + await live.send(anna, { + clientId: "anna-1", + locale: "en", + room, + type: "join", + }); + await live.send(ben, { + clientId: "ben-1", + locale: "pl", + room, + type: "join", + }); + + expect(ofType(ben.received(), "joined")).toEqual([ + { + clientId: "ben-1", + members: [ + expect.objectContaining({ + clientId: "anna-1", + name: "Anna", + userId: 1, + }), + expect.objectContaining({ + clientId: "ben-1", + name: "Ben", + userId: 2, + }), + ], + room, + type: "joined", + }, + ]); + expect(lastPresence(anna.received())).toEqual([ + { clientId: "anna-1", field: null, locale: "en" }, + { clientId: "ben-1", field: null, locale: "pl" }, + ]); + }); + + it("refuses a tab the authorization refuses, and keeps it out", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + const intruder = live.socket({ id: 2 }); + live.deny(intruder); + + await live.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + await live.send(intruder, { + clientId: "x", + locale: null, + room, + type: "join", + }); + + expect(ofType(intruder.received(), "error")).toEqual([ + { clientId: "x", code: "FORBIDDEN", room, type: "error" }, + ]); + expect(live.server.presence.membersOf(room).map(m => m.clientId)).toEqual([ + "anna-1", + ]); + }); + + it("lets a tab move to a new socket, but nobody else take its clientId", async () => { + const live = harness(); + const annaOld = live.socket({ id: 1 }); + const annaNew = live.socket({ id: 1 }); + const mallory = live.socket({ id: 2 }); + await live.send(annaOld, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + + await live.send(mallory, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + + expect(ofType(mallory.received(), "error").at(-1)?.code).toBe("FORBIDDEN"); + expect(live.server.presence.get(room, "anna-1")?.member.userId).toBe(1); + + await live.send(annaNew, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + live.disconnect(annaOld); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(live.server.presence.membersOf(room).map(m => m.clientId)).toEqual([ + "anna-1", + ]); + expect(live.userLeft).toEqual([]); + }); + + it("answers a malformed message with INVALID_MESSAGE", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + + await live.send(anna, { + clientId: "anna-1", + room: { itemId: "1" }, + type: "join", + }); + await live.send(anna, { type: "dance" }); + + expect(ofType(anna.received(), "error")).toEqual([ + { clientId: "anna-1", code: "INVALID_MESSAGE", type: "error" }, + { clientId: "", code: "INVALID_MESSAGE", type: "error" }, + ]); + }); +}); + +describe("focus", () => { + it("tells the room which field and language a member is in", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + const ben = live.socket({ id: 2 }); + await live.send(anna, { + clientId: "anna-1", + locale: "en", + room, + type: "join", + }); + await live.send(ben, { + clientId: "ben-1", + locale: "en", + room, + type: "join", + }); + + await live.send(anna, { + clientId: "anna-1", + field: "title", + locale: "pl", + room, + type: "focus", + }); + + expect(lastPresence(ben.received())).toEqual([ + { clientId: "anna-1", field: "title", locale: "pl" }, + { clientId: "ben-1", field: null, locale: "en" }, + ]); + }); + + it("is applied after a join sent right before it, without waiting", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + + await Promise.all([ + live.send(anna, { clientId: "anna-1", locale: "en", room, type: "join" }), + live.send(anna, { + clientId: "anna-1", + field: "title", + locale: "en", + room, + type: "focus", + }), + ]); + + expect(lastPresence(anna.received())).toEqual([ + { clientId: "anna-1", field: "title", locale: "en" }, + ]); + }); + + it("is NOT_JOINED for a tab outside the room, or another socket's tab", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + const ben = live.socket({ id: 2 }); + await live.send(anna, { + clientId: "anna-1", + locale: "en", + room, + type: "join", + }); + + await live.send(ben, { + clientId: "anna-1", + field: "title", + locale: "en", + room, + type: "focus", + }); + + expect(ofType(ben.received(), "error")).toEqual([ + { clientId: "anna-1", code: "NOT_JOINED", room, type: "error" }, + ]); + expect(live.server.presence.membersOf(room)[0]?.field).toBeNull(); + }); +}); + +describe("leaving", () => { + it("removes the tab and reports the user gone after their last tab", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + const ben = live.socket({ id: 2 }); + await live.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + await live.send(anna, { + clientId: "anna-2", + locale: null, + room, + type: "join", + }); + await live.send(ben, { + clientId: "ben-1", + locale: null, + room, + type: "join", + }); + + await live.send(anna, { clientId: "anna-1", room, type: "leave" }); + + expect(live.userLeft).toEqual([]); + expect(lastPresence(ben.received())?.map(m => m.clientId)).toEqual([ + "anna-2", + "ben-1", + ]); + + await live.send(anna, { clientId: "anna-2", room, type: "leave" }); + + expect(live.userLeft).toEqual([{ room, userId: 1 }]); + expect(lastPresence(ben.received())?.map(m => m.clientId)).toEqual([ + "ben-1", + ]); + }); + + it("removes every tab of a socket that closes", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + const ben = live.socket({ id: 2 }); + await live.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + await live.send(anna, { + clientId: "anna-2", + locale: null, + room, + type: "join", + }); + await live.send(ben, { + clientId: "ben-1", + locale: null, + room, + type: "join", + }); + + live.disconnect(anna); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(lastPresence(ben.received())?.map(m => m.clientId)).toEqual([ + "ben-1", + ]); + expect(live.userLeft).toEqual([{ room, userId: 1 }]); + }); + + it("drops a member whose heartbeats stopped", async () => { + let now = 0; + const live = harness({ now: () => now }); + const anna = live.socket({ id: 1 }); + const ben = live.socket({ id: 2 }); + await live.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + await live.send(ben, { + clientId: "ben-1", + locale: null, + room, + type: "join", + }); + + now = CONTENT_LIVE_MEMBER_TIMEOUT_MS - 1_000; + await live.send(ben, { clientId: "ben-1", room, type: "heartbeat" }); + now = CONTENT_LIVE_MEMBER_TIMEOUT_MS; + await live.server.sweep(); + + expect(live.server.presence.membersOf(room).map(m => m.clientId)).toEqual([ + "ben-1", + ]); + expect(live.userLeft).toEqual([{ room, userId: 1 }]); + + await live.send(anna, { clientId: "anna-1", room, type: "heartbeat" }); + + expect(ofType(anna.received(), "error").at(-1)?.code).toBe("NOT_JOINED"); + }); + + it("removes a member whose access was revoked, once the cache expires", async () => { + let now = 0; + const live = harness({ authTtlMs: 60_000, now: () => now }); + const anna = live.socket({ id: 1 }); + await live.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + + live.deny(anna); + await live.send(anna, { clientId: "anna-1", room, type: "heartbeat" }); + + expect(live.server.presence.membersOf(room)).toHaveLength(1); + + now = 60_000; + await live.send(anna, { clientId: "anna-1", room, type: "heartbeat" }); + + expect(ofType(anna.received(), "error").at(-1)?.code).toBe("FORBIDDEN"); + expect(live.server.presence.membersOf(room)).toEqual([]); + }); +}); + +describe("members on another instance", () => { + const twoInstances = () => { + let now = 0; + const delivered: { data: unknown; room: string }[] = []; + const rooms = new Map<string, Set<string>>(); + const peers: { + first?: ReturnType<typeof createLiveHarness>; + second?: ReturnType<typeof createLiveHarness>; + } = {}; + + const forward = + ( + origin: string, + target: () => ReturnType<typeof createLiveHarness> | undefined, + ) => + (snapshot: ContentLivePresenceSnapshot) => { + target()?.server.onInstanceMessage({ + data: JSON.parse(JSON.stringify(snapshot)), + origin, + topic: CONTENT_LIVE_PRESENCE_TOPIC, + }); + }; + + const first = harness({ + now: () => now, + publish: forward("a", () => peers.second), + }); + const second = harness({ + now: () => now, + publish: forward("b", () => peers.first), + registry: { + join: (_ws, room, memberId) => { + const members = rooms.get(room) ?? new Set(); + rooms.set(room, members); + members.add(memberId); + + return true; + }, + leave: (_ws, room, memberId) => + rooms.get(room)?.delete(memberId) ?? false, + toRoom: (room, _id, data) => { + delivered.push({ data, room }); + }, + }, + }); + peers.first = first; + peers.second = second; + + return { + delivered, + first, + second, + setNow: (value: number) => { + now = value; + }, + }; + }; + + const ids = (members: ContentLiveMember[] | undefined) => + members?.map(member => member.clientId); + + it("lists the union of both instances' members", async () => { + const { first, second } = twoInstances(); + const anna = first.socket({ id: 1 }); + const ben = second.socket({ id: 2 }); + + await first.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + await second.send(ben, { + clientId: "ben-1", + locale: null, + room, + type: "join", + }); + + expect(ids(ofType(ben.received(), "joined")[0]?.members)).toEqual([ + "ben-1", + "anna-1", + ]); + expect(ids(ofType(anna.received(), "presence").at(-1)?.members)).toEqual([ + "anna-1", + "ben-1", + ]); + + await second.send(ben, { + clientId: "ben-1", + field: "title", + locale: null, + room, + type: "focus", + }); + + expect( + ofType(anna.received(), "presence") + .at(-1) + ?.members.find(member => member.clientId === "ben-1")?.field, + ).toBe("title"); + + await second.send(ben, { clientId: "ben-1", room, type: "leave" }); + + expect(ids(ofType(anna.received(), "presence").at(-1)?.members)).toEqual([ + "anna-1", + ]); + }); + + it("tells a newcomer instance about its members right away", async () => { + const { delivered, first, second } = twoInstances(); + const anna = first.socket({ id: 1 }); + await first.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + const ben = second.socket({ id: 2 }); + + await second.send(ben, { + clientId: "ben-1", + locale: null, + room, + type: "join", + }); + + const latest = delivered.at(-1)?.data as { members: ContentLiveMember[] }; + expect(ids(latest.members)).toEqual(["ben-1", "anna-1"]); + }); + + it("forgets another instance's members when it stops refreshing them", async () => { + const { first, second, setNow } = twoInstances(); + const anna = first.socket({ id: 1 }); + const ben = second.socket({ id: 2 }); + await first.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + await second.send(ben, { + clientId: "ben-1", + locale: null, + room, + type: "join", + }); + + second.server.stop(); + setNow(CONTENT_LIVE_MEMBER_TIMEOUT_MS - 1_000); + await first.send(anna, { clientId: "anna-1", room, type: "heartbeat" }); + setNow(CONTENT_LIVE_MEMBER_TIMEOUT_MS); + await first.server.sweep(); + + expect(ids(ofType(anna.received(), "presence").at(-1)?.members)).toEqual([ + "anna-1", + ]); + }); +}); diff --git a/packages/vitnode/src/content/server/live/presence.ts b/packages/vitnode/src/content/server/live/presence.ts new file mode 100644 index 0000000000..4b9c166a8b --- /dev/null +++ b/packages/vitnode/src/content/server/live/presence.ts @@ -0,0 +1,230 @@ +import type { + ContentLiveMember, + ContentLiveRoomRef, +} from "@/content/live/protocol"; + +import { + CONTENT_LIVE_MEMBER_TIMEOUT_MS, + contentLiveRoom, +} from "@/content/live/protocol"; + +export interface ContentLivePresenceSnapshot { + members: ContentLiveMember[]; + room: ContentLiveRoomRef; +} + +export interface ContentLiveLocalMember<TConnection> { + connection: TConnection; + lastSeen: number; + member: ContentLiveMember; + room: ContentLiveRoomRef; +} + +export const createContentLivePresence = <TConnection>({ + deliver, + now = Date.now, + publish, +}: { + deliver: (room: ContentLiveRoomRef, members: ContentLiveMember[]) => void; + now?: () => number; + publish: (snapshot: ContentLivePresenceSnapshot) => void; +}) => { + const refs = new Map<string, ContentLiveRoomRef>(); + const local = new Map< + string, + Map<string, ContentLiveLocalMember<TConnection>> + >(); + const remote = new Map< + string, + Map<string, { expiresAt: number; members: ContentLiveMember[] }> + >(); + + const forget = (key: string): void => { + if (!local.has(key) && !remote.has(key)) refs.delete(key); + }; + + const localMembers = (key: string): ContentLiveMember[] => + [...(local.get(key)?.values() ?? [])].map(entry => entry.member); + + const membersOf = (room: ContentLiveRoomRef): ContentLiveMember[] => { + const key = contentLiveRoom(room); + const members = localMembers(key); + const seen = new Set(members.map(member => member.clientId)); + + for (const snapshot of remote.get(key)?.values() ?? []) { + for (const member of snapshot.members) { + if (seen.has(member.clientId)) continue; + seen.add(member.clientId); + members.push(member); + } + } + + return members; + }; + + const deliverRoom = (key: string): void => { + const room = refs.get(key); + if (room) deliver(room, membersOf(room)); + }; + + const announce = (key: string): void => { + const room = refs.get(key); + if (!room) return; + deliver(room, membersOf(room)); + publish({ members: localMembers(key), room }); + }; + + const removeLocal = ( + key: string, + clientId: string, + ): ContentLiveLocalMember<TConnection> | undefined => { + const members = local.get(key); + const entry = members?.get(clientId); + if (!members || !entry) return undefined; + + members.delete(clientId); + if (members.size === 0) local.delete(key); + announce(key); + forget(key); + + return entry; + }; + + return { + applyRemote: ( + origin: string, + { members, room }: ContentLivePresenceSnapshot, + ): boolean => { + const key = contentLiveRoom(room); + const instances = + remote.get(key) ?? + new Map<string, { expiresAt: number; members: ContentLiveMember[] }>(); + const isNew = !instances.has(origin); + + if (members.length === 0) { + if (isNew) return false; + instances.delete(origin); + if (instances.size === 0) remote.delete(key); + } else { + refs.set(key, room); + instances.set(origin, { + expiresAt: now() + CONTENT_LIVE_MEMBER_TIMEOUT_MS, + members, + }); + remote.set(key, instances); + } + + deliverRoom(key); + forget(key); + + return isNew && members.length > 0; + }, + focus: ( + room: ContentLiveRoomRef, + clientId: string, + { field, locale }: Pick<ContentLiveMember, "field" | "locale">, + ): boolean => { + const key = contentLiveRoom(room); + const entry = local.get(key)?.get(clientId); + if (!entry) return false; + + entry.lastSeen = now(); + if (entry.member.field === field && entry.member.locale === locale) { + return true; + } + entry.member = { ...entry.member, field, locale }; + announce(key); + + return true; + }, + get: ( + room: ContentLiveRoomRef, + clientId: string, + ): ContentLiveLocalMember<TConnection> | undefined => + local.get(contentLiveRoom(room))?.get(clientId), + hasUser: (room: ContentLiveRoomRef, userId: number): boolean => + [...(local.get(contentLiveRoom(room))?.values() ?? [])].some( + entry => entry.member.userId === userId, + ), + heartbeat: (room: ContentLiveRoomRef, clientId: string): boolean => { + const entry = local.get(contentLiveRoom(room))?.get(clientId); + if (!entry) return false; + entry.lastSeen = now(); + + return true; + }, + join: ({ + connection, + member, + room, + }: { + connection: TConnection; + member: ContentLiveMember; + room: ContentLiveRoomRef; + }): ContentLiveMember[] => { + const key = contentLiveRoom(room); + refs.set(key, room); + const members = + local.get(key) ?? + new Map<string, ContentLiveLocalMember<TConnection>>(); + local.set(key, members); + members.set(member.clientId, { + connection, + lastSeen: now(), + member, + room, + }); + announce(key); + + return membersOf(room); + }, + localMembers: (): ContentLiveLocalMember<TConnection>[] => + [...local.values()].flatMap(members => [...members.values()]), + membersOf, + refresh: (only?: ContentLiveRoomRef): void => { + const onlyKey = only ? contentLiveRoom(only) : undefined; + for (const [key, members] of local) { + const room = refs.get(key); + if (room && (onlyKey === undefined || onlyKey === key)) { + publish({ + members: [...members.values()].map(entry => entry.member), + room, + }); + } + } + }, + remove: ( + room: ContentLiveRoomRef, + clientId: string, + ): ContentLiveLocalMember<TConnection> | undefined => + removeLocal(contentLiveRoom(room), clientId), + sweep: (): ContentLiveLocalMember<TConnection>[] => { + const at = now(); + const removed: ContentLiveLocalMember<TConnection>[] = []; + + for (const [key, members] of [...local]) { + for (const [clientId, entry] of [...members]) { + if (at - entry.lastSeen < CONTENT_LIVE_MEMBER_TIMEOUT_MS) continue; + const gone = removeLocal(key, clientId); + if (gone) removed.push(gone); + } + } + + for (const [key, instances] of [...remote]) { + let changed = false; + for (const [origin, snapshot] of [...instances]) { + if (snapshot.expiresAt > at) continue; + instances.delete(origin); + changed = true; + } + if (instances.size === 0) remote.delete(key); + if (changed) { + deliverRoom(key); + forget(key); + } + } + + return removed; + }, + }; +}; diff --git a/packages/vitnode/src/content/server/live/server.ts b/packages/vitnode/src/content/server/live/server.ts new file mode 100644 index 0000000000..03cd05ad3b --- /dev/null +++ b/packages/vitnode/src/content/server/live/server.ts @@ -0,0 +1,452 @@ +import type { Context } from "hono"; +import type { WSContext } from "hono/ws"; + +import { z } from "zod"; + +import type { + ContentLiveClientMessage, + ContentLiveErrorCode, + ContentLiveRoomRef, + ContentLiveServerMessage, +} from "@/content/live/protocol"; + +import { + CONTENT_LIVE_HEARTBEAT_MS, + contentLiveChannel, + contentLiveRoom, +} from "@/content/live/protocol"; +import { publishToInstances, realtime, wsRegistry } from "@/ws/registry"; + +import type { ContentLiveAuthorization } from "./auth"; +import type { ContentDocumentStore } from "./documents"; +import type { + ContentLiveLocalMember, + ContentLivePresenceSnapshot, +} from "./presence"; + +import { + authorizeContentLive, + createContentLiveAuthCache, + isContentLiveDocValid, +} from "./auth"; +import { createContentDocumentStore } from "./document-store"; +import { createContentDocuments } from "./documents"; +import { contentLiveUserLeft } from "./hooks"; +import { clientIdOf, contentLiveClientMessageSchema } from "./messages"; +import { createContentLivePresence } from "./presence"; + +export const CONTENT_LIVE_PRESENCE_TOPIC = `${contentLiveChannel.id}:presence`; + +const DOC_ROOM_PREFIX = "content-doc:"; + +export interface ContentLiveConnection { + c: Context; + ws: WSContext; +} + +type LocalMember = ContentLiveLocalMember<ContentLiveConnection>; + +const snapshotSchema = z.object({ + members: z.array( + z.object({ + avatarColor: z.string().nullable(), + clientId: z.string(), + field: z.string().nullable(), + locale: z.string().nullable(), + name: z.string(), + nameCode: z.string().nullable(), + userId: z.number(), + }), + ), + room: z.object({ contentTypeId: z.string(), itemId: z.number() }), +}) satisfies z.ZodType<ContentLivePresenceSnapshot>; + +const resetSchema = z.object({ + room: z.object({ contentTypeId: z.string(), itemId: z.number() }), + type: z.literal("reset"), +}); + +const logError = (message: string, error: unknown): void => { + // eslint-disable-next-line no-console + console.error(message, error); +}; + +const sendTo = (ws: WSContext, data: ContentLiveServerMessage): void => { + try { + ws.send(JSON.stringify({ data, id: contentLiveChannel.id })); + } catch { + return; + } +}; + +const roomOf = (message: ContentLiveClientMessage): ContentLiveRoomRef => + "room" in message + ? message.room + : { + contentTypeId: message.doc.contentTypeId, + itemId: message.doc.itemId, + }; + +export const createContentLiveServer = ({ + authorize = authorizeContentLive, + authTtlMs, + createStore = createContentDocumentStore, + isDocValid = isContentLiveDocValid, + now = Date.now, + publish = snapshot => { + publishToInstances(CONTENT_LIVE_PRESENCE_TOPIC, snapshot); + }, + registry = wsRegistry, + userLeft = contentLiveUserLeft, +}: { + authorize?: ( + c: Context, + room: ContentLiveRoomRef, + ) => Promise<ContentLiveAuthorization>; + authTtlMs?: number; + createStore?: (c: Context) => ContentDocumentStore; + isDocValid?: typeof isContentLiveDocValid; + now?: () => number; + publish?: (snapshot: ContentLivePresenceSnapshot) => void; + registry?: Pick<typeof wsRegistry, "join" | "leave" | "toRoom">; + userLeft?: typeof contentLiveUserLeft; +} = {}) => { + const auth = createContentLiveAuthCache({ authorize, now, ttlMs: authTtlMs }); + const connections = new WeakMap<WSContext, ContentLiveConnection>(); + const queues = new WeakMap<WSContext, Promise<void>>(); + + const presence = createContentLivePresence<ContentLiveConnection>({ + deliver: (room, members) => { + registry.toRoom(contentLiveRoom(room), contentLiveChannel.id, { + members, + room, + type: "presence", + } satisfies ContentLiveServerMessage); + }, + now, + publish, + }); + + const documents = createContentDocuments<ContentLiveConnection>({ + join: ({ ws }, room, clientId) => { + registry.join(ws, room, clientId); + }, + leave: ({ ws }, room, clientId) => { + registry.leave(ws, room, clientId); + }, + relay: (room, message) => { + realtime.toRoom(room, contentLiveChannel, message); + }, + send: ({ ws }, message) => { + sendTo(ws, message); + }, + }); + + const connectionOf = (c: Context, ws: WSContext): ContentLiveConnection => { + const existing = connections.get(ws); + if (existing) return existing; + const created = { c, ws }; + connections.set(ws, created); + + return created; + }; + + const afterLeft = async (entries: LocalMember[]): Promise<void> => { + const gone = new Map<string, LocalMember>(); + for (const entry of entries) { + const key = `${contentLiveRoom(entry.room)}|${entry.member.userId}`; + if (!presence.hasUser(entry.room, entry.member.userId)) { + gone.set(key, entry); + } + } + + await Promise.all( + entries.map(async ({ connection, member, room }) => { + registry.leave(connection.ws, contentLiveRoom(room), member.clientId); + await documents.closeClient(room, member.clientId); + }), + ); + await Promise.all( + [...gone.values()].map(async ({ connection, member, room }) => { + await userLeft({ c: connection.c, room, userId: member.userId }); + }), + ); + }; + + const removeMembers = async ( + members: { clientId: string; room: ContentLiveRoomRef }[], + ): Promise<void> => { + const removed = members.flatMap( + ({ clientId, room }) => presence.remove(room, clientId) ?? [], + ); + await afterLeft(removed); + }; + + const removeMember = async ( + room: ContentLiveRoomRef, + clientId: string, + ): Promise<void> => { + await removeMembers([{ clientId, room }]); + }; + + const isMember = ( + connection: ContentLiveConnection, + room: ContentLiveRoomRef, + clientId: string, + ): boolean => presence.get(room, clientId)?.connection === connection; + + let sweeper: ReturnType<typeof setInterval> | undefined; + + const sweep = async (): Promise<void> => { + const removed = presence.sweep(); + presence.refresh(); + await afterLeft(removed); + await documents.retrySeeding(); + }; + + const startSweeper = (): void => { + if (sweeper) return; + sweeper = setInterval(() => { + sweep().catch((error: unknown) => { + logError("Content live sweep error:", error); + }); + }, CONTENT_LIVE_HEARTBEAT_MS); + sweeper.unref?.(); + }; + + const handleMessage = async ( + connection: ContentLiveConnection, + message: ContentLiveClientMessage, + ): Promise<void> => { + const { c, ws } = connection; + const { clientId } = message; + const room = roomOf(message); + const fail = (code: ContentLiveErrorCode): void => { + sendTo(ws, { clientId, code, room, type: "error" }); + }; + + const guard = async (): Promise<ContentLiveAuthorization> => { + const result = await auth.authorize(c, ws, room); + if (!result.ok) { + if (isMember(connection, room, clientId)) { + await removeMember(room, clientId); + } + fail(result.code); + } + + return result; + }; + + if (message.type === "join") { + const result = await guard(); + if (!result.ok) return; + + const previous = presence.get(room, clientId); + if (previous && previous.connection !== connection) { + if (previous.member.userId !== result.user.id) { + fail("FORBIDDEN"); + + return; + } + registry.leave(previous.connection.ws, contentLiveRoom(room), clientId); + } + registry.join(ws, contentLiveRoom(room), clientId); + const members = presence.join({ + connection, + member: { + avatarColor: result.user.avatarColor, + clientId, + field: previous?.member.field ?? null, + locale: message.locale, + name: result.user.name, + nameCode: result.user.nameCode, + userId: result.user.id, + }, + room, + }); + sendTo(ws, { clientId, members, room, type: "joined" }); + startSweeper(); + + return; + } + + if (message.type === "leave") { + if (isMember(connection, room, clientId)) { + await removeMember(room, clientId); + } + + return; + } + + if (!isMember(connection, room, clientId)) { + fail("NOT_JOINED"); + + return; + } + + const result = await guard(); + if (!result.ok) return; + + switch (message.type) { + case "doc:awareness": + case "doc:update": { + const args = { + clientId, + connection, + doc: message.doc, + update: message.update, + }; + const outcome = + message.type === "doc:update" + ? documents.update({ ...args, userId: result.user.id }) + : documents.awareness(args); + if (outcome !== "ok") fail(outcome); + + return; + } + case "doc:close": { + await documents.close({ clientId, connection, doc: message.doc }); + + return; + } + case "doc:open": { + if (!(await isDocValid(c, message.doc))) { + fail("NOT_FOUND"); + + return; + } + const outcome = await documents.open({ + baseVersion: result.version, + clientId, + connection, + doc: message.doc, + stateVector: message.stateVector, + store: createStore(c), + userId: result.user.id, + }); + if (outcome !== "ok") fail(outcome); + + return; + } + case "focus": { + presence.focus(room, clientId, { + field: message.field, + locale: message.locale, + }); + + return; + } + case "heartbeat": { + presence.heartbeat(room, clientId); + + return; + } + } + }; + + return { + documents, + handle: async ({ + c, + data, + ws, + }: { + c: Context; + data: unknown; + ws: WSContext; + }): Promise<void> => { + const parsed = contentLiveClientMessageSchema.safeParse(data); + if (!parsed.success) { + sendTo(ws, { + clientId: clientIdOf(data), + code: "INVALID_MESSAGE", + type: "error", + }); + + return; + } + + const connection = connectionOf(c, ws); + const previous = queues.get(ws) ?? Promise.resolve(); + const current = previous.then(async () => { + await handleMessage(connection, parsed.data); + }); + const settled = current.catch((error: unknown) => { + logError("Content live message error:", error); + }); + queues.set(ws, settled); + await settled; + }, + onConnectionClose: ( + ws: WSContext, + left: { memberId: string; room: string }[], + ): void => { + const connection = connections.get(ws); + if (!connection) return; + + const work: Promise<void>[] = []; + for (const { memberId, room } of left) { + if (room.startsWith(DOC_ROOM_PREFIX)) { + work.push(documents.closeRoom(room, memberId, connection)); + } + } + work.push( + removeMembers( + presence + .localMembers() + .filter(entry => entry.connection === connection) + .map(({ member, room }) => ({ clientId: member.clientId, room })), + ), + ); + + void Promise.all(work).catch((error: unknown) => { + logError("Content live close error:", error); + }); + }, + onInstanceMessage: ({ + data, + origin, + topic, + }: { + data: unknown; + origin: string; + topic: string; + }): void => { + if (topic !== CONTENT_LIVE_PRESENCE_TOPIC) return; + const parsed = snapshotSchema.safeParse(data); + if (!parsed.success) return; + + if (presence.applyRemote(origin, parsed.data)) { + presence.refresh(parsed.data.room); + } + }, + onRemoteRoomMessage: (room: string, id: string, data: unknown): void => { + if (id !== contentLiveChannel.id) return; + if (room.startsWith(DOC_ROOM_PREFIX)) { + documents.applyRemote(room, data); + + return; + } + const reset = resetSchema.safeParse(data); + if (reset.success) documents.resetRecord(reset.data.room); + }, + onReset: async ({ + c, + room, + }: { + c: Context; + room: ContentLiveRoomRef; + }): Promise<void> => { + documents.resetRecord(room); + await createStore(c).deleteRecord(room); + }, + presence, + stop: (): void => { + clearInterval(sweeper); + sweeper = undefined; + }, + sweep, + }; +}; + +export type ContentLiveServer = ReturnType<typeof createContentLiveServer>; diff --git a/packages/vitnode/src/content/server/live/socket.ts b/packages/vitnode/src/content/server/live/socket.ts new file mode 100644 index 0000000000..38fad8b5af --- /dev/null +++ b/packages/vitnode/src/content/server/live/socket.ts @@ -0,0 +1,25 @@ +import { buildWebSocket } from "@/api/lib/websocket"; +import { + onInstanceMessage, + onRemoteRoomMessage, + wsRegistry, +} from "@/ws/registry"; + +import { onContentLiveReset } from "./hooks"; +import { createContentLiveServer } from "./server"; + +const contentLiveServer = createContentLiveServer(); + +wsRegistry.onConnectionClose(contentLiveServer.onConnectionClose); +onRemoteRoomMessage(contentLiveServer.onRemoteRoomMessage); +onInstanceMessage(contentLiveServer.onInstanceMessage); +onContentLiveReset(contentLiveServer.onReset); + +export const contentLiveWebSocket = buildWebSocket({ + description: + "Content Engine live editing: presence and collaborative rich text documents.", + id: "live", + onMessage: async ({ c, data, ws }) => { + await contentLiveServer.handle({ c, data, ws }); + }, +}); diff --git a/packages/vitnode/src/content/server/localized-admin-routes.ts b/packages/vitnode/src/content/server/localized-admin-routes.ts index ddf69dfb80..1db22a00dd 100644 --- a/packages/vitnode/src/content/server/localized-admin-routes.ts +++ b/packages/vitnode/src/content/server/localized-admin-routes.ts @@ -29,6 +29,7 @@ import { contentEditorialEffects } from "./editorial-effects"; import { emitContentEvent } from "./emit"; import { contentFileFields } from "./files"; import { withHttpErrors } from "./http-errors"; +import { commitContentDrafts } from "./live-store"; import { identifier, jsonBody, @@ -381,6 +382,7 @@ export const buildContentLocalizedAdminRoutes = < }, }, handler: async c => { + const savedFrom = new Date(); const id = identifier(c); const body = await readJson(c, updateBody); const entries = body.translations as TranslationEntry[]; @@ -469,6 +471,20 @@ export const buildContentLocalizedAdminRoutes = < }); } + if (definition.liveEditing.enabled) { + await commitContentDrafts( + c, + { contentTypeId: definition.id, itemId: id }, + { + locales: [ + ...(shared === undefined ? [] : [null]), + ...entries.map(entry => entry.locale), + ], + savedFrom, + }, + ); + } + return c.json(result.row, 200); }, }); diff --git a/packages/vitnode/src/content/server/localized-public-service.ts b/packages/vitnode/src/content/server/localized-public-service.ts index d18521f92b..023fe76b6e 100644 --- a/packages/vitnode/src/content/server/localized-public-service.ts +++ b/packages/vitnode/src/content/server/localized-public-service.ts @@ -107,7 +107,7 @@ export const createContentLocalizedPublicService = < if (!publicApi.enabled || !localization.enabled) { throw new ContentEngineError( - "The localized public service needs both `publicApi: { enabled: true, path, fields }` and `localization: { enabled: true, defaultLocale }`.", + "The localized public service needs both `publicApi: { path, fields }` and `localization: { defaultLocale }`.", { contentTypeId }, ); } diff --git a/packages/vitnode/src/content/server/localized-service.ts b/packages/vitnode/src/content/server/localized-service.ts index 13e11af80d..3cd8b450eb 100644 --- a/packages/vitnode/src/content/server/localized-service.ts +++ b/packages/vitnode/src/content/server/localized-service.ts @@ -65,7 +65,7 @@ export const createContentLocalizedService = < if (!definition.localization.enabled) { throw new ContentEngineError( - "The localized service needs `localization: { enabled: true, defaultLocale }` on the content type.", + "The localized service needs `localization: { defaultLocale }` on the content type.", { contentTypeId }, ); } diff --git a/packages/vitnode/src/content/server/permission-matrix.test.ts b/packages/vitnode/src/content/server/permission-matrix.test.ts index f344037587..11c4668cb9 100644 --- a/packages/vitnode/src/content/server/permission-matrix.test.ts +++ b/packages/vitnode/src/content/server/permission-matrix.test.ts @@ -25,14 +25,14 @@ import { buildContentRoutes } from "./routes"; const kitchenSink = defineContentType({ id: "test.everything", tableName: "test_everything", - localization: { enabled: true, defaultLocale: "en", fallback: "default" }, - publication: { enabled: true }, + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, editorial: { - enabled: true, revisions: { retention: 10 }, - preview: { enabled: true, expiresInMinutes: 30 }, - scheduling: { enabled: true }, + preview: { expiresInMinutes: 30 }, + scheduling: true, }, + liveEditing: true, fields: { title: field.text({ localized: true, required: true, maxLength: 200 }), slug: field.slug({ localized: true, source: "title" }), @@ -49,7 +49,6 @@ const kitchenSink = defineContentType({ }), }, publicApi: { - enabled: true, path: "everything", // `id` is exposed because delivery resolves alternates by identifier off the // public projection, and a localized delivery content type is refused without @@ -61,10 +60,9 @@ const kitchenSink = defineContentType({ // needs `editorial` and a localized slug, and this fixture has both - which is // the whole reason it is the maximal one rather than a second fixture. delivery: { - enabled: true, - redirects: { enabled: true }, + redirects: true, seo: { titleField: "title" }, - sitemap: { enabled: true }, + sitemap: true, }, admin: { list: { columns: ["featured", "status"] }, @@ -244,6 +242,11 @@ describe("the generated permission matrix", () => { // because one Save button writes one record. "PUT /{id}/localized": "can_edit", "PUT /{id}/translations/{locale}": "can_edit", + "GET /{id}/draft": "can_edit", + "GET /{id}/locks": "can_edit", + "POST /{id}/draft/discard": "can_edit", + "POST /{id}/locks": "can_edit", + "PUT /{id}/draft": "can_edit", }; it("gates every route on a staff permission", async () => { diff --git a/packages/vitnode/src/content/server/public-privacy.test.ts b/packages/vitnode/src/content/server/public-privacy.test.ts index 1a98d7564f..1f795614fe 100644 --- a/packages/vitnode/src/content/server/public-privacy.test.ts +++ b/packages/vitnode/src/content/server/public-privacy.test.ts @@ -13,9 +13,9 @@ import { const contentType = defineContentType({ id: "test.privacy", tableName: "test_privacy", - localization: { enabled: true, defaultLocale: "en", fallback: "default" }, - publication: { enabled: true }, - editorial: { enabled: true }, + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, + editorial: true, fields: { title: field.text({ localized: true, required: true, maxLength: 200 }), slug: field.slug({ localized: true, source: "title" }), @@ -53,7 +53,6 @@ const contentType = defineContentType({ hiddenLinks: field.relation({ multiple: true, self: true }), }, publicApi: { - enabled: true, path: "privacy", fields: [ "title", diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index 3fd8b8e712..6be5add16f 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -255,7 +255,7 @@ export const createContentPublicService = < if (!publicApi.enabled) { throw new ContentEngineError( - "This content type has no public API. Add `publicApi: { enabled: true, path, fields }` to generate one.", + "This content type has no public API. Add `publicApi: { path, fields }` to generate one.", { contentTypeId }, ); } diff --git a/packages/vitnode/src/content/server/publication.test.ts b/packages/vitnode/src/content/server/publication.test.ts index 28e393b804..dd6f49c7dc 100644 --- a/packages/vitnode/src/content/server/publication.test.ts +++ b/packages/vitnode/src/content/server/publication.test.ts @@ -92,7 +92,7 @@ describe("publicationMethods", () => { ContentEngineError, ); expect(() => publicationMethods(testCategoryContentType, service)).toThrow( - /publication: \{ enabled: true \}/, + /publication: true/, ); }); }); diff --git a/packages/vitnode/src/content/server/publication.ts b/packages/vitnode/src/content/server/publication.ts index 7e7e2e6251..f36bf358cc 100644 --- a/packages/vitnode/src/content/server/publication.ts +++ b/packages/vitnode/src/content/server/publication.ts @@ -21,7 +21,7 @@ export const publicationColumns = ( if (!definition.publication.enabled || !publishedAt || !status) { throw new ContentEngineError( - "The published predicate needs `publication: { enabled: true }` on the content type.", + "The published predicate needs `publication: true` on the content type.", { contentTypeId: definition.id }, ); } @@ -51,7 +51,7 @@ export const contentTranslationPublicationColumns = ( !status ) { throw new ContentEngineError( - "The translation published predicate needs both `localization: { enabled: true }` and `publication: { enabled: true }` on the content type.", + "The translation published predicate needs both `localization: { defaultLocale }` and `publication: true` on the content type.", { contentTypeId: definition.id }, ); } @@ -75,7 +75,7 @@ export const publicationMethods = < ): ContentPublicationMethods<TDefinition> => { if (!definition.publication.enabled) { throw new ContentEngineError( - "publish/unpublish need `publication: { enabled: true }` on the content type.", + "publish/unpublish need `publication: true` on the content type.", { contentTypeId: definition.id }, ); } diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts index 02b06c7d6c..d4c2df8a13 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -1,7 +1,7 @@ import type { SQL } from "drizzle-orm"; import type { PgColumn } from "drizzle-orm/pg-core"; -import { and, eq, ilike, isNull, or } from "drizzle-orm"; +import { and, eq, ilike, isNull, or, sql } from "drizzle-orm"; import type { ContentFieldDescriptor, @@ -38,7 +38,16 @@ export const buildSearchCondition = ( const pattern = containsLikePattern(trimmed); - return or(...columns.map(column => ilike(column, pattern))); + return or( + ...columns.map(column => + column.getSQLType() === "jsonb" + ? ilike( + sql`jsonb_path_query_array(${column}, 'strict $.**.text')::text`, + pattern, + ) + : ilike(column, pattern), + ), + ); }; const filterValue = ( diff --git a/packages/vitnode/src/content/server/revision-snapshot.ts b/packages/vitnode/src/content/server/revision-snapshot.ts index 7dff0b57aa..02aa6e8efc 100644 --- a/packages/vitnode/src/content/server/revision-snapshot.ts +++ b/packages/vitnode/src/content/server/revision-snapshot.ts @@ -13,6 +13,7 @@ import { isContentReferenceCollection, readContentLeaf, } from "../paths"; +import { isRichTextDocument } from "../rich-text/document"; const toIso = (value: unknown): string => { if (value instanceof Date) return value.toISOString(); @@ -71,6 +72,12 @@ const toFieldSnapshot = ( : []; } + if (fieldValue.kind === "richText") { + const value = values[name]; + + return isRichTextDocument(value) ? value : null; + } + if (fieldValue.kind === "repeatable") { const value = values[name]; if (!Array.isArray(value)) return []; @@ -173,7 +180,10 @@ const projectSnapshotFields = ( if (fieldValue.kind === "group") { projected[name] = - value === null || typeof value !== "object" || Array.isArray(value) + value === null || + typeof value !== "object" || + Array.isArray(value) || + isRichTextDocument(value) ? null : pickLeaves(value, leaves); continue; diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 8f117f4d20..6e4713c709 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -62,6 +62,9 @@ import { withContentReferenceLists, zodContentReferenceListItem, } from "./list-references"; +import { buildContentLiveRoutes } from "./live-routes"; +import { commitContentDrafts } from "./live-store"; +import { resetContentLiveRecord } from "./live/hooks"; import { contentLocaleRouting } from "./locale-routing"; import { buildContentLocalizedAdminRoutes } from "./localized-admin-routes"; import { resolveContentLocalizedValues } from "./localized-display"; @@ -905,6 +908,7 @@ export const buildContentRoutes = < }, }, handler: async c => { + const savedFrom = new Date(); const id = identifier(c); const { expectedVersion, values } = await readJson( c, @@ -927,6 +931,14 @@ export const buildContentRoutes = < // stated once, in `contentEditorialEffects`. await contentEditorialEffects(c, definition, result, { model, pluginId }); + if (definition.liveEditing.enabled) { + await commitContentDrafts( + c, + { contentTypeId: definition.id, itemId: id }, + { locales: [null], savedFrom }, + ); + } + return c.json(result.row, 200); }, }); @@ -1232,6 +1244,11 @@ export const buildContentRoutes = < } await contentEditorialEffects(c, definition, result, { model, pluginId }); + await resetContentLiveRecord( + c, + { contentTypeId: definition.id, itemId: id }, + "restored", + ); return c.json({ changed: result.changed, row: result.row }, 200); }, @@ -1681,6 +1698,11 @@ export const buildContentRoutes = < if (!result) throw notFound(definition); await contentEditorialEffects(c, definition, result, { model, pluginId }); + await resetContentLiveRecord( + c, + { contentTypeId: definition.id, itemId: id }, + "deleted", + ); return c.json(result.row, 200); }, @@ -1743,6 +1765,9 @@ export const buildContentRoutes = < ? [publicationRoute("publish"), publicationRoute("unpublish")] : []), ...(editorial ? [revisionList, revisionDetail, restore] : []), + ...(definition.liveEditing.enabled + ? buildContentLiveRoutes(model, { pluginId }) + : []), // Mounted only for a content type that declares a file field, so nothing // else gains a binary endpoint it has no use for. ...(hasFileFields ? [upload] : []), diff --git a/packages/vitnode/src/content/server/search-document.ts b/packages/vitnode/src/content/server/search-document.ts index 2ee245a5bc..cc98bc4cbe 100644 --- a/packages/vitnode/src/content/server/search-document.ts +++ b/packages/vitnode/src/content/server/search-document.ts @@ -8,11 +8,16 @@ import { } from "../cache"; import { partitionContentFields } from "../localization"; import { readContentPath, splitContentFieldPath } from "../paths"; +import { isRichTextDocument } from "../rich-text/document"; +import { richTextToPlainText } from "../rich-text/plain-text"; import { contentSearchUrl } from "../search"; /** Collapses whitespace so a multi-line value cannot break a result heading. */ -const normalize = (value: unknown): string => - typeof value === "string" ? value.replace(/\s+/g, " ").trim() : ""; +const normalize = (value: unknown): string => { + const text = isRichTextDocument(value) ? richTextToPlainText(value) : value; + + return typeof text === "string" ? text.replace(/\s+/g, " ").trim() : ""; +}; const readSearchValue = ( values: Record<string, unknown>, diff --git a/packages/vitnode/src/content/server/search-indexer.test.ts b/packages/vitnode/src/content/server/search-indexer.test.ts index 35cda783b4..ad76f7cc83 100644 --- a/packages/vitnode/src/content/server/search-indexer.test.ts +++ b/packages/vitnode/src/content/server/search-indexer.test.ts @@ -86,14 +86,12 @@ const coauthoredContentType = defineContentType({ body: field.textarea({ nullable: true }), authors: field.user({ multiple: true, ordered: true }), }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, path: "multi-authors", fields: ["title", "slug", "body", "publishedAt"], }, search: { - enabled: true, titleField: "title", contentFields: ["body"], pathTemplate: "/multi-authors/{slug}", diff --git a/packages/vitnode/src/content/server/search-indexer.ts b/packages/vitnode/src/content/server/search-indexer.ts index fd578c8ce6..a82f67d3a3 100644 --- a/packages/vitnode/src/content/server/search-indexer.ts +++ b/packages/vitnode/src/content/server/search-indexer.ts @@ -134,7 +134,7 @@ const assertPublicationColumns = ( ): void => { if (!definition.publication.enabled || !columns.status) { throw new ContentEngineError( - "The search indexer needs `publication: { enabled: true }` on the content type.", + "The search indexer needs `publication: true` on the content type.", { contentTypeId: definition.id }, ); } @@ -248,7 +248,7 @@ export const createContentLocalizedSearchIndexer = < if (!translationTable || !translationColumns) { throw new ContentEngineError( - "The localized search indexer needs `localization: { enabled: true, defaultLocale }` on the content type.", + "The localized search indexer needs `localization: { defaultLocale }` on the content type.", { contentTypeId: definition.id }, ); } diff --git a/packages/vitnode/src/content/server/service.test.ts b/packages/vitnode/src/content/server/service.test.ts index 595d6cb273..765353e7a3 100644 --- a/packages/vitnode/src/content/server/service.test.ts +++ b/packages/vitnode/src/content/server/service.test.ts @@ -450,7 +450,7 @@ describe("content service", () => { defineContentType({ id: "test.searchable-note", tableName: "test_searchable_notes", - localization: { enabled: true, defaultLocale: "en" }, + localization: { defaultLocale: "en" }, fields: { title: field.text({ localized: true, required: true }), code: field.text({ nullable: true }), diff --git a/packages/vitnode/src/content/server/table.test.ts b/packages/vitnode/src/content/server/table.test.ts index 5f57cbeb51..df752205bf 100644 --- a/packages/vitnode/src/content/server/table.test.ts +++ b/packages/vitnode/src/content/server/table.test.ts @@ -81,7 +81,7 @@ describe("createContentTable", () => { id: "test.versioned", tableName: "test_versioned", fields: { title: field.text({ required: true }) }, - editorial: { enabled: true }, + editorial: true, }), ); const versionColumn = getTableConfig(editorialTable).columns.find( diff --git a/packages/vitnode/src/content/server/translation-editorial-service.test.ts b/packages/vitnode/src/content/server/translation-editorial-service.test.ts index 5f2f473d1f..15c46c4af1 100644 --- a/packages/vitnode/src/content/server/translation-editorial-service.test.ts +++ b/packages/vitnode/src/content/server/translation-editorial-service.test.ts @@ -578,15 +578,12 @@ describe("configuration guards", () => { c: {} as never, definition: { ...testLocalizedGuideContentType, - editorial: { - ...testLocalizedGuideContentType.editorial, - enabled: false, - }, + editorial: false, } as never, pluginId: PLUGIN_ID, schemas, translations: translations(), }), - ).toThrow(/needs `editorial: \{ enabled: true \}`/); + ).toThrow(/needs `editorial: true`/); }); }); diff --git a/packages/vitnode/src/content/server/translation-editorial-service.ts b/packages/vitnode/src/content/server/translation-editorial-service.ts index de866543c0..7f22170f2b 100644 --- a/packages/vitnode/src/content/server/translation-editorial-service.ts +++ b/packages/vitnode/src/content/server/translation-editorial-service.ts @@ -163,14 +163,14 @@ export const createContentTranslationEditorialService = < if (!definition.localization.enabled) { throw new ContentEngineError( - "The translation editorial service needs `localization: { enabled: true, defaultLocale }` on the content type.", + "The translation editorial service needs `localization: { defaultLocale }` on the content type.", { contentTypeId }, ); } if (!definition.editorial.enabled) { throw new ContentEngineError( - "The translation editorial service needs `editorial: { enabled: true }` on the content type - without it there is no revision history for a translation to write to.", + "The translation editorial service needs `editorial: true` on the content type - without it there is no revision history for a translation to write to.", { contentTypeId }, ); } diff --git a/packages/vitnode/src/content/server/translation-model.ts b/packages/vitnode/src/content/server/translation-model.ts index d297945685..b9efd32032 100644 --- a/packages/vitnode/src/content/server/translation-model.ts +++ b/packages/vitnode/src/content/server/translation-model.ts @@ -198,7 +198,7 @@ export const createContentTranslationModel = < if (!definition.localization.enabled) { throw new ContentEngineError( - "The translation service needs `localization: { enabled: true, defaultLocale }` on the content type.", + "The translation service needs `localization: { defaultLocale }` on the content type.", { contentTypeId }, ); } @@ -318,7 +318,7 @@ export const createContentTranslationModel = < const statusColumn = (): PgColumn => { if (!publication) { throw new ContentEngineError( - "Translations can only be published on a content type with `publication: { enabled: true }` - without it there is no status column for a translation status to be subordinate to.", + "Translations can only be published on a content type with `publication: true` - without it there is no status column for a translation status to be subordinate to.", { contentTypeId }, ); } diff --git a/packages/vitnode/src/content/server/translation-routes.ts b/packages/vitnode/src/content/server/translation-routes.ts index ca4a172c50..1c4fe1d515 100644 --- a/packages/vitnode/src/content/server/translation-routes.ts +++ b/packages/vitnode/src/content/server/translation-routes.ts @@ -25,6 +25,8 @@ import { CONTENT_TRANSLATION_REVISION_OPERATIONS, } from "../const"; import { resolveContentActor } from "./actor"; +import { commitContentDrafts } from "./live-store"; +import { resetContentLiveRecord } from "./live/hooks"; import { contentLocaleRouting } from "./locale-routing"; import { assertContentPreviewIsServable, @@ -267,6 +269,7 @@ export const buildContentTranslationRoutes = < }, }, handler: async c => { + const savedFrom = new Date(); const id = identifier(c); const target = locale(c); const { expectedVersion, values } = await readJson( @@ -305,6 +308,14 @@ export const buildContentTranslationRoutes = < await announce(c, outcome); + if (definition.liveEditing.enabled) { + await commitContentDrafts( + c, + { contentTypeId: definition.id, itemId: id }, + { locales: [target], savedFrom }, + ); + } + return c.json({ changed: outcome.changed, row: outcome.row }, 200); }, }); @@ -629,6 +640,11 @@ export const buildContentTranslationRoutes = < } await announce(c, outcome); + await resetContentLiveRecord( + c, + { contentTypeId: definition.id, itemId: id }, + "restored", + ); return c.json({ changed: outcome.changed, row: outcome.row }, 200); }, diff --git a/packages/vitnode/src/content/server/translation-table.test.ts b/packages/vitnode/src/content/server/translation-table.test.ts index 054c3e4701..e8e7528070 100644 --- a/packages/vitnode/src/content/server/translation-table.test.ts +++ b/packages/vitnode/src/content/server/translation-table.test.ts @@ -270,7 +270,7 @@ describe("a content type without localization", () => { createContentTranslationTable(testPostContentType, { table: posts.table, }), - ).toThrow(/needs `localization: \{ enabled: true, defaultLocale \}`/); + ).toThrow(/needs `localization: \{ defaultLocale \}`/); }); }); diff --git a/packages/vitnode/src/content/server/translation-table.ts b/packages/vitnode/src/content/server/translation-table.ts index 527eac3609..9aba9ae03c 100644 --- a/packages/vitnode/src/content/server/translation-table.ts +++ b/packages/vitnode/src/content/server/translation-table.ts @@ -37,7 +37,7 @@ export const createContentTranslationTable = < if (!localization.enabled) { throw new ContentEngineError( - "createContentTranslationTable needs `localization: { enabled: true, defaultLocale }` on the content type.", + "createContentTranslationTable needs `localization: { defaultLocale }` on the content type.", { contentTypeId }, ); } diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index c65d111482..8196ed6d30 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -1,9 +1,11 @@ +import type { $Type } from "drizzle-orm"; import type { AnyPgColumnBuilder, PgBooleanBuilder, PgBuildColumns, PgDoublePrecisionBuilder, PgIntegerBuilder, + PgJsonbBuilder, PgSerialBuilder, PgTableWithColumns, PgTextBuilder, @@ -13,6 +15,7 @@ import type { SetNotNull, } from "drizzle-orm/pg-core"; +import type { RichTextDocument } from "../rich-text/document"; import type { ContentEditorialField, ContentFieldsOf, @@ -46,7 +49,9 @@ type BaseBuilderFor<TField> = TField extends { kind: "boolean" } ? PgIntegerBuilder : TField extends { kind: "textarea" } ? PgTextBuilder - : PgVarcharBuilder; + : TField extends { kind: "richText" } + ? $Type<PgJsonbBuilder, RichTextDocument> + : PgVarcharBuilder; type ApplyDefault<TBuilder extends AnyPgColumnBuilder, TField> = HasColumnDefault<TField> extends true ? SetHasDefault<TBuilder> : TBuilder; diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index a9cfb7b5a1..389aa59f55 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -18,6 +18,7 @@ import type { CONTENT_TRANSLATION_SYSTEM_FIELDS, } from "./const"; import type { ContentFileDescriptor } from "./files"; +import type { RichTextDocument } from "./rich-text/document"; import type { ContentSchemas } from "./schemas"; export type ContentSystemField = (typeof CONTENT_SYSTEM_FIELDS)[number]; @@ -119,6 +120,16 @@ export interface ContentTextareaField< minLength?: number; } +export interface ContentRichTextField< + TRequired extends boolean = boolean, + TNullable extends boolean = boolean, + TLocalized extends boolean = boolean, +> extends ContentFieldShared<TRequired, TNullable> { + kind: "richText"; + localized: TLocalized; + maxBytes?: number; +} + export interface ContentNumberField< TRequired extends boolean = boolean, TNullable extends boolean = boolean, @@ -276,6 +287,7 @@ export type ContentFieldDescriptor = | ContentNumberField | ContentRelationField | ContentRepeatableField + | ContentRichTextField | ContentSlugField | ContentTextareaField | ContentTextField @@ -328,7 +340,9 @@ type ScalarFieldValue<TField> = TField extends { kind: "boolean" } ? TValue : TField extends { kind: "file" | "number" | "relation" | "user" } ? number - : string; + : TField extends { kind: "richText" } + ? RichTextDocument + : string; /** The scalar half of {@link ContentFieldInput}. `dateTime` crosses as ISO. */ type ScalarFieldInput<TField> = TField extends { kind: "boolean" } @@ -339,7 +353,9 @@ type ScalarFieldInput<TField> = TField extends { kind: "boolean" } ? TValue : TField extends { kind: "file" | "number" | "relation" | "user" } ? number - : string; + : TField extends { kind: "richText" } + ? RichTextDocument + : string; /** Every leaf of a group, as it comes back. Nested, never flattened. */ type ContentGroupValue<TFields> = Prettify<{ @@ -745,7 +761,7 @@ export interface ContentAdminConfig< sections?: ContentAdminFormSection<TFields>[]; }; list?: ContentAdminListConfig<TFields, TPublication, TEditorial>; - navigation?: { enabled?: boolean }; + navigation?: boolean; /** * Where the generated screens live under `/admin/content/`. Defaults to the id * with its dots as slashes: `blog.post` -> `blog/post`. @@ -895,17 +911,6 @@ export interface ResolvedContentIndex { // Publication // --------------------------------------------------------------------------- -/** - * Opts a content type into the draft/published lifecycle. - * - * `enabled` is literal `true` rather than `boolean` so the flag survives - * inference: every conditional in this file keys off `{ enabled: true }`, and a - * widened `boolean` would silently resolve to the disabled branch. - */ -export interface ContentPublicationConfig { - enabled: true; -} - export interface ResolvedContentPublicationConfig< TEnabled extends boolean = boolean, > { @@ -965,13 +970,9 @@ type ExposableFlatFieldKeys<TFields> = Exclude< /** * Opts a content type into a generated, read-only public API. * - * Requires `publication: { enabled: true }` and exactly one exposed slug field, - * both checked at definition time. Enabling publication on its own never makes - * anything public - this block is the only thing that does. - * - * `enabled` is literal `true` for the same reason publication's is: every - * conditional keys off it, and a widened `boolean` would silently resolve to - * "no public API". + * Requires `publication: true` and exactly one exposed slug field, both checked + * at definition time. Enabling publication on its own never makes anything + * public - this block is the only thing that does, by being present. */ export interface ContentPublicApiConfig< TField extends string = string, @@ -980,7 +981,6 @@ export interface ContentPublicApiConfig< defaultOrder?: "asc" | "desc"; /** Defaults to `publishedAt`. Must be orderable. */ defaultOrderBy?: "publishedAt" | TField; - enabled: true; /** The allowlist. There is no wildcard - a new field is private until listed. */ fields: readonly [TField, ...TField[]]; /** Equality filters the list route accepts. Defaults to none. */ @@ -1167,9 +1167,6 @@ export type ContentSearchTextField< * private value would otherwise leak through a result snippet, a highlighted * match, ranking, or the mere fact that a record matched an exact-match probe. * - * `enabled` is literal `true` for the same reason publication's and publicApi's - * are: a widened `boolean` would silently resolve to "no search". - * * Generic over the three field-name *unions* rather than over the field map, so * `defineContentType` can infer each one from the literal it was given and then * check it against `ContentSearchTitleField` and friends. Spelling those out @@ -1188,23 +1185,17 @@ export interface ContentSearchConfig< contentFields: readonly [TText, ...TText[]]; /** Prepended to the indexed body so it shows up in result excerpts. */ descriptionField?: TDescription; - enabled: true; pathTemplate: string; /** The result heading. Weighted above the body by the index. */ titleField: TTitle; } -/** - * Whether a `search` argument opted in. - * - * `defineContentType` infers the whole `search` object as one type parameter - - * an intersection member like `{ enabled: TEnabled }` is not an inference site, - * so reading the literal back off the argument is the only way to keep it. - */ -export type ContentSearchEnabled<TSearch> = TSearch extends { enabled: true } +export type ContentOptionEnabled<TOption> = [TOption] extends [object | true] ? true : false; +export type ContentSearchEnabled<TSearch> = ContentOptionEnabled<TSearch>; + /** * `search` after `defineContentType` has filled in every default. * @@ -1328,23 +1319,9 @@ export interface ContentDeliverySeoConfig< titleField?: TTitle; } -/** - * Automatic redirects from a record's historical public URLs. - * - * Needs a slug field, which `publicApi` already guarantees. What it adds is - * persistence: every slug that was ever *publicly addressable* is written to - * `core_content_slug_history`, which is what makes an old URL resolvable after - * the row has moved on - and what reserves it, so unrelated content cannot - * quietly inherit somebody else's incoming links. - */ -export interface ContentDeliveryRedirectsConfig { - enabled: true; -} - export interface ContentDeliverySitemapConfig { /** One of the seven `changefreq` values the protocol defines. */ changeFrequency?: ContentSitemapChangeFrequency; - enabled: true; /** `0` to `1` inclusive. */ priority?: number; } @@ -1367,43 +1344,48 @@ export interface ContentDeliveryHreflangConfig { * Opts a content type into the delivery layer: canonical URLs, slug history, * redirects, localized alternates, SEO projection and sitemap entries. * - * Requires `publicApi: { enabled: true }`, checked at compile time through - * `TPublicEnabled` and again at definition time - a content type with no public - * API has no public URL, so there is nothing for delivery to be about. + * `redirects: true` writes every slug that was ever publicly addressable to + * `core_content_slug_history`, so an old URL keeps resolving after the row has + * moved on, and unrelated content cannot inherit somebody else's incoming links. + * It needs `editorial`, checked at compile time through `TEditorialEnabled`. * - * `enabled` is literal `true` for the same reason every other opt-in's is: every - * conditional keys off `{ enabled: true }`, and a widened `boolean` would - * silently resolve to "no delivery". + * Delivery itself requires a public API, checked at compile time by + * `ContentDeliveryInput` and again at definition time - a content type with no + * public API has no public URL, so there is nothing for delivery to be about. */ export interface ContentDeliveryConfig< - // Both flags default to `true` rather than `boolean`, which is what keeps the bare - // `ContentDeliveryConfig` usable as a widened parameter type: `boolean extends - // true` is false, so a `boolean` default would resolve `enabled` to `never` and - // make the erased form describe a config nobody can write. - TPublicEnabled extends boolean = true, TEditorialEnabled extends boolean = true, TTitle extends string = string, TDescription extends string = string, TNoIndex extends string = string, > { - enabled: TPublicEnabled extends true ? true : never; hreflang?: ContentDeliveryHreflangConfig; path?: string; - - redirects?: TPublicEnabled extends true - ? TEditorialEnabled extends true - ? ContentDeliveryRedirectsConfig | { enabled: false } - : { enabled: false } - : { enabled: false }; + redirects?: TEditorialEnabled extends true ? boolean : false; seo?: ContentDeliverySeoConfig<TTitle, TDescription, TNoIndex>; - sitemap?: ContentDeliverySitemapConfig | { enabled: false }; + sitemap?: boolean | ContentDeliverySitemapConfig; } -export type ContentDeliveryEnabled<TDelivery> = TDelivery extends { - enabled: true; -} - ? true - : false; +export type ContentDeliveryInput< + TPublicEnabled extends boolean = true, + TEditorialEnabled extends boolean = true, + TTitle extends string = string, + TDescription extends string = string, + TNoIndex extends string = string, +> = + | false + | (TPublicEnabled extends true + ? + | ContentDeliveryConfig< + TEditorialEnabled, + TTitle, + TDescription, + TNoIndex + > + | true + : never); + +export type ContentDeliveryEnabled<TDelivery> = ContentOptionEnabled<TDelivery>; /** `delivery.seo` after `defineContentType` has filled in every default. */ export interface ResolvedContentDeliverySeoConfig { @@ -1446,47 +1428,39 @@ export interface ContentEditorialRevisionsConfig { } export interface ContentEditorialPreviewConfig { - enabled: true; /** How long a link stays valid. 1-1440 minutes, defaults to 15. */ expiresInMinutes?: number; pathTemplate?: string; } -export interface ContentEditorialSchedulingConfig { - enabled: true; -} - export interface ContentEditorialConfig< TPublicEnabled extends boolean = boolean, TPublication extends boolean = boolean, > { - enabled: true; preview?: TPublicEnabled extends true - ? ContentEditorialPreviewConfig | { enabled: false } - : { enabled: false }; + ? boolean | ContentEditorialPreviewConfig + : false; revisions?: ContentEditorialRevisionsConfig; - scheduling?: TPublication extends true - ? ContentEditorialSchedulingConfig | { enabled: false } - : { enabled: false }; + scheduling?: TPublication extends true ? boolean : false; } -export type ContentEditorialEnabled<TEditorial> = TEditorial extends { - enabled: true; -} - ? true - : false; +export type ContentEditorialInput< + TPublicEnabled extends boolean = boolean, + TPublication extends boolean = boolean, +> = boolean | ContentEditorialConfig<TPublicEnabled, TPublication>; + +export type ContentEditorialEnabled<TEditorial> = + ContentOptionEnabled<TEditorial>; export type ContentPreviewEnabled<TEditorial> = TEditorial extends { - enabled: true; - preview: { enabled: true }; + preview: infer TPreview; } - ? true + ? ContentOptionEnabled<TPreview> : false; export type ContentSchedulingEnabled<TEditorial> = TEditorial extends { - enabled: true; - scheduling: { enabled: true }; + scheduling: true; } ? true : false; @@ -1522,7 +1496,6 @@ export type ContentLocalizationFallback = export interface ContentLocalizationConfig { defaultLocale: string; - enabled: true; fallback?: ContentLocalizationFallback; } @@ -1539,11 +1512,8 @@ export interface ResolvedContentLocalizationConfig< translationTableName: string; } -export type ContentLocalizationEnabled<TLocalization> = TLocalization extends { - enabled: true; -} - ? true - : false; +export type ContentLocalizationEnabled<TLocalization> = + ContentOptionEnabled<TLocalization>; /** Field names whose value lives in the translation table. */ export type ContentLocalizedFieldName<TDefinition> = LocalizedFieldKeys< @@ -1698,6 +1668,7 @@ export interface ContentTypeDefinition< id: TId; /** Declared indexes plus the automatic ones, deduplicated and named. */ indexes: ResolvedContentIndex[]; + liveEditing: { enabled: boolean }; /** * Per-language content, or the disabled default when `localization` is * omitted. diff --git a/packages/vitnode/src/database/content-documents.ts b/packages/vitnode/src/database/content-documents.ts new file mode 100644 index 0000000000..504f50503e --- /dev/null +++ b/packages/vitnode/src/database/content-documents.ts @@ -0,0 +1,45 @@ +import { camelCase, customType, index, uniqueIndex } from "drizzle-orm/pg-core"; + +import { core_users } from "./users"; + +const bytea = customType<{ data: Uint8Array; driverData: Buffer }>({ + dataType() { + return "bytea"; + }, + fromDriver(value) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + }, + toDriver(value) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + }, +}); + +export const core_content_documents = camelCase.table.withRLS( + "core_content_documents", + t => ({ + id: t.serial().primaryKey(), + contentTypeId: t.varchar({ length: 100 }).notNull(), + itemId: t.integer().notNull(), + field: t.varchar({ length: 100 }).notNull(), + language: t.varchar({ length: 35 }).notNull().default(""), + state: bytea().notNull(), + baseVersion: t.integer(), + updatedAt: t.timestamp().notNull().defaultNow(), + updatedBy: t.integer().references(() => core_users.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + }), + t => [ + uniqueIndex("core_content_documents_doc_unique").on( + t.contentTypeId, + t.itemId, + t.field, + t.language, + ), + index("core_content_documents_updated_at_idx").on(t.updatedAt), + index("core_content_documents_updated_by_idx").on(t.updatedBy), + ], +); + +export type ContentDocumentRow = typeof core_content_documents.$inferSelect; diff --git a/packages/vitnode/src/database/content-drafts.ts b/packages/vitnode/src/database/content-drafts.ts new file mode 100644 index 0000000000..7c997f2aef --- /dev/null +++ b/packages/vitnode/src/database/content-drafts.ts @@ -0,0 +1,74 @@ +import { camelCase, index, uniqueIndex } from "drizzle-orm/pg-core"; + +import { CONTENT_LOCALE_MAX_LENGTH } from "../content/const"; +import { core_users } from "./users"; + +export const core_content_field_locks = camelCase.table.withRLS( + "core_content_field_locks", + t => ({ + id: t.serial().primaryKey(), + contentTypeId: t.varchar({ length: 100 }).notNull(), + itemId: t.integer().notNull(), + field: t.varchar({ length: 100 }).notNull(), + language: t + .varchar({ length: CONTENT_LOCALE_MAX_LENGTH }) + .notNull() + .default(""), + userId: t + .integer() + .notNull() + .references(() => core_users.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + acquiredAt: t.timestamp().notNull().defaultNow(), + expiresAt: t.timestamp().notNull(), + }), + t => [ + uniqueIndex("core_content_field_locks_key_unique").on( + t.contentTypeId, + t.itemId, + t.field, + t.language, + ), + index("core_content_field_locks_expires_at_idx").on(t.expiresAt), + index("core_content_field_locks_user_id_idx").on(t.userId), + ], +); + +export type ContentFieldLockRow = typeof core_content_field_locks.$inferSelect; + +export const core_content_drafts = camelCase.table.withRLS( + "core_content_drafts", + t => ({ + id: t.serial().primaryKey(), + contentTypeId: t.varchar({ length: 100 }).notNull(), + itemId: t.integer().notNull(), + language: t + .varchar({ length: CONTENT_LOCALE_MAX_LENGTH }) + .notNull() + .default(""), + values: t.jsonb().$type<Record<string, unknown>>().notNull().default({}), + baseVersion: t.integer().notNull().default(0), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + updatedBy: t.integer().references(() => core_users.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + }), + t => [ + uniqueIndex("core_content_drafts_key_unique").on( + t.contentTypeId, + t.itemId, + t.language, + ), + index("core_content_drafts_updated_at_idx").on(t.updatedAt), + index("core_content_drafts_updated_by_idx").on(t.updatedBy), + ], +); + +export type ContentDraftRow = typeof core_content_drafts.$inferSelect; diff --git a/packages/vitnode/src/framework/plugin-routes/content-urls.test.ts b/packages/vitnode/src/framework/plugin-routes/content-urls.test.ts index 1802bd9d13..158f5d7687 100644 --- a/packages/vitnode/src/framework/plugin-routes/content-urls.test.ts +++ b/packages/vitnode/src/framework/plugin-routes/content-urls.test.ts @@ -153,7 +153,7 @@ describe("content URLs served by page routes", () => { ); expect(error.message).toContain('Add a page route at "/blog/:slug"'); expect(error.message).toContain("point delivery.path at a path"); - expect(error.message).toContain("`delivery: { enabled: false }`"); + expect(error.message).toContain("`delivery: false`"); expect(error.message).toContain('Declared in "@vitnode/blog/content".'); }); diff --git a/packages/vitnode/src/framework/plugin-routes/content-urls.ts b/packages/vitnode/src/framework/plugin-routes/content-urls.ts index e57af8a79b..190bfb353f 100644 --- a/packages/vitnode/src/framework/plugin-routes/content-urls.ts +++ b/packages/vitnode/src/framework/plugin-routes/content-urls.ts @@ -40,7 +40,7 @@ export interface AssertContentUrlsHavePagesOptions { } const disableHint: Record<ContentUrlSetting, string> = { - "delivery.path": "turn delivery off (`delivery: { enabled: false }`)", + "delivery.path": "turn delivery off (`delivery: false`)", "search.pathTemplate": "turn search off for this content type", }; diff --git a/packages/vitnode/src/lib/ai/translation-freshness.test.ts b/packages/vitnode/src/lib/ai/translation-freshness.test.ts index f1672dfd40..7a60f102e7 100644 --- a/packages/vitnode/src/lib/ai/translation-freshness.test.ts +++ b/packages/vitnode/src/lib/ai/translation-freshness.test.ts @@ -66,4 +66,44 @@ describe("translationFreshness", () => { }), ).toEqual({ title: "untracked" }); }); + + it("reads rich text documents, telling an empty one from a translated one", () => { + const paragraph = (text: string) => ({ + content: [{ content: [{ text, type: "text" }], type: "paragraph" }], + type: "doc", + }); + const richValues = { + body: [ + { languageCode: "en", value: paragraph("New body") }, + { languageCode: "pl", value: paragraph("Stara treść") }, + ], + summary: [ + { languageCode: "en", value: paragraph("Short") }, + { + languageCode: "pl", + value: { content: [{ type: "paragraph" }], type: "doc" }, + }, + ], + }; + + expect( + translationFreshness({ + fields: ["body", "summary"], + locale: "pl", + records: [ + { + field: "body", + locale: "pl", + sourceFingerprint: fieldFingerprint( + [{ languageCode: "en", value: paragraph("Old body") }], + "en", + ), + targetFingerprint: fieldFingerprint(richValues.body, "pl"), + }, + ], + sourceLocale: "en", + values: richValues, + }), + ).toEqual({ body: "outdated", summary: "missing" }); + }); }); diff --git a/packages/vitnode/src/lib/ai/translation-freshness.ts b/packages/vitnode/src/lib/ai/translation-freshness.ts index a0d70692ed..4d3e41201c 100644 --- a/packages/vitnode/src/lib/ai/translation-freshness.ts +++ b/packages/vitnode/src/lib/ai/translation-freshness.ts @@ -1,5 +1,10 @@ import { fingerprint } from "@/content/hash"; -import { getLangValue } from "@/lib/helpers/multi-lang"; +import { + isRichTextDocument, + isRichTextEmpty, +} from "@/content/rich-text/document"; +import { richTextToHtml } from "@/content/rich-text/html"; +import { findLangValue } from "@/lib/helpers/multi-lang"; export type TranslationFreshness = | "edited" @@ -15,15 +20,18 @@ export interface TranslationSourceRecord { targetFingerprint: string; } -type FieldValue = - | string - | undefined - | { languageCode: string; value: string }[]; +const localeValue = (value: unknown, locale: string): unknown => + Array.isArray(value) ? findLangValue(value, locale) : value; -const textOf = (value: unknown, locale: string): string => - typeof value === "string" || Array.isArray(value) - ? getLangValue(value as FieldValue, locale) - : ""; +const textOf = (value: unknown, locale: string): string => { + const localized = localeValue(value, locale); + if (typeof localized === "string") return localized; + if (isRichTextDocument(localized) && !isRichTextEmpty(localized)) { + return richTextToHtml(localized); + } + + return ""; +}; export const fieldFingerprint = (value: unknown, locale: string): string => fingerprint(textOf(value, locale)); diff --git a/packages/vitnode/src/lib/docs-links.ts b/packages/vitnode/src/lib/docs-links.ts index db73e482c0..d54ad6e004 100644 --- a/packages/vitnode/src/lib/docs-links.ts +++ b/packages/vitnode/src/lib/docs-links.ts @@ -2,7 +2,7 @@ export const DOCS_URLS = { ai: "https://vitnode.com/docs/dev/ai", captcha: "https://vitnode.com/docs/dev/captcha", contentPreview: - "https://vitnode.com/docs/dev/content-engine/publication-and-editorial#preview-links", + "https://vitnode.com/docs/content-engine/editorial#preview-links", cron: "https://vitnode.com/docs/dev/cron", email: "https://vitnode.com/docs/dev/email", queue: "https://vitnode.com/docs/dev/advanced/queue", diff --git a/packages/vitnode/src/lib/helpers/multi-lang.ts b/packages/vitnode/src/lib/helpers/multi-lang.ts index 6186782a39..078f09ee7b 100644 --- a/packages/vitnode/src/lib/helpers/multi-lang.ts +++ b/packages/vitnode/src/lib/helpers/multi-lang.ts @@ -4,12 +4,12 @@ import type { InputParams } from "./auto-form"; import { getNestedParam } from "./auto-form"; -export interface MultiLangValueItem { +export interface MultiLangValueItem<TValue = string> { languageCode: string; - value: string; + value: TValue; } -export type MultiLangValue = MultiLangValueItem[]; +export type MultiLangValue<TValue = string> = MultiLangValueItem<TValue>[]; export const multiLangValueSchema = ({ maxLength, @@ -49,20 +49,26 @@ export const getLangValue = ( ); }; -const hasText = (text: string): boolean => text.trim() !== ""; +export const findLangValue = <TValue>( + value: MultiLangValue<TValue> | undefined, + languageCode: string, +): TValue | undefined => + (Array.isArray(value) ? value : []).find( + item => item.languageCode === languageCode, + )?.value; -export const pickLangCode = ({ +export const hasText = (text: string): boolean => text.trim() !== ""; + +export const pickLangCodeWhere = ({ defaultLanguage, - isFilled = hasText, + isFilledIn, languageCodes, locale, - value, }: { defaultLanguage?: null | string; - isFilled?: (text: string) => boolean; + isFilledIn: (languageCode: string) => boolean; languageCodes: readonly string[]; locale: string; - value: MultiLangValue | string | undefined; }): string => { const current = languageCodes.includes(locale) ? locale @@ -74,10 +80,30 @@ export const pickLangCode = ({ (code): code is string => typeof code === "string" && languageCodes.includes(code), ) - .find(code => isFilled(getLangValue(value, code))) ?? current + .find(isFilledIn) ?? current ); }; +export const pickLangCode = ({ + defaultLanguage, + isFilled = hasText, + languageCodes, + locale, + value, +}: { + defaultLanguage?: null | string; + isFilled?: (text: string) => boolean; + languageCodes: readonly string[]; + locale: string; + value: MultiLangValue | string | undefined; +}): string => + pickLangCodeWhere({ + defaultLanguage, + isFilledIn: code => isFilled(getLangValue(value, code)), + languageCodes, + locale, + }); + export const resolveLangValue = ( value: MultiLangValue | string | undefined, { @@ -98,11 +124,11 @@ export const resolveLangValue = ( }), ); -export const upsertLangValue = ( - value: MultiLangValue | undefined, +export const upsertLangValue = <TValue = string>( + value: MultiLangValue<TValue> | undefined, languageCode: string, - newValue: string, -): MultiLangValue => { + newValue: TValue, +): MultiLangValue<TValue> => { const current = Array.isArray(value) ? value : []; if (current.some(item => item.languageCode === languageCode)) { diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 82473aa549..e649d44c39 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -1627,6 +1627,52 @@ "errors": { "version_conflict": "Someone else saved this language while you were editing." } + }, + "live": { + "is_editing": "is editing", + "locked_desc": "Read-only while they edit. Their changes show up here as they type.", + "presence": { + "label": "{count, plural, one {# other person is} other {# other people are}} in this record", + "more": "and {count, plural, one {# more person} other {# more people}}: {names}", + "viewing": "looking around", + "in_language": "working in {language}", + "editing_field": "editing {field} ({language})", + "editing_shared": "editing {field}", + "field": "Also in this field: {names}", + "language": "{count, plural, one {{names} is working in this language} other {{names} are working in this language}}" + }, + "status": { + "saving": "Saving…", + "saved": "Draft saved {time}", + "saved_just_now": "Draft saved just now", + "saved_at": "Draft saved at {time}", + "unsaved": "Unsaved changes", + "failed": "Draft not saved", + "discard": "Discard", + "discard_label": "Discard unsaved changes", + "discard_title": "Discard unsaved changes?", + "discard_desc": "The record goes back to how it was last saved. Unsaved changes from everyone editing it are discarded too.", + "discard_confirm": "Discard changes", + "discard_failed": "The changes could not be discarded. Try again." + }, + "reset": { + "restored": { + "title": "This record was restored", + "desc": "Someone restored an earlier version, so the form now shows it. Unsaved drafts were cleared." + }, + "deleted": { + "title": "This record was deleted", + "desc": "Someone deleted it while you were editing. Nothing here can be saved anymore." + }, + "discarded": { + "title": "Unsaved changes were discarded", + "desc": "Someone editing this record went back to the saved version." + }, + "discarded_own": { + "title": "Changes discarded", + "desc": "The form is back to the saved version." + } + } } }, "editor": { diff --git a/packages/vitnode/src/pages/admin/content.tsx b/packages/vitnode/src/pages/admin/content.tsx index 3ce0861522..d209bc7e08 100644 --- a/packages/vitnode/src/pages/admin/content.tsx +++ b/packages/vitnode/src/pages/admin/content.tsx @@ -1,5 +1,3 @@ -import { use } from "react"; - import type { ContentFrontendRegistry } from "@/content/admin/registry"; import type { PluginRoutePageProps } from "@/routing"; import type { ContentListRouteSearch } from "@/tanstack/admin/content/route-search"; @@ -24,19 +22,9 @@ import { * * Deliberately *not* loader data: it carries every content type's editor fields * and form layouts, so returning it would serialise the whole thing into the - * SSR payload. A promise the component `use()`s is the same thing the hand-built - * route did by resolving it beside the screen's own chunk. + * SSR payload. */ -let registryPromise: Promise<ContentFrontendRegistry> | undefined; - -// Not `async`: `use()` suspends on promise *identity*, and an async wrapper -// would hand it a new promise on every render. -// oxlint-disable-next-line typescript/promise-function-async -const contentRegistry = (): Promise<ContentFrontendRegistry> => { - registryPromise ??= getContentRegistryLoader()(); - - return registryPromise; -}; +const registry: ContentFrontendRegistry = await getContentRegistryLoader()(); type ContentPageData = Awaited<ReturnType<typeof loadContentPage>>; @@ -49,7 +37,6 @@ const loadContentPage = async ({ params: Readonly<Record<string, string>>; search: ContentListRouteSearch; }) => { - const registry = await contentRegistry(); const resolved = await loadContentAdminRoute({ ...context, registry, @@ -71,7 +58,7 @@ const ContentAdminPage = ({ <ContentAdminScreenContent {...loaderData} navigate={navigate} - registry={use(contentRegistry())} + registry={registry} search={search} /> ); diff --git a/packages/vitnode/src/tanstack/admin/content/form/transport.ts b/packages/vitnode/src/tanstack/admin/content/form/transport.ts index ba84298aab..2f69b04966 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/transport.ts +++ b/packages/vitnode/src/tanstack/admin/content/form/transport.ts @@ -18,6 +18,13 @@ import { readContentRowInBrowser, setContentPublishedInBrowser, } from "@/views/admin/views/content/form/mutations-api"; +import { + changeContentLockInBrowser, + discardContentDraftInBrowser, + listContentLocksInBrowser, + readContentDraftInBrowser, + saveContentDraftInBrowser, +} from "@/views/admin/views/content/live/api"; import { contentApiTarget, invalidateContentAfterWrite } from "../query"; @@ -104,9 +111,19 @@ export const contentFormTransport = ( return result; }, + listLocks: async (contentTypeId, itemId) => + await listContentLocksInBrowser(targetFor(contentTypeId), itemId), + listTranslations: async (contentTypeId, itemId) => await listContentTranslationsInBrowser(targetFor(contentTypeId), itemId), + lock: async (contentTypeId, itemId, request) => + await changeContentLockInBrowser( + targetFor(contentTypeId), + itemId, + request, + ), + loadOptions: async (contentTypeId, field, search, ids) => await loadContentOptionsInBrowser( targetFor(contentTypeId), @@ -127,9 +144,18 @@ export const contentFormTransport = ( return result; }, + discardDraft: async (contentTypeId, itemId) => + await discardContentDraftInBrowser(targetFor(contentTypeId), itemId), + + readDraft: async (contentTypeId, itemId) => + await readContentDraftInBrowser(targetFor(contentTypeId), itemId), + reloadRow: async (contentTypeId, itemId) => await readContentRowInBrowser(targetFor(contentTypeId), itemId), + saveDraft: async (contentTypeId, itemId, draft) => + await saveContentDraftInBrowser(targetFor(contentTypeId), itemId, draft), + unpublish: async (contentTypeId, itemId) => { const result = await setContentPublishedInBrowser( targetFor(contentTypeId), diff --git a/packages/vitnode/src/tanstack/admin/content/query.test.ts b/packages/vitnode/src/tanstack/admin/content/query.test.ts index db2757151f..b7902b95c1 100644 --- a/packages/vitnode/src/tanstack/admin/content/query.test.ts +++ b/packages/vitnode/src/tanstack/admin/content/query.test.ts @@ -34,7 +34,7 @@ const articles = defineContentType({ const pages = defineContentType({ id: "blog.page", tableName: "blog_page", - localization: { enabled: true, defaultLocale: "en", fallback: "default" }, + localization: { defaultLocale: "en", fallback: "default" }, fields: { heading: field.text({ localized: true, required: true }) }, }) as AnyContentTypeDefinition; diff --git a/packages/vitnode/src/tanstack/admin/content/route-search.test.ts b/packages/vitnode/src/tanstack/admin/content/route-search.test.ts index 66816251cf..e09ef5dee9 100644 --- a/packages/vitnode/src/tanstack/admin/content/route-search.test.ts +++ b/packages/vitnode/src/tanstack/admin/content/route-search.test.ts @@ -31,7 +31,7 @@ const articles = defineContentType({ admin: { list: { orderableFields: ["title"], searchableFields: ["title"] }, }, - publication: { enabled: true }, + publication: true, }) as AnyContentTypeDefinition; /** No searchable fields, no publication - the narrowest possible contract. */ diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index 2e76c98824..c79914bf21 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -24,7 +24,7 @@ export const testCategoryContentType = defineContentType({ export const testLocalizedCategoryContentType = defineContentType({ id: "test.localized-category", tableName: "test_localized_categories", - localization: { enabled: true, defaultLocale: "en", fallback: "default" }, + localization: { defaultLocale: "en", fallback: "default" }, fields: { color: field.text({ maxLength: 50, nullable: true }), name: field.text({ @@ -113,11 +113,10 @@ export const testPostContentType = defineContentType({ target: () => testCategoryContentType, }), }, - publication: { enabled: true }, + publication: true, // `views` and `author` are deliberately absent from `fields`: they are the // "a private field never leaves Postgres" assertion in the public tests. publicApi: { - enabled: true, path: "posts", fields: ["title", "slug", "excerpt", "category", "publishedAt"], searchableFields: ["title", "excerpt"], @@ -145,22 +144,19 @@ export const testEditorialPostContentType = defineContentType({ excerpt: field.textarea({ maxLength: 500, nullable: true }), views: field.number({ integer: true, min: 0, defaultValue: 0 }), }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, path: "editorial", fields: ["title", "slug", "excerpt", "publishedAt"], defaultOrderBy: "publishedAt", }, editorial: { - enabled: true, revisions: { retention: 10 }, preview: { - enabled: true, expiresInMinutes: 30, pathTemplate: "/editorial/preview/{token}", }, - scheduling: { enabled: true }, + scheduling: true, }, admin: { titleField: "title", @@ -178,7 +174,8 @@ export const testEditorialNoteContentType = defineContentType({ title: field.text({ required: true, maxLength: 200 }), body: field.textarea({ nullable: true }), }, - editorial: { enabled: true }, + editorial: true, + liveEditing: true, }); export const testSearchablePostContentType = defineContentType({ @@ -193,15 +190,13 @@ export const testSearchablePostContentType = defineContentType({ views: field.number({ integer: true, min: 0, defaultValue: 0 }), author: field.user(), }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, path: "searchable", fields: ["title", "slug", "excerpt", "body", "publishedAt"], defaultOrderBy: "publishedAt", }, search: { - enabled: true, titleField: "title", descriptionField: "excerpt", contentFields: ["excerpt", "body"], @@ -217,7 +212,6 @@ export const testLocalizedArticleContentType = defineContentType({ id: "test.localized", tableName: "test_localized_articles", localization: { - enabled: true, defaultLocale: "en", fallback: "none", }, @@ -243,7 +237,7 @@ export const testLocalizedArticleContentType = defineContentType({ export const testLocalizedNoteContentType = defineContentType({ id: "test.localized-note", tableName: "test_localized_notes", - localization: { enabled: true, defaultLocale: "EN" }, + localization: { defaultLocale: "EN" }, fields: { heading: field.text({ localized: true, required: true }), slug: field.slug({ localized: true }), @@ -254,9 +248,10 @@ export const testLocalizedNoteContentType = defineContentType({ export const testLocalizedGuideContentType = defineContentType({ id: "test.localized-guide", tableName: "test_localized_guides", - editorial: { enabled: true, revisions: { retention: 5 } }, - localization: { enabled: true, defaultLocale: "en", fallback: "default" }, - publication: { enabled: true }, + editorial: { revisions: { retention: 5 } }, + liveEditing: true, + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, fields: { title: field.text({ localized: true, required: true, maxLength: 200 }), slug: field.slug({ localized: true, source: "title" }), @@ -273,12 +268,11 @@ export const testLocalizedPageContentType = defineContentType({ id: "test.localized-page", tableName: "test_localized_pages", editorial: { - enabled: true, - preview: { enabled: true, expiresInMinutes: 30 }, + preview: { expiresInMinutes: 30 }, revisions: { retention: 5 }, }, - localization: { enabled: true, defaultLocale: "en", fallback: "default" }, - publication: { enabled: true }, + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, fields: { title: field.text({ localized: true, required: true, maxLength: 200 }), slug: field.slug({ localized: true, source: "title" }), @@ -286,7 +280,6 @@ export const testLocalizedPageContentType = defineContentType({ featured: field.boolean({ defaultValue: false }), }, publicApi: { - enabled: true, path: "localized-pages", fields: ["title", "slug", "body", "featured", "publishedAt"], searchableFields: ["title", "body"], @@ -306,15 +299,14 @@ export const testLocalizedPageContentType = defineContentType({ export const testStrictLocalizedPageContentType = defineContentType({ id: "test.strict-localized-page", tableName: "test_strict_localized_pages", - localization: { enabled: true, defaultLocale: "en", fallback: "none" }, - publication: { enabled: true }, + localization: { defaultLocale: "en", fallback: "none" }, + publication: true, fields: { title: field.text({ localized: true, required: true, maxLength: 200 }), slug: field.slug({ localized: true, source: "title" }), featured: field.boolean({ defaultValue: false }), }, publicApi: { - enabled: true, path: "strict-localized-pages", fields: ["title", "slug", "featured", "publishedAt"], searchableFields: ["title"], @@ -329,8 +321,8 @@ export const testStrictLocalizedPageContentType = defineContentType({ export const testLocalizedSearchPageContentType = defineContentType({ id: "test.localized-search-page", tableName: "test_localized_search_pages", - localization: { enabled: true, defaultLocale: "en", fallback: "default" }, - publication: { enabled: true }, + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, fields: { title: field.text({ localized: true, required: true, maxLength: 200 }), slug: field.slug({ localized: true, source: "title" }), @@ -338,7 +330,6 @@ export const testLocalizedSearchPageContentType = defineContentType({ featured: field.boolean({ defaultValue: false }), }, publicApi: { - enabled: true, path: "pages", fields: ["title", "slug", "body", "featured", "publishedAt"], searchableFields: ["title", "body"], @@ -346,7 +337,6 @@ export const testLocalizedSearchPageContentType = defineContentType({ filterableFields: ["featured"], }, search: { - enabled: true, titleField: "title", contentFields: ["title", "body"], pathTemplate: "/{locale}/pages/{slug}", @@ -359,19 +349,17 @@ export const testLocalizedSearchPageContentType = defineContentType({ export const testStrictLocalizedSearchPageContentType = defineContentType({ id: "test.strict-localized-search-page", tableName: "test_strict_localized_search_pages", - localization: { enabled: true, defaultLocale: "en", fallback: "none" }, - publication: { enabled: true }, + localization: { defaultLocale: "en", fallback: "none" }, + publication: true, fields: { title: field.text({ localized: true, required: true, maxLength: 200 }), slug: field.slug({ localized: true, source: "title" }), }, publicApi: { - enabled: true, path: "strict-pages", fields: ["title", "slug", "publishedAt"], }, search: { - enabled: true, titleField: "title", contentFields: ["title"], pathTemplate: "/strict-pages/{slug}", @@ -381,9 +369,9 @@ export const testStrictLocalizedSearchPageContentType = defineContentType({ export const testAdvancedLocalizedContentType = defineContentType({ id: "test.advanced-localized", tableName: "test_advanced_localized", - localization: { enabled: true, defaultLocale: "en", fallback: "default" }, - publication: { enabled: true }, - editorial: { enabled: true }, + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, + editorial: true, fields: { title: field.text({ localized: true, required: true, maxLength: 200 }), slug: field.slug({ localized: true, source: "title" }), @@ -408,7 +396,6 @@ export const testAdvancedLocalizedContentType = defineContentType({ }), }, publicApi: { - enabled: true, path: "advanced-localized", fields: [ "title", @@ -424,7 +411,6 @@ export const testAdvancedLocalizedContentType = defineContentType({ orderableFields: ["publishedAt"], }, search: { - enabled: true, titleField: "title", descriptionField: "seo.description", contentFields: ["title", "seo.description", "faq.question", "faq.answer"], @@ -444,33 +430,31 @@ export const testDeliveredPostContentType = defineContentType({ excerpt: field.textarea({ maxLength: 500, nullable: true }), hidden: field.boolean({ defaultValue: false }), }, - publication: { enabled: true }, - editorial: { enabled: true }, + publication: true, + editorial: true, publicApi: { - enabled: true, path: "delivered-posts", fields: ["id", "title", "slug", "excerpt", "hidden", "publishedAt"], defaultOrderBy: "publishedAt", }, delivery: { - enabled: true, - redirects: { enabled: true }, + redirects: true, seo: { titleField: "title", descriptionField: "excerpt", noIndexField: "hidden", openGraph: { titleField: "title", descriptionField: "excerpt" }, }, - sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, + sitemap: { changeFrequency: "weekly", priority: 0.7 }, }, }); export const testDeliveredLocalizedContentType = defineContentType({ id: "test.delivered-localized", tableName: "test_delivered_localized", - localization: { enabled: true, defaultLocale: "en", fallback: "default" }, - publication: { enabled: true }, - editorial: { enabled: true }, + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, + editorial: true, fields: { title: field.text({ localized: true, required: true, maxLength: 200 }), slug: field.slug({ localized: true, source: "title" }), @@ -484,7 +468,6 @@ export const testDeliveredLocalizedContentType = defineContentType({ }), }, publicApi: { - enabled: true, path: "delivered-localized", fields: [ "id", @@ -497,15 +480,14 @@ export const testDeliveredLocalizedContentType = defineContentType({ defaultOrderBy: "publishedAt", }, delivery: { - enabled: true, - redirects: { enabled: true }, + redirects: true, hreflang: { xDefault: "defaultLocale" }, seo: { titleField: "seo.title", fallbackTitleField: "title", descriptionField: "seo.description", }, - sitemap: { enabled: true, changeFrequency: "daily", priority: 0.5 }, + sitemap: { changeFrequency: "daily", priority: 0.5 }, }, admin: { list: { columns: ["status", "updatedAt"] }, @@ -515,26 +497,23 @@ export const testDeliveredLocalizedContentType = defineContentType({ export const testDeliveredPreviewableContentType = defineContentType({ id: "test.delivered-previewable", tableName: "test_delivered_previewable", - localization: { enabled: true, defaultLocale: "en", fallback: "default" }, - publication: { enabled: true }, + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, editorial: { - enabled: true, - preview: { enabled: true, expiresInMinutes: 30 }, + preview: { expiresInMinutes: 30 }, }, fields: { title: field.text({ localized: true, required: true, maxLength: 200 }), slug: field.slug({ localized: true, source: "title" }), }, publicApi: { - enabled: true, path: "delivered-previewable", fields: ["id", "title", "slug", "publishedAt"], defaultOrderBy: "publishedAt", }, delivery: { - enabled: true, - redirects: { enabled: true }, - sitemap: { enabled: true }, + redirects: true, + sitemap: true, }, admin: { list: { columns: ["status", "updatedAt"] }, @@ -584,9 +563,8 @@ export const testFilePostContentType = defineContentType({ allowedMimeTypes: ["application/pdf"], }), }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, path: "file-posts", fields: ["title", "slug", "cover", "publishedAt"], }, @@ -627,10 +605,9 @@ export const testFileGalleryContentType = defineContentType({ allowedMimeTypes: ["application/pdf"], }), }, - publication: { enabled: true }, - editorial: { enabled: true, revisions: { retention: 5 } }, + publication: true, + editorial: { revisions: { retention: 5 } }, publicApi: { - enabled: true, path: "file-galleries", fields: ["title", "slug", "cover", "gallery", "publishedAt"], }, diff --git a/packages/vitnode/src/tests/content-live.ts b/packages/vitnode/src/tests/content-live.ts new file mode 100644 index 0000000000..c1c655cd1c --- /dev/null +++ b/packages/vitnode/src/tests/content-live.ts @@ -0,0 +1,175 @@ +import type { Context } from "hono"; +import type { WSContext } from "hono/ws"; + +import type { + ContentLiveClientMessage, + ContentLiveDocRef, + ContentLiveServerMessage, +} from "@/content/live/protocol"; +import type { ContentLiveUser } from "@/content/server/live/auth"; +import type { ContentDocumentStore } from "@/content/server/live/documents"; + +import { createContentLiveServer } from "@/content/server/live/server"; +import { wsRegistry } from "@/ws/registry"; + +type ServerOptions = NonNullable<Parameters<typeof createContentLiveServer>[0]>; + +export const createFakeDocumentStore = () => { + const rows = new Map<string, Uint8Array>(); + const claims = new Set<string>(); + const keyOf = (doc: ContentLiveDocRef) => + `${doc.contentTypeId}:${doc.itemId}:${doc.field}:${doc.locale ?? ""}`; + const saves: { doc: ContentLiveDocRef; userId: null | number }[] = []; + + const store: ContentDocumentStore = { + claimSeed: async doc => { + const key = keyOf(doc); + if (rows.has(key) || claims.has(key)) return Promise.resolve(false); + claims.add(key); + + return Promise.resolve(true); + }, + deleteRecord: async room => { + const prefix = `${room.contentTypeId}:${room.itemId}:`; + for (const key of [...rows.keys(), ...claims]) { + if (!key.startsWith(prefix)) continue; + rows.delete(key); + claims.delete(key); + } + + return Promise.resolve(); + }, + load: async doc => Promise.resolve(rows.get(keyOf(doc)) ?? null), + releaseSeed: async doc => { + claims.delete(keyOf(doc)); + + return Promise.resolve(); + }, + save: async (doc, state, { userId }) => { + rows.set(keyOf(doc), state); + claims.delete(keyOf(doc)); + saves.push({ doc, userId }); + + return Promise.resolve(); + }, + }; + + return { + claims, + has: (doc: ContentLiveDocRef) => rows.has(keyOf(doc)), + saves, + store, + }; +}; + +export interface FakeLiveSocket { + c: Context; + listen: (listener: (message: ContentLiveServerMessage) => void) => () => void; + received: () => ContentLiveServerMessage[]; + ws: WSContext; +} + +export const createLiveHarness = (options: Partial<ServerOptions> = {}) => { + const users = new Map<Context, ContentLiveUser>(); + const denied = new Set<Context>(); + const sockets: WSContext[] = []; + const store = createFakeDocumentStore(); + const userLeft: { room: unknown; userId: number }[] = []; + + const server = createContentLiveServer({ + authorize: async c => { + const user = users.get(c); + if (!user || denied.has(c)) { + return Promise.resolve({ + code: "FORBIDDEN" as const, + ok: false as const, + }); + } + + return Promise.resolve({ ok: true as const, user, version: 3 }); + }, + createStore: () => store.store, + isDocValid: async (_c, doc) => Promise.resolve(doc.field === "content"), + userLeft: async ({ room, userId }) => { + userLeft.push({ room, userId }); + + return Promise.resolve(); + }, + ...options, + }); + const usesRealRegistry = !options.registry; + const stopClose = usesRealRegistry + ? wsRegistry.onConnectionClose(server.onConnectionClose) + : () => {}; + + const socket = (user: Partial<ContentLiveUser> & { id: number }) => { + const sent: { data: ContentLiveServerMessage; id: string }[] = []; + const listeners = new Set<(message: ContentLiveServerMessage) => void>(); + const ws = { + send: (raw: string) => { + const message = JSON.parse(raw) as { + data: ContentLiveServerMessage; + id: string; + }; + sent.push(message); + for (const listener of listeners) listener(message.data); + }, + } as unknown as WSContext; + const c = {} as Context; + users.set(c, { + avatarColor: null, + name: `user-${user.id}`, + nameCode: null, + ...user, + }); + if (usesRealRegistry) wsRegistry.add(ws, user.id); + sockets.push(ws); + + return { + c, + listen: listener => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + received: () => sent.map(message => message.data), + ws, + } satisfies FakeLiveSocket; + }; + + const send = async ( + target: FakeLiveSocket, + message: ContentLiveClientMessage | Record<string, unknown>, + ): Promise<void> => { + await server.handle({ c: target.c, data: message, ws: target.ws }); + }; + + return { + deny: (target: FakeLiveSocket) => denied.add(target.c), + disconnect: (target: FakeLiveSocket) => { + wsRegistry.remove(target.ws); + }, + send, + server, + socket, + stop: () => { + server.stop(); + stopClose(); + sockets.splice(0).forEach(ws => wsRegistry.remove(ws)); + }, + store, + userLeft, + }; +}; + +type MessageOfType<TType> = ContentLiveServerMessage & { type: TType }; + +export const ofType = <TType extends ContentLiveServerMessage["type"]>( + messages: ContentLiveServerMessage[], + type: TType, +): MessageOfType<TType>[] => + messages.filter( + (message): message is MessageOfType<TType> => message.type === type, + ); diff --git a/packages/vitnode/src/tests/memory-db.ts b/packages/vitnode/src/tests/memory-db.ts index fc5a51d645..db44f41bbd 100644 --- a/packages/vitnode/src/tests/memory-db.ts +++ b/packages/vitnode/src/tests/memory-db.ts @@ -70,7 +70,7 @@ const originalNameOf = (table: Table): string => { }; const WORD_PATTERN = - /\s*(>=|<=|<>|!=|::|[(),*=<>]|'(?:[^']|'')*'|"[^"]*"|\d+(?:\.\d+)?|[A-Za-z_][\w]*)/y; + /\s*(>=|<=|<>|!=|::|\|\||[(),*=<>]|'(?:[^']|'')*'|"[^"]*"|\d+(?:\.\d+)?|[A-Za-z_][\w]*)/y; const tokenizeText = (text: string): Token[] => { const tokens: Token[] = []; @@ -206,8 +206,23 @@ const parseTokens = (tokens: Token[], source: string) => { return node; }; + const parseConcat = (): Node => { + let node = parseCast(); + while (isWord("||")) { + position++; + node = { + kind: "compare", + left: node, + operator: "||", + right: parseCast(), + }; + } + + return node; + }; + const parseComparison = (): Node => { - const left = parseCast(); + const left = parseConcat(); const token = peek(); if (token?.kind !== "word") return left; @@ -218,7 +233,7 @@ const parseTokens = (tokens: Token[], source: string) => { kind: "compare", left, operator: token.value === "!=" ? "<>" : token.value, - right: parseCast(), + right: parseConcat(), }; } if (token.value === "is") { @@ -366,8 +381,21 @@ const textOf = (value: unknown): string => { const isMissing = (value: unknown): value is null | undefined => value === null || value === undefined; +const isPlainObject = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null && !Array.isArray(value); + const compare = (left: unknown, operator: string, right: unknown) => { if (isMissing(left) || isMissing(right)) return null; + if (operator === "||") { + if (isPlainObject(left) && isPlainObject(right)) { + return { ...left, ...right }; + } + if (typeof left === "string" && typeof right === "string") { + return left + right; + } + + return unsupported(`"||" between ${typeof left} and ${typeof right}`); + } if (operator === "=") return equal(left, right); if (operator === "<>") return !equal(left, right); const a = comparable(left) as number | string; diff --git a/packages/vitnode/src/tiptap.css b/packages/vitnode/src/tiptap.css index 98139b7fd3..e9cab2d734 100644 --- a/packages/vitnode/src/tiptap.css +++ b/packages/vitnode/src/tiptap.css @@ -192,3 +192,33 @@ @apply text-muted-foreground pointer-events-none float-start h-0 content-[attr(data-placeholder)]; } } + +/* Co-editing: another person's caret and the name above it. Colors come + inline from the person's avatar color. */ +.collaboration-carets__caret { + @apply pointer-events-none relative -mx-px border-x break-normal; +} + +.collaboration-carets__label { + @apply absolute start-0 -top-5 rounded-md rounded-es-none px-1.5 py-0.5 text-xs leading-none font-semibold whitespace-nowrap select-none; + transform-origin: bottom left; + animation: collaboration-caret-label-in 200ms cubic-bezier(0.22, 1, 0.36, 1) + backwards; +} + +.collaboration-carets__label:dir(rtl) { + transform-origin: bottom right; +} + +@keyframes collaboration-caret-label-in { + from { + opacity: 0; + scale: 0.9; + } +} + +@media (prefers-reduced-motion: reduce) { + .collaboration-carets__label { + animation: none; + } +} diff --git a/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx b/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx index df2b3023a5..d75591b02b 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx @@ -15,6 +15,8 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { isRichTextDocument } from "@/content/rich-text/document"; +import { richTextToPlainText } from "@/content/rich-text/plain-text"; export interface ContentConflictState { currentVersion: number; @@ -24,6 +26,7 @@ export interface ContentConflictState { const asText = (value: unknown): string => { if (value === null || value === undefined || value === "") return "—"; if (value instanceof Date) return value.toISOString(); + if (isRichTextDocument(value)) return richTextToPlainText(value) || "—"; // A row arrives as JSON, so a value is a primitive or it is something the // comparison has no opinion about - stringifying an object would compare diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx index 0473630f19..83d909f775 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -25,6 +25,7 @@ import type { TranslationRow } from "../content-mutation"; import type { ContentFormHeaderValue } from "../form/context"; import type { ContentFormSkeletonOverrides } from "../form/skeleton"; import type { ContentOptionsLoader } from "../lib/field-component"; +import type { ContentLiveFormState } from "../live/root"; import type { ContentConflictState } from "./conflict-notice"; import { ContentFormProvider } from "../form/context"; @@ -45,6 +46,12 @@ import { useContentFormTransport } from "../form/transport"; import { ContentField } from "../lib/field-component"; import { contentErrorKey } from "../lib/mutation-feedback"; import { useInvalidateContentOptions } from "../lib/options-query"; +import { useContentLive } from "../live/context"; +import { ContentLiveField } from "../live/field"; +import { ContentLivePresence } from "../live/presence"; +import { ContentLiveRoot } from "../live/root"; +import { ContentLiveStatus } from "../live/status"; +import { overlayContentDrafts } from "../live/values"; import { ConflictNotice } from "./conflict-notice"; export interface ContentFormProps { @@ -122,19 +129,40 @@ export const ContentForm = ({ }; }, [contentTypeId, data, pendingRow, transport]); - if (loaded === null || pendingRow) { + const skeleton = ( + <ContentFormSkeleton + contentTypeId={spec.contentTypeId} + header={props.presentation === "page" ? props.header : undefined} + layout={props.layout} + mode={data ? "edit" : "create"} + pluginId={spec.pluginId} + publication={props.publication} + shape={contentSpecSkeletonShape(spec, fieldSkeletons)} + singular={props.singular} + title={props.title} + /> + ); + + if (loaded === null || pendingRow) return skeleton; + + if (row && spec.liveEditing) { return ( - <ContentFormSkeleton - contentTypeId={spec.contentTypeId} - header={props.presentation === "page" ? props.header : undefined} - layout={props.layout} - mode={data ? "edit" : "create"} - pluginId={spec.pluginId} - publication={props.publication} - shape={contentSpecSkeletonShape(spec, fieldSkeletons)} - singular={props.singular} - title={props.title} - /> + <ContentLiveRoot + data={row} + fallback={skeleton} + spec={spec} + translations={loaded} + > + {live => ( + <ContentFormFields + data={live.row} + live={live} + spec={spec} + translations={live.translations} + {...props} + /> + )} + </ContentLiveRoot> ); } @@ -153,6 +181,7 @@ const ContentFormFields = ({ fieldOverrides = {}, header, layout, + live, onCreated, presentation = "dialog", publication = false, @@ -160,7 +189,9 @@ const ContentFormFields = ({ spec, title, translations = [], -}: ContentFormProps) => { +}: ContentFormProps & { + live?: ContentLiveFormState; +}) => { const t = useTranslations("core.content"); const tErrors = useTranslations("core.global.errors"); const tContentErrors = useTranslations("core.content.errors"); @@ -169,6 +200,7 @@ const ContentFormFields = ({ const transport = useContentFormTransport(); const locale = useLocale(); const invalidateOptions = useInvalidateContentOptions(); + const liveSession = useContentLive(); const canPublish = useAdminStaffPermission({ module: spec.permissionModule, permission: CONTENT_PERMISSIONS.publish, @@ -203,10 +235,15 @@ const ContentFormFields = ({ const [opened, setOpened] = React.useState(() => translations); - const values = React.useMemo( - () => contentFormInitialValues(spec, data, opened), - [spec, data, opened], - ); + const drafts = live?.drafts ?? null; + const draftLabels = live?.labels; + const values = React.useMemo(() => { + if (!data || !drafts) return contentFormInitialValues(spec, data, opened); + + const overlaid = overlayContentDrafts(data, opened, drafts, draftLabels); + + return contentFormInitialValues(spec, overlaid.data, overlaid.translations); + }, [spec, data, opened, drafts, draftLabels]); const formSchema = React.useMemo( () => buildFormSchemaFromSpec(spec, values), @@ -354,6 +391,7 @@ const ContentFormFields = ({ if (mutation.version !== undefined) setExpectedVersion(mutation.version); invalidateOptions(spec.contentTypeId); + await liveSession?.reloadDrafts(); const toastTitle = contentTitleFromValues(spec, submitted, locale) ?? @@ -425,22 +463,23 @@ const ContentFormFields = ({ // suspends on promise children, so the dialog spins forever. component: props => { const Override = fieldOverrides[fieldSpec.name]; - if (Override) { - return ( - <Override {...props} multiLang={fieldSpec.localized === true} /> - ); - } return ( - <ContentField - files={files} - loadOptions={loadOptions} - spec={fieldSpec} - uploadFile={async ({ field, file }) => - await uploadContentFile({ field, file, spec }) - } - {...props} - /> + <ContentLiveField field={props.field} fieldSpec={fieldSpec}> + {Override ? ( + <Override {...props} multiLang={fieldSpec.localized === true} /> + ) : ( + <ContentField + files={files} + loadOptions={loadOptions} + spec={fieldSpec} + uploadFile={async ({ field, file }) => + await uploadContentFile({ field, file, spec }) + } + {...props} + /> + )} + </ContentLiveField> ); }, }), @@ -506,6 +545,12 @@ const ContentFormFields = ({ ) : ( <> <ContentFormHeader /> + {presentation === "page" || !live ? null : ( + <div className="flex flex-wrap items-center gap-3"> + <ContentLivePresence /> + <ContentLiveStatus /> + </div> + )} {publication && data ? ( <ContentFormPublication diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx index abb560051a..84d8ea8854 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx @@ -12,6 +12,11 @@ import type { import { DateFormat } from "@/components/date-format"; import { Badge } from "@/components/ui/badge"; import { contentRevisionDiff } from "@/content/revisions"; +import { + isRichTextDocument, + isRichTextEmpty, +} from "@/content/rich-text/document"; +import { richTextToPlainText } from "@/content/rich-text/plain-text"; const TEXTAREA_PREVIEW_LINES = 8; @@ -25,6 +30,7 @@ const TOKEN_KINDS = new Set([ const isBlank = (value: ContentSnapshotValue | undefined): boolean => { if (value === null || value === undefined || value === "") return true; + if (isRichTextDocument(value)) return isRichTextEmpty(value); if (Array.isArray(value)) return value.length === 0; if (typeof value === "object") { @@ -42,6 +48,21 @@ const Empty = ({ label }: { label: string }) => ( </span> ); +const TextPreview = ({ text }: { text: string }) => { + const lines = text.split("\n"); + + return lines.length > TEXTAREA_PREVIEW_LINES ? ( + <details> + <summary className="cursor-pointer"> + {lines.slice(0, TEXTAREA_PREVIEW_LINES).join("\n")} + </summary> + <span className="whitespace-pre-wrap">{text}</span> + </details> + ) : ( + <span className="whitespace-pre-wrap">{text}</span> + ); +}; + const Value = ({ emptyLabel, kind, @@ -57,6 +78,10 @@ const Value = ({ }) => { if (isBlank(value)) return <Empty label={emptyLabel} />; + if (isRichTextDocument(value)) { + return <TextPreview text={richTextToPlainText(value)} />; + } + if (Array.isArray(value)) { if (typeof value[0] === "number") { return ( @@ -107,21 +132,8 @@ const Value = ({ case "user": return <span>{labels[String(value)] ?? `#${String(value)}`}</span>; - case "textarea": { - const text = String(value); - const lines = text.split("\n"); - - return lines.length > TEXTAREA_PREVIEW_LINES ? ( - <details> - <summary className="cursor-pointer"> - {lines.slice(0, TEXTAREA_PREVIEW_LINES).join("\n")} - </summary> - <span className="whitespace-pre-wrap">{text}</span> - </details> - ) : ( - <span className="whitespace-pre-wrap">{text}</span> - ); - } + case "textarea": + return <TextPreview text={String(value)} />; default: return <span className="wrap-break-word">{String(value)}</span>; diff --git a/packages/vitnode/src/views/admin/views/content/form/index.ts b/packages/vitnode/src/views/admin/views/content/form/index.ts index 26778b02c6..36b2aaaab7 100644 --- a/packages/vitnode/src/views/admin/views/content/form/index.ts +++ b/packages/vitnode/src/views/admin/views/content/form/index.ts @@ -1,3 +1,23 @@ +export { + type ContentLiveAutosaveStatus, + type ContentLiveContextValue, + useContentLive, +} from "../live/context"; +export { + ContentLiveFieldPresence, + ContentLiveLanguagePresence, + ContentLivePresence, +} from "../live/presence"; +export { + type ContentRichTextEditorHandle, + useContentRichTextEditor, + useContentRichTextReplace, +} from "../live/rich-text"; +export { ContentLiveStatus } from "../live/status"; +export type { + ContentLiveDraftEvent, + ContentLiveSession, +} from "../live/use-session"; export { type ContentFormContextValue, type ContentFormHeaderValue, diff --git a/packages/vitnode/src/views/admin/views/content/form/layout-primitives.tsx b/packages/vitnode/src/views/admin/views/content/form/layout-primitives.tsx index 5183130777..16e0db453d 100644 --- a/packages/vitnode/src/views/admin/views/content/form/layout-primitives.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/layout-primitives.tsx @@ -3,6 +3,9 @@ import React from "react"; import { PageTitle } from "@/components/ui/page-title"; +import { useContentLive } from "../live/context"; +import { ContentLivePresence } from "../live/presence"; +import { ContentLiveStatus } from "../live/status"; import { useContentForm } from "./context"; export const ContentFormHeader = ({ @@ -13,6 +16,7 @@ export const ContentFormHeader = ({ className?: string; }) => { const { header, markHeaderRendered } = useContentForm(); + const live = useContentLive(); markHeaderRendered?.(); @@ -22,7 +26,19 @@ export const ContentFormHeader = ({ <PageTitle back={header.back} className={className} - desc={header.desc} + desc={ + live ? ( + <div className="flex flex-col gap-1"> + {header.desc} + <div className="flex flex-wrap items-center gap-3"> + <ContentLivePresence /> + <ContentLiveStatus /> + </div> + </div> + ) : ( + header.desc + ) + } h1={header.title} > {children} diff --git a/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx b/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx index 4932922a55..987dd5e080 100644 --- a/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx @@ -49,6 +49,7 @@ const controlOfKind: Record<string, ContentFormSkeletonControl> = { number: "input", relation: "input", repeatable: "list", + richText: "editor", slug: "input", text: "input", textarea: "textarea", diff --git a/packages/vitnode/src/views/admin/views/content/form/transport.tsx b/packages/vitnode/src/views/admin/views/content/form/transport.tsx index 65bd7d7497..caf642a149 100644 --- a/packages/vitnode/src/views/admin/views/content/form/transport.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/transport.tsx @@ -1,5 +1,8 @@ import React from "react"; +import type { ContentDrafts } from "@/content/live/http"; +import type { ContentFieldLock } from "@/content/live/protocol"; + import type { ContentMutationResult, ContentRowResult, @@ -7,6 +10,11 @@ import type { TranslationRow, } from "../content-mutation"; import type { ContentOption } from "../lib/field-component"; +import type { + ContentDraftSaveResult, + ContentFieldLockAction, + ContentFieldLockResult, +} from "../live/api"; export interface ContentFormTransport { /** Creates a record from the shared fields alone. */ @@ -21,6 +29,11 @@ export interface ContentFormTransport { translations: ContentTranslationInput[], ) => Promise<ContentMutationResult>; + discardDraft: ( + contentTypeId: string, + itemId: number, + ) => Promise<{ error?: string; status: number }>; + edit: ( contentTypeId: string, itemId: number, @@ -36,6 +49,11 @@ export interface ContentFormTransport { expectedVersion?: number, ) => Promise<ContentMutationResult>; + listLocks: ( + contentTypeId: string, + itemId: number, + ) => Promise<{ error?: string; locks: ContentFieldLock[] }>; + listTranslations: ( contentTypeId: string, itemId: number, @@ -47,16 +65,37 @@ export interface ContentFormTransport { search: string, ids?: number[], ) => Promise<ContentOption[]>; + lock: ( + contentTypeId: string, + itemId: number, + request: { + action: ContentFieldLockAction; + field: string; + locale: null | string; + }, + ) => Promise<ContentFieldLockResult>; + /** Moves a record to `published`. Idempotent: a no-op is a success. */ publish: ( contentTypeId: string, itemId: number, ) => Promise<ContentMutationResult>; + readDraft: ( + contentTypeId: string, + itemId: number, + ) => Promise<{ drafts?: ContentDrafts; error?: string }>; + reloadRow: ( contentTypeId: string, itemId: number, ) => Promise<ContentRowResult>; + + saveDraft: ( + contentTypeId: string, + itemId: number, + draft: { locale: null | string; values: Record<string, unknown> }, + ) => Promise<ContentDraftSaveResult>; /** Moves a record back to `draft`. Idempotent, like {@link publish}. */ unpublish: ( contentTypeId: string, diff --git a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx index dc70321afc..b9b933c1c3 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx +++ b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { useLocale, useTranslations } from "use-intl"; import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; @@ -20,6 +21,7 @@ import { AutoFormSelect } from "@/components/form/fields/select"; import { AutoFormSwitch } from "@/components/form/fields/switch"; import { AutoFormTextarea } from "@/components/form/fields/textarea"; +import { ContentFormFieldSkeleton } from "../form/skeleton"; import { ContentGroupField } from "./group-field"; import { ContentOptionSwatch } from "./option-swatch"; import { contentOptionsQueryKey } from "./options-query"; @@ -71,6 +73,12 @@ const fileList = ( return Array.isArray(value) ? value : []; }; +const AutoFormEditor = React.lazy(async () => + import("@/components/form/fields/editor").then(module => ({ + default: module.AutoFormEditor, + })), +); + type ContentKindFieldProps = ItemAutoFormComponentProps & { spec: ContentFormFieldSpec; }; @@ -255,6 +263,20 @@ export const ContentField = ({ /> ); + case "richText": + return ( + <React.Suspense + fallback={<ContentFormFieldSkeleton control="editor" />} + > + <AutoFormEditor + format="json" + label={spec.label} + multiLang={multiLang} + {...props} + /> + </React.Suspense> + ); + case "textarea": return ( <AutoFormTextarea diff --git a/packages/vitnode/src/views/admin/views/content/lib/invalidate.test.ts b/packages/vitnode/src/views/admin/views/content/lib/invalidate.test.ts index 8679139b7f..f3ef368895 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/invalidate.test.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/invalidate.test.ts @@ -227,3 +227,31 @@ describe("the narrow helpers stay narrow", () => { } }); }); + +describe("inactive content queries", () => { + it("refetches a list nobody is watching, so the next loader read is fresh", async () => { + const queryClient = new QueryClient(); + const key = contentListQueryKey(TYPE, { first: 25 }); + let version = 0; + const queryFn = async () => await Promise.resolve({ version: ++version }); + + await queryClient.query({ queryFn, queryKey: key }); + await invalidateContentList(queryClient, TYPE); + + await expect( + queryClient.query({ queryFn, queryKey: key, staleTime: "static" }), + ).resolves.toEqual({ version: 2 }); + }); + + it("refetches a record nobody is watching", async () => { + const queryClient = new QueryClient(); + const key = contentItemQueryKey(TYPE, ITEM); + let version = 0; + const queryFn = async () => await Promise.resolve({ version: ++version }); + + await queryClient.query({ queryFn, queryKey: key }); + await invalidateContentItem(queryClient, TYPE, ITEM); + + expect(queryClient.getQueryData(key)).toEqual({ version: 2 }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/lib/invalidate.ts b/packages/vitnode/src/views/admin/views/content/lib/invalidate.ts index 84c91743b3..9b90d5b427 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/invalidate.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/invalidate.ts @@ -17,6 +17,7 @@ export const invalidateContentList = async ( ): Promise<void> => { await queryClient.invalidateQueries({ queryKey: contentListQueryRoot(contentTypeId), + refetchType: "all", }); }; @@ -27,6 +28,7 @@ export const invalidateContentItem = async ( ): Promise<void> => { await queryClient.invalidateQueries({ queryKey: contentItemQueryRoot(contentTypeId, itemId), + refetchType: "all", }); }; diff --git a/packages/vitnode/src/views/admin/views/content/live/api.ts b/packages/vitnode/src/views/admin/views/content/live/api.ts new file mode 100644 index 0000000000..45e2c55acc --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/api.ts @@ -0,0 +1,135 @@ +import type { + ContentDraftRejection, + ContentDrafts, + ContentFieldLockAction, +} from "@/content/live/http"; +import type { ContentFieldLock } from "@/content/live/protocol"; + +import { + zodContentDraftRejection, + zodContentDrafts, + zodContentDraftWritten, + zodContentFieldLocked, + zodContentFieldLockList, + zodContentFieldLockResponse, +} from "@/content/live/http"; + +import type { ContentApiTarget } from "../content-request"; + +import { sendContentApiRequest as send } from "../lib/api-result"; + +export type { ContentFieldLockAction }; + +export interface ContentFieldLockResult { + error?: string; + lock?: ContentFieldLock | null; + lockedBy?: ContentFieldLock | null; + status: number; +} + +export interface ContentDraftSaveResult { + error?: string; + rejection?: ContentDraftRejection; + status: number; + updatedAt?: string; +} + +const parseJson = (text: string | undefined): unknown => { + if (!text) return undefined; + + try { + return JSON.parse(text); + } catch { + return undefined; + } +}; + +export const listContentLocksInBrowser = async ( + target: ContentApiTarget, + id: number, +): Promise<{ error?: string; locks: ContentFieldLock[] }> => { + const result = await send( + { method: "get", path: `/${id}/locks`, target }, + zodContentFieldLockList, + ); + + return result.data + ? { locks: result.data.locks } + : { error: result.error ?? "", locks: [] }; +}; + +export const changeContentLockInBrowser = async ( + target: ContentApiTarget, + id: number, + body: { + action: ContentFieldLockAction; + field: string; + locale: null | string; + }, +): Promise<ContentFieldLockResult> => { + const result = await send( + { body, method: "post", path: `/${id}/locks`, target }, + zodContentFieldLockResponse, + ); + + if (result.data) return { lock: result.data.lock, status: result.status }; + + const locked = zodContentFieldLocked.safeParse(parseJson(result.error)); + + return { + error: result.error ?? "", + ...(locked.success ? { lockedBy: locked.data.lock } : {}), + status: result.status, + }; +}; + +export const readContentDraftInBrowser = async ( + target: ContentApiTarget, + id: number, +): Promise<{ drafts?: ContentDrafts; error?: string }> => { + const result = await send( + { method: "get", path: `/${id}/draft`, target }, + zodContentDrafts, + ); + + return result.data ? { drafts: result.data } : { error: result.error ?? "" }; +}; + +export const discardContentDraftInBrowser = async ( + target: ContentApiTarget, + id: number, +): Promise<{ error?: string; status: number }> => { + const result = await send({ + body: {}, + method: "post", + path: `/${id}/draft/discard`, + target, + }); + + return result.data + ? { status: result.status } + : { error: result.error ?? "", status: result.status }; +}; + +export const saveContentDraftInBrowser = async ( + target: ContentApiTarget, + id: number, + body: { locale: null | string; values: Record<string, unknown> }, +): Promise<ContentDraftSaveResult> => { + const result = await send( + { body, method: "put", path: `/${id}/draft`, target }, + zodContentDraftWritten, + ); + + if (result.data) { + return { status: result.status, updatedAt: result.data.updatedAt }; + } + + const rejection = zodContentDraftRejection.safeParse(parseJson(result.error)); + + return { + error: result.error ?? "", + ...(rejection.success ? { rejection: rejection.data } : {}), + status: result.status, + }; +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/collaborative-editor.test.tsx b/packages/vitnode/src/views/admin/views/content/live/collaborative-editor.test.tsx new file mode 100644 index 0000000000..566acae24d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/collaborative-editor.test.tsx @@ -0,0 +1,291 @@ +// @vitest-environment jsdom +import { act, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as Y from "yjs"; + +import type { + ContentLiveClientMessage, + ContentLiveDocRef, + ContentLiveMember, + ContentLiveServerMessage, +} from "@/content/live/protocol"; +import type { RichTextDocument } from "@/content/rich-text/document"; + +import { contentLiveChannel } from "@/content/live/protocol"; +import { VitNodeWebSocketProvider } from "@/ws/provider"; + +import type { ContentLiveContextValue } from "./context"; + +import { ContentLiveRichTextEditor } from "./collaborative-editor"; +import { decodeContentDocBytes, encodeContentDocBytes } from "./doc-provider"; +import { createContentRichTextRegistry } from "./rich-text"; + +const doc: ContentLiveDocRef = { + contentTypeId: "test.note", + field: "content", + itemId: 7, + locale: "en", +}; + +class FakeWebSocket { + constructor(url: string) { + this.url = url; + sockets.push(this); + } + + static readonly CLOSED = 3; + static readonly CLOSING = 2; + static readonly CONNECTING = 0; + static readonly OPEN = 1; + + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onopen: (() => void) | null = null; + readyState = FakeWebSocket.CONNECTING; + sent: ContentLiveClientMessage[] = []; + readonly url: string; + + close() { + this.readyState = FakeWebSocket.CLOSED; + } + + open() { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + + push(data: ContentLiveServerMessage) { + this.onmessage?.({ + data: JSON.stringify({ data, id: contentLiveChannel.id }), + }); + } + + send(raw: string) { + const message = JSON.parse(raw) as { data: ContentLiveClientMessage }; + this.sent.push(message.data); + } +} + +let sockets: FakeWebSocket[] = []; + +const ME: ContentLiveMember = { + avatarColor: "2563eb", + clientId: "my-tab", + field: "content", + locale: "en", + name: "Me", + nameCode: "Me", + userId: 1, +}; + +const paragraphDoc = (text: string): RichTextDocument => ({ + content: [{ content: [{ text, type: "text" }], type: "paragraph" }], + type: "doc", +}); + +const liveValue = (): ContentLiveContextValue => ({ + autosave: { flush: vi.fn(), queue: vi.fn() }, + coEditing: true, + discard: vi.fn(), + itemId: doc.itemId, + locale: "en", + reloadDrafts: vi.fn(), + richText: createContentRichTextRegistry(), + session: { + clientId: ME.clientId, + focus: vi.fn(), + live: true, + locks: [], + members: [ME], + onCommitted: () => () => {}, + onDraft: () => () => {}, + onReset: () => () => {}, + readDrafts: async () => await Promise.resolve(null), + readyState: 1, + refreshLocks: vi.fn(), + rememberSelf: vi.fn(), + resetLocally: vi.fn(), + self: ME.userId, + }, + spec: { + contentTypeId: doc.contentTypeId, + defaultLocale: "en", + editorial: true, + fields: [], + permissionModule: "notes", + pluginId: "@vitnode/example", + sections: [], + titleField: "title", + }, + status: { dirty: false, failed: false, savedAt: null, saving: false }, +}); + +const sentOf = <TType extends ContentLiveClientMessage["type"]>( + type: TType, +): (ContentLiveClientMessage & { type: TType })[] => + (sockets[0]?.sent ?? []).filter( + (message): message is ContentLiveClientMessage & { type: TType } => + message.type === type, + ); + +const serverCopy = () => { + const copy = new Y.Doc(); + for (const message of sentOf("doc:update")) { + Y.applyUpdate(copy, decodeContentDocBytes(message.update)); + } + + return copy; +}; + +const mount = (value: null | RichTextDocument) => { + const live = liveValue(); + const onChange = vi.fn<(next: RichTextDocument) => void>(); + const view = render( + <VitNodeWebSocketProvider> + <ContentLiveRichTextEditor + aria-label="Content" + field="content" + live={live} + locale="en" + onChange={onChange} + value={value} + /> + </VitNodeWebSocketProvider>, + ); + act(() => sockets[0]?.open()); + + const answerOpen = async () => { + act(() => { + sockets[0]?.push({ + clientId: ME.clientId, + doc, + stateVector: encodeContentDocBytes(Y.encodeStateVector(new Y.Doc())), + type: "doc:state-vector", + }); + }); + await screen.findByRole("textbox"); + }; + + return { answerOpen, live, onChange, view }; +}; + +describe("ContentLiveRichTextEditor", () => { + beforeEach(() => { + sockets = []; + vi.stubEnv("VITNODE_API_URL", "http://localhost:8000"); + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("BroadcastChannel", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("opens the shared document and waits for it before showing the editor", async () => { + const { answerOpen } = mount(paragraphDoc("From the record")); + + expect(sentOf("doc:open")).toEqual([ + expect.objectContaining({ clientId: ME.clientId, doc }), + ]); + expect(screen.queryByRole("textbox")).toBeNull(); + + await answerOpen(); + + expect(screen.getByRole("textbox").textContent).not.toContain( + "From the record", + ); + }); + + it("fills an empty document from the record when the server asks it to", async () => { + const { answerOpen, onChange } = mount(paragraphDoc("From the record")); + await answerOpen(); + + act(() => { + sockets[0]?.push({ clientId: ME.clientId, doc, type: "doc:seed" }); + }); + + await waitFor(() => { + expect(screen.getByRole("textbox").textContent).toBe("From the record"); + }); + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + content: [ + expect.objectContaining({ + content: [{ text: "From the record", type: "text" }], + type: "paragraph", + }), + ], + }), + ); + expect(serverCopy().getXmlFragment("default").toJSON()).toContain( + "From the record", + ); + + act(() => { + sockets[0]?.push({ clientId: ME.clientId, doc, type: "doc:seed" }); + }); + expect(screen.getByRole("textbox").textContent).toBe("From the record"); + }); + + it("shows what someone else types and keeps the form value with it", async () => { + const { answerOpen, onChange } = mount(null); + await answerOpen(); + + const anna = new Y.Doc(); + const paragraph = new Y.XmlElement("paragraph"); + paragraph.insert(0, [new Y.XmlText("Hello from Anna")]); + anna.getXmlFragment("default").insert(0, [paragraph]); + act(() => { + sockets[0]?.push({ + doc, + from: "anna-tab", + type: "doc:update", + update: encodeContentDocBytes(Y.encodeStateAsUpdate(anna)), + }); + }); + + await waitFor(() => { + expect(screen.getByRole("textbox").textContent).toBe("Hello from Anna"); + }); + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + content: [ + expect.objectContaining({ + content: [{ text: "Hello from Anna", type: "text" }], + }), + ], + }), + ); + }); + + it("lets code outside replace the document through the shared editor", async () => { + const { answerOpen, live } = mount(null); + await answerOpen(); + await waitFor(() => { + expect(live.richText.get("content", "en")).not.toBeNull(); + }); + + act(() => { + live.richText.replace("content", "en", paragraphDoc("Przetłumaczone")); + }); + + expect(screen.getByRole("textbox").textContent).toBe("Przetłumaczone"); + expect(serverCopy().getXmlFragment("default").toJSON()).toContain( + "Przetłumaczone", + ); + }); + + it("closes the document when it goes away", async () => { + const { answerOpen, live, view } = mount(null); + await answerOpen(); + + view.rerender(<VitNodeWebSocketProvider>{null}</VitNodeWebSocketProvider>); + + expect(sentOf("doc:close")).toEqual([ + { clientId: ME.clientId, doc, type: "doc:close" }, + ]); + expect(live.richText.get("content", "en")).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/live/collaborative-editor.tsx b/packages/vitnode/src/views/admin/views/content/live/collaborative-editor.tsx new file mode 100644 index 0000000000..fc2f6c1637 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/collaborative-editor.tsx @@ -0,0 +1,252 @@ +import type { Editor } from "@tiptap/react"; + +import { Collaboration } from "@tiptap/extension-collaboration"; +import { CollaborationCaret } from "@tiptap/extension-collaboration-caret"; +import React from "react"; +import * as Y from "yjs"; + +import type { CollaborativeEditorProps } from "@/components/tiptap/collaboration"; +import type { + ContentLiveDocRef, + ContentLiveServerMessage, +} from "@/content/live/protocol"; + +import { useEditorConfig } from "@/components/editor-provider"; +import { EditorSkeleton } from "@/components/tiptap/editor-skeleton"; +import { + editorEmojiItems, + toRichTextDocument, +} from "@/components/tiptap/rich-text-json"; +import { TipTapEditor } from "@/components/tiptap/tiptap-editor"; +import { contentLiveChannel } from "@/content/live/protocol"; +import { isRichTextEmpty } from "@/content/rich-text/document"; +import { useVitNodeWebSocketContext } from "@/ws/provider"; + +import type { ContentLiveContextValue } from "./context"; +import type { + ContentDocProvider, + ContentDocTransport, + ContentDocUser, +} from "./doc-provider"; + +import { createContentDocProvider } from "./doc-provider"; +import { + contentLiveLabelColor, + contentLiveMemberColor, +} from "./presence-model"; + +const FRAGMENT = "default"; + +const renderCaret = (user: Record<string, unknown>): HTMLElement => { + const color = + typeof user.color === "string" && user.color !== "transparent" + ? user.color + : contentLiveMemberColor(null); + const caret = document.createElement("span"); + caret.classList.add("collaboration-carets__caret"); + caret.style.borderColor = color; + + const label = document.createElement("div"); + label.classList.add("collaboration-carets__label"); + label.style.backgroundColor = color; + label.style.color = contentLiveLabelColor(color); + label.textContent = typeof user.name === "string" ? user.name : ""; + caret.append(label); + + return caret; +}; + +const renderSelection = (user: Record<string, unknown>) => ({ + class: "collaboration-carets__selection", + style: `background-color: ${contentLiveMemberColor( + typeof user.color === "string" ? user.color : null, + )}33`, +}); + +const useDocTransport = (live: boolean): ContentDocTransport => { + const { send, subscribe } = useVitNodeWebSocketContext(); + const liveRef = React.useRef(live); + const listenersRef = React.useRef(new Set<(next: boolean) => void>()); + + const transport = React.useMemo<ContentDocTransport>( + () => ({ + isLive: () => liveRef.current, + onLiveChange: listener => { + listenersRef.current.add(listener); + + return () => { + listenersRef.current.delete(listener); + }; + }, + send: message => { + send(contentLiveChannel.id, message); + }, + subscribe: listener => + subscribe(contentLiveChannel.id, data => { + listener(data as ContentLiveServerMessage); + }), + }), + [send, subscribe], + ); + + React.useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler -- `live` arrives over the socket; this forwards it to providers outside React + if (liveRef.current === live) return; + liveRef.current = live; + for (const listener of listenersRef.current) listener(live); + }, [live]); + + return transport; +}; + +interface Binding { + provider: ContentDocProvider; + user: ContentDocUser; + ydoc: Y.Doc; +} + +const subscribeNever = () => () => {}; + +export const ContentLiveRichTextEditor = ({ + className, + field, + live, + locale, + onChange, + value, + ...props +}: CollaborativeEditorProps & { + field: string; + live: ContentLiveContextValue; +}) => { + const { richText, session } = live; + const { emojis } = useEditorConfig(); + const emojiItems = React.useMemo(() => editorEmojiItems(emojis), [emojis]); + const transport = useDocTransport(session.live); + + const contentTypeId = live.spec.contentTypeId; + const itemId = live.itemId; + const doc = React.useMemo<ContentLiveDocRef>( + () => ({ contentTypeId, field, itemId, locale }), + [contentTypeId, field, itemId, locale], + ); + + const self = session.members.find( + member => member.clientId === session.clientId, + ); + const color = contentLiveMemberColor(self?.avatarColor ?? null); + const user = React.useMemo<ContentDocUser>( + () => ({ color, name: self?.name ?? "" }), + [color, self?.name], + ); + const userRef = React.useRef(user); + React.useEffect(() => { + userRef.current = user; + }, [user]); + + const [seedValue] = React.useState(value); + + const [binding, setBinding] = React.useState<Binding | null>(null); + React.useEffect(() => { + const ydoc = new Y.Doc(); + const opener = userRef.current; + const provider = createContentDocProvider({ + clientId: session.clientId, + doc, + transport, + user: opener, + ydoc, + }); + // eslint-disable-next-line @eslint-react/set-state-in-effect, react-you-might-not-need-an-effect/no-adjust-state-on-prop-change, react-you-might-not-need-an-effect/no-external-store-subscription -- a resource with a lifetime, not derived state + setBinding({ provider, user: opener, ydoc }); + + return () => { + provider.destroy(); + ydoc.destroy(); + }; + }, [doc, session.clientId, transport]); + + const synced = React.useSyncExternalStore( + binding?.provider.subscribeSynced ?? subscribeNever, + () => binding?.provider.synced() ?? false, + () => false, + ); + + const extensions = React.useMemo( + () => + binding + ? [ + Collaboration.configure({ + document: binding.ydoc, + field: FRAGMENT, + }), + CollaborationCaret.configure({ + provider: binding.provider, + render: renderCaret, + selectionRender: renderSelection, + user: binding.user, + }), + ] + : [], + [binding], + ); + + const [editor, setEditor] = React.useState<Editor | null>(null); + + const emit = React.useEffectEvent((current: Editor) => { + onChange(toRichTextDocument(current.getJSON(), emojiItems)); + }); + + React.useEffect(() => { + if (!editor || !binding) return; + + emit(editor); + + const stopSeed = binding.provider.onSeed(() => { + if (binding.ydoc.getXmlFragment(FRAGMENT).length > 0) return; + if (isRichTextEmpty(seedValue)) return; + + editor + .chain() + .command(({ tr }) => { + tr.setMeta("addToHistory", false); + + return true; + }) + .setContent(seedValue) + .run(); + }); + // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-live-state-to-parent -- the registry lives outside React: AI tools find the editor through it + const stopRegister = richText.register(field, locale, { + replace: document => { + editor.commands.setContent(document); + }, + }); + + return () => { + stopSeed(); + stopRegister(); + }; + }, [binding, editor, field, locale, richText, seedValue]); + + React.useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler -- `user` comes from presence over the socket, not from an event here + if (!editor || editor.isDestroyed) return; + editor.commands.updateUser(user); + }, [editor, user]); + + if (!binding || !synced) return <EditorSkeleton className={className} />; + + return ( + <TipTapEditor + className={className} + extensions={extensions} + format="json" + key={binding.ydoc.guid} + onChange={onChange} + onEditor={setEditor} + undoRedo={false} + {...props} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/context.ts b/packages/vitnode/src/views/admin/views/content/live/context.ts new file mode 100644 index 0000000000..2c586a1547 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/context.ts @@ -0,0 +1,35 @@ +import React from "react"; + +import type { ContentFormSpec } from "@/content/admin/spec"; + +import type { ContentRichTextRegistry } from "./rich-text"; +import type { ContentLiveSession } from "./use-session"; + +export interface ContentLiveAutosaveStatus { + dirty: boolean; + failed: boolean; + savedAt: null | string; + saving: boolean; +} + +export interface ContentLiveContextValue { + autosave: { + flush: () => Promise<void>; + queue: (field: string, locale: null | string, value: unknown) => void; + }; + coEditing: boolean; + discard: () => Promise<boolean>; + itemId: number; + locale: null | string; + reloadDrafts: () => Promise<void>; + richText: ContentRichTextRegistry; + session: ContentLiveSession; + spec: ContentFormSpec; + status: ContentLiveAutosaveStatus; +} + +export const ContentLiveContext = + React.createContext<ContentLiveContextValue | null>(null); + +export const useContentLive = (): ContentLiveContextValue | null => + React.use(ContentLiveContext); diff --git a/packages/vitnode/src/views/admin/views/content/live/doc-provider.test.ts b/packages/vitnode/src/views/admin/views/content/live/doc-provider.test.ts new file mode 100644 index 0000000000..bd53a6b461 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/doc-provider.test.ts @@ -0,0 +1,340 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from "vitest"; +import * as Y from "yjs"; + +import type { + ContentLiveClientMessage, + ContentLiveDocRef, + ContentLiveServerMessage, +} from "@/content/live/protocol"; + +import { + createLiveHarness, + type FakeLiveSocket, + ofType, +} from "@/tests/content-live"; + +import { + type ContentDocProvider, + type ContentDocTransport, + createContentDocProvider, + decodeContentDocBytes, + encodeContentDocBytes, +} from "./doc-provider"; + +const room = { contentTypeId: "test.guide", itemId: 1 }; +const doc: ContentLiveDocRef = { ...room, field: "content", locale: "en" }; +const polish: ContentLiveDocRef = { ...doc, locale: "pl" }; + +const harnesses: ReturnType<typeof createLiveHarness>[] = []; +const providers: ContentDocProvider[] = []; + +afterEach(() => { + providers.splice(0).forEach(provider => provider.destroy()); + harnesses.splice(0).forEach(live => live.stop()); +}); + +const setup = () => { + const live = createLiveHarness(); + harnesses.push(live); + const pending = new Set<Promise<void>>(); + + const settle = async () => { + while (pending.size > 0) await Promise.all([...pending]); + }; + + const browser = (userId: number) => { + let socket: FakeLiveSocket = live.socket({ id: userId }); + let isLive = false; + const sent: ContentLiveClientMessage[] = []; + const liveListeners = new Set<(next: boolean) => void>(); + const messageListeners = new Set< + (message: ContentLiveServerMessage) => void + >(); + const received: ContentLiveServerMessage[] = []; + const relay = (message: ContentLiveServerMessage) => { + received.push(message); + for (const listener of messageListeners) listener(message); + }; + let stopListening = socket.listen(relay); + + const send = (message: ContentLiveClientMessage) => { + sent.push(message); + const task: Promise<void> = live.send(socket, message).then(() => { + pending.delete(task); + }); + pending.add(task); + }; + + const setLive = (next: boolean) => { + if (next === isLive) return; + isLive = next; + for (const listener of liveListeners) listener(next); + }; + + const transport: ContentDocTransport = { + isLive: () => isLive, + onLiveChange: listener => { + liveListeners.add(listener); + + return () => { + liveListeners.delete(listener); + }; + }, + send, + subscribe: listener => { + messageListeners.add(listener); + + return () => { + messageListeners.delete(listener); + }; + }, + }; + + return { + deliver: relay, + drop: () => { + setLive(false); + stopListening(); + live.disconnect(socket); + }, + join: async (clientId: string) => { + send({ clientId, locale: "en", room, type: "join" }); + await settle(); + setLive(true); + }, + opensBy: (clientId: string) => + sent.filter( + message => + message.type === "doc:open" && message.clientId === clientId, + ), + received: () => received, + reconnect: () => { + socket = live.socket({ id: userId }); + stopListening = socket.listen(relay); + }, + tab: (clientId: string, name: string, ref = doc) => { + const ydoc = new Y.Doc(); + const provider = createContentDocProvider({ + clientId, + doc: ref, + transport, + user: { color: "#2563eb", name }, + ydoc, + }); + providers.push(provider); + + return { + provider, + text: () => ydoc.getText("content").toJSON(), + type: (change: (text: Y.Text) => void) => { + change(ydoc.getText("content")); + }, + ydoc, + }; + }, + }; + }; + + const serverText = (ref = doc) => + live.server.documents.loaded(ref)?.getText("content").toJSON(); + + return { browser, live, serverText, settle }; +}; + +describe("createContentDocProvider", () => { + it("encodes bytes as base64 the server can read", () => { + const bytes = Uint8Array.from( + { length: 70_000 }, + (_, index) => index % 256, + ); + + expect(decodeContentDocBytes(encodeContentDocBytes(bytes))).toEqual(bytes); + expect(encodeContentDocBytes(bytes)).toBe( + Buffer.from(bytes).toString("base64"), + ); + }); + + it("converges two editors through the server", async () => { + const { browser, serverText, settle } = setup(); + const annaBrowser = browser(1); + const benBrowser = browser(2); + await annaBrowser.join("anna-1"); + await benBrowser.join("ben-1"); + const anna = annaBrowser.tab("anna-1", "Anna"); + const ben = benBrowser.tab("ben-1", "Ben"); + expect(anna.provider.synced()).toBe(false); + await settle(); + expect(anna.provider.synced()).toBe(true); + + anna.type(text => text.insert(0, "Hello")); + await settle(); + expect(ben.text()).toBe("Hello"); + + ben.type(text => text.insert(text.length, " world")); + anna.type(text => text.insert(0, "> ")); + await settle(); + + expect(anna.text()).toBe("> Hello world"); + expect(ben.text()).toBe(anna.text()); + expect(serverText()).toBe(anna.text()); + }); + + it("keeps tabs that share one socket on their own documents", async () => { + const { browser, settle } = setup(); + const shared = browser(1); + await shared.join("tab-1"); + await shared.join("tab-2"); + await shared.join("tab-3"); + const first = shared.tab("tab-1", "Anna"); + const second = shared.tab("tab-2", "Anna"); + const elsewhere = shared.tab("tab-3", "Anna", polish); + await settle(); + + first.type(text => text.insert(0, "Hi")); + await settle(); + + expect(second.text()).toBe("Hi"); + expect(elsewhere.text()).toBe(""); + }); + + it("opens again after a reconnect and sends what the server missed", async () => { + const { browser, serverText, settle } = setup(); + const annaBrowser = browser(1); + const benBrowser = browser(2); + await annaBrowser.join("anna-1"); + await benBrowser.join("ben-1"); + const anna = annaBrowser.tab("anna-1", "Anna"); + const ben = benBrowser.tab("ben-1", "Ben"); + await settle(); + + annaBrowser.drop(); + await settle(); + anna.type(text => text.insert(0, "offline ")); + ben.type(text => text.insert(0, "online")); + await settle(); + expect(anna.text()).toBe("offline "); + expect(serverText()).toBe("online"); + + annaBrowser.reconnect(); + await annaBrowser.join("anna-1"); + await settle(); + + expect(annaBrowser.opensBy("anna-1")).toHaveLength(2); + expect(anna.text()).toBe(ben.text()); + expect(serverText()).toBe(anna.text()); + expect(anna.text()).toContain("offline "); + expect(anna.text()).toContain("online"); + }); + + it("opens again when the server no longer has the tab in the document", async () => { + const { browser, live, serverText, settle } = setup(); + const annaBrowser = browser(1); + await annaBrowser.join("anna-1"); + const anna = annaBrowser.tab("anna-1", "Anna"); + await settle(); + anna.type(text => text.insert(0, "Kept")); + await settle(); + + live.server.documents.resetRecord(room); + anna.type(text => text.insert(text.length, " and more")); + await settle(); + + expect(annaBrowser.opensBy("anna-1")).toHaveLength(2); + expect(serverText()).toBe("Kept and more"); + }); + + it("seeds the empty document once, from the tab the server picked", async () => { + const { browser, settle } = setup(); + const annaBrowser = browser(1); + const benBrowser = browser(2); + await annaBrowser.join("anna-1"); + await benBrowser.join("ben-1"); + const anna = annaBrowser.tab("anna-1", "Anna"); + await settle(); + + let seeds = 0; + anna.provider.onSeed(() => { + seeds += 1; + anna.type(text => text.insert(0, "From the record")); + }); + const ben = benBrowser.tab("ben-1", "Ben"); + let benSeeds = 0; + ben.provider.onSeed(() => { + benSeeds += 1; + }); + await settle(); + + expect(seeds).toBe(1); + expect(benSeeds).toBe(0); + expect(ben.text()).toBe("From the record"); + }); + + it("ignores a second seed request for the same document", async () => { + const { browser, settle } = setup(); + const annaBrowser = browser(1); + await annaBrowser.join("anna-1"); + const anna = annaBrowser.tab("anna-1", "Anna"); + let seeds = 0; + anna.provider.onSeed(() => { + seeds += 1; + }); + await settle(); + + annaBrowser.drop(); + await settle(); + annaBrowser.reconnect(); + await annaBrowser.join("anna-1"); + await settle(); + + expect( + ofType(annaBrowser.received(), "doc:seed").filter( + seed => seed.clientId === "anna-1", + ), + ).toHaveLength(2); + expect(seeds).toBe(1); + }); + + it("shares carets and takes them away when the editor closes", async () => { + const { browser, settle } = setup(); + const annaBrowser = browser(1); + const benBrowser = browser(2); + await annaBrowser.join("anna-1"); + await benBrowser.join("ben-1"); + const anna = annaBrowser.tab("anna-1", "Anna"); + await settle(); + const ben = benBrowser.tab("ben-1", "Ben"); + await settle(); + + expect(ben.provider.awareness.getStates().get(anna.ydoc.clientID)).toEqual({ + user: { color: "#2563eb", name: "Anna" }, + }); + expect(anna.provider.awareness.getStates().get(ben.ydoc.clientID)).toEqual({ + user: { color: "#2563eb", name: "Ben" }, + }); + + anna.provider.destroy(); + await settle(); + + expect(ben.provider.awareness.getStates().has(anna.ydoc.clientID)).toBe( + false, + ); + expect(annaBrowser.opensBy("anna-1")).toHaveLength(1); + }); + + it("goes quiet once the record is reset under it", async () => { + const { browser, live, serverText, settle } = setup(); + const annaBrowser = browser(1); + await annaBrowser.join("anna-1"); + const anna = annaBrowser.tab("anna-1", "Anna"); + await settle(); + + live.server.documents.resetRecord(room); + annaBrowser.deliver({ reason: "restored", room, type: "reset" }); + anna.type(text => text.insert(0, "Stale")); + await settle(); + + expect(serverText()).toBe(undefined); + expect(annaBrowser.opensBy("anna-1")).toHaveLength(1); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/live/doc-provider.ts b/packages/vitnode/src/views/admin/views/content/live/doc-provider.ts new file mode 100644 index 0000000000..1e7ceb10d7 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/doc-provider.ts @@ -0,0 +1,302 @@ +import { + applyAwarenessUpdate, + Awareness, + encodeAwarenessUpdate, + removeAwarenessStates, +} from "y-protocols/awareness"; +import * as Y from "yjs"; + +import type { + ContentLiveClientMessage, + ContentLiveDocRef, + ContentLiveServerMessage, +} from "@/content/live/protocol"; + +import { + sameContentLiveDoc, + sameContentLiveRoom, +} from "@/content/live/protocol"; + +export interface ContentDocTransport { + isLive: () => boolean; + onLiveChange: (listener: (live: boolean) => void) => () => void; + send: (message: ContentLiveClientMessage) => void; + subscribe: ( + listener: (message: ContentLiveServerMessage) => void, + ) => () => void; +} + +export interface ContentDocUser { + color: string; + name: string; +} + +export interface ContentDocProvider { + awareness: Awareness; + destroy: () => void; + doc: ContentLiveDocRef; + onSeed: (listener: () => void) => () => void; + subscribeSynced: (listener: () => void) => () => void; + synced: () => boolean; +} + +const REMOTE_ORIGIN = Symbol("content-doc-remote"); + +const BASE64_CHUNK = 0x80_00; + +export const encodeContentDocBytes = (bytes: Uint8Array): string => { + let binary = ""; + for (let start = 0; start < bytes.length; start += BASE64_CHUNK) { + binary += String.fromCharCode( + ...bytes.subarray(start, start + BASE64_CHUNK), + ); + } + + return btoa(binary); +}; + +export const decodeContentDocBytes = (value: string): Uint8Array => + Uint8Array.from(atob(value), char => char.charCodeAt(0)); + +const isEmptyUpdate = (update: Uint8Array): boolean => { + const { ds, structs } = Y.decodeUpdate(update); + + return structs.length === 0 && ds.clients.size === 0; +}; + +export const createContentDocProvider = ({ + clientId, + doc, + transport, + user, + ydoc, +}: { + clientId: string; + doc: ContentLiveDocRef; + transport: ContentDocTransport; + user: ContentDocUser; + ydoc: Y.Doc; +}): ContentDocProvider => { + const awareness = new Awareness(ydoc); + awareness.setLocalStateField("user", user); + + let destroyed = false; + let seated = transport.isLive(); + let stopped = false; + let opened = false; + let awaitingOpen = false; + let synced = false; + let seed: "handled" | "none" | "pending" = "none"; + const seedListeners = new Set<() => void>(); + const syncedListeners = new Set<() => void>(); + + const canSend = (): boolean => !destroyed && !stopped && opened && seated; + + const sendAwareness = (): void => { + if (!canSend() || !awareness.states.has(ydoc.clientID)) return; + + transport.send({ + clientId, + doc, + type: "doc:awareness", + update: encodeContentDocBytes( + encodeAwarenessUpdate(awareness, [ydoc.clientID]), + ), + }); + }; + + const open = (): void => { + if (destroyed || stopped) return; + + opened = true; + awaitingOpen = true; + transport.send({ + clientId, + doc, + stateVector: encodeContentDocBytes(Y.encodeStateVector(ydoc)), + type: "doc:open", + }); + sendAwareness(); + }; + + const deliverSeed = (): void => { + if (seed !== "pending" || seedListeners.size === 0) return; + + seed = "handled"; + for (const listener of seedListeners) listener(); + }; + + const markSynced = (): void => { + if (synced) return; + + synced = true; + for (const listener of syncedListeners) listener(); + }; + + const onUpdate = (update: Uint8Array, origin: unknown): void => { + if (origin === REMOTE_ORIGIN || !canSend()) return; + + transport.send({ + clientId, + doc, + type: "doc:update", + update: encodeContentDocBytes(update), + }); + }; + ydoc.on("update", onUpdate); + + const onAwareness = ( + { + added, + removed, + updated, + }: { added: number[]; removed: number[]; updated: number[] }, + origin: unknown, + ): void => { + if (origin === REMOTE_ORIGIN) return; + const changed = [...added, ...updated, ...removed]; + if (!changed.includes(ydoc.clientID) || !canSend()) return; + + transport.send({ + clientId, + doc, + type: "doc:awareness", + update: encodeContentDocBytes( + encodeAwarenessUpdate(awareness, [ydoc.clientID]), + ), + }); + }; + awareness.on("update", onAwareness); + + const sendMissingUpdate = (stateVector: string): void => { + try { + const missing = Y.encodeStateAsUpdate( + ydoc, + decodeContentDocBytes(stateVector), + ); + if (!isEmptyUpdate(missing) && canSend()) { + transport.send({ + clientId, + doc, + type: "doc:update", + update: encodeContentDocBytes(missing), + }); + } + } catch { + return; + } + }; + + const onMessage = (message: ContentLiveServerMessage): void => { + if (destroyed) return; + + switch (message.type) { + case "doc:awareness": + case "doc:update": { + if (!sameContentLiveDoc(message.doc, doc)) return; + if (message.from === clientId) return; + + try { + const bytes = decodeContentDocBytes(message.update); + if (message.type === "doc:update") { + Y.applyUpdate(ydoc, bytes, REMOTE_ORIGIN); + } else { + applyAwarenessUpdate(awareness, bytes, REMOTE_ORIGIN); + } + } catch { + return; + } + + return; + } + case "doc:seed": + if (message.clientId !== clientId) return; + if (!sameContentLiveDoc(message.doc, doc)) return; + if (seed === "none") seed = "pending"; + deliverSeed(); + + return; + case "doc:state-vector": { + if (message.clientId !== clientId) return; + if (!sameContentLiveDoc(message.doc, doc)) return; + + awaitingOpen = false; + sendMissingUpdate(message.stateVector); + markSynced(); + + return; + } + case "error": + if (message.clientId !== clientId || message.code !== "NOT_JOINED") { + return; + } + if (message.room && !sameContentLiveRoom(message.room, doc)) return; + if (!awaitingOpen && seated) open(); + + return; + case "joined": + if (message.clientId !== clientId) return; + if (!sameContentLiveRoom(message.room, doc)) return; + seated = true; + open(); + + return; + case "reset": + if (sameContentLiveRoom(message.room, doc)) stopped = true; + + return; + default: + return; + } + }; + const unsubscribe = transport.subscribe(onMessage); + + const stopLive = transport.onLiveChange(live => { + seated = live; + if (!live) { + opened = false; + awaitingOpen = false; + + return; + } + if (!opened) open(); + }); + + if (seated) open(); + + return { + awareness, + destroy: () => { + if (destroyed) return; + + removeAwarenessStates(awareness, [ydoc.clientID], "local"); + if (canSend()) transport.send({ clientId, doc, type: "doc:close" }); + destroyed = true; + + unsubscribe(); + stopLive(); + ydoc.off("update", onUpdate); + awareness.off("update", onAwareness); + awareness.destroy(); + seedListeners.clear(); + syncedListeners.clear(); + }, + doc, + onSeed: listener => { + seedListeners.add(listener); + deliverSeed(); + + return () => { + seedListeners.delete(listener); + }; + }, + subscribeSynced: listener => { + syncedListeners.add(listener); + + return () => { + syncedListeners.delete(listener); + }; + }, + synced: () => synced, + }; +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/field.test.tsx b/packages/vitnode/src/views/admin/views/content/live/field.test.tsx new file mode 100644 index 0000000000..af79eeaee3 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/field.test.tsx @@ -0,0 +1,484 @@ +// @vitest-environment jsdom +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AnyFormFieldApi } from "@/components/form/auto-form"; +import type { + ContentFormFieldSpec, + ContentFormSpec, +} from "@/content/admin/spec"; +import type { + ContentFieldLock, + ContentLiveMember, +} from "@/content/live/protocol"; + +import { useMultiLangField } from "@/components/form/fields/multi-lang"; +import { MultiLangPresenceContext } from "@/components/form/fields/multi-lang-presence"; +import { LanguagesProvider } from "@/components/languages-provider"; +import { EditorCollaborationContext } from "@/components/tiptap/collaboration"; + +import type { ContentFormTransport } from "../form/transport"; +import type { ContentLiveContextValue } from "./context"; +import type { ContentLiveDraftEvent, ContentLiveSession } from "./use-session"; + +import { ContentFormTransportProvider } from "../form/transport"; +import { ContentLiveContext } from "./context"; +import { CONTENT_FIELD_LOCK_BLUR_GRACE_MS, ContentLiveField } from "./field"; +import { createContentRichTextRegistry } from "./rich-text"; + +const ME = 1; +const ANNA = 2; + +const fieldSpec: ContentFormFieldSpec = { + kind: "text", + label: "Title", + name: "title", + nullable: false, + required: true, +}; + +const spec: ContentFormSpec = { + contentTypeId: "test.note", + defaultLocale: null, + editorial: true, + fields: [fieldSpec], + permissionModule: "notes", + pluginId: "@vitnode/example", + sections: [], + titleField: "title", +}; + +const languages = [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, +]; + +const localizedTitle: ContentFormFieldSpec = { ...fieldSpec, localized: true }; + +const content: ContentFormFieldSpec = { + kind: "richText", + label: "Content", + localized: true, + name: "content", + nullable: false, + required: true, +}; + +const memberOf = ( + userId: number, + name: string, + where: Pick<ContentLiveMember, "field" | "locale">, +): ContentLiveMember => ({ + avatarColor: "2563eb", + clientId: `${name}-tab`, + name, + nameCode: name, + userId, + ...where, +}); + +const EditorProbe = () => + React.use(EditorCollaborationContext) ? ( + <p>shared editor</p> + ) : ( + <p>local editor</p> + ); + +const SwitchableTitle = () => { + const { currentValue, selected, setSelected } = useMultiLangField({ + name: "title", + onBlur: () => undefined, + onChange: () => undefined, + value: [ + { languageCode: "en", value: "Hello" }, + { languageCode: "pl", value: "Cześć" }, + ], + }); + const presence = React.use(MultiLangPresenceContext); + + return ( + <> + {languages.map(language => ( + <button + aria-pressed={selected === language.code} + key={language.code} + onClick={() => { + setSelected(language.code); + }} + type="button" + > + {language.name} + {presence?.busy(language.code) ? " (busy)" : null} + {presence?.marker(language.code)} + </button> + ))} + <input aria-label="Title" readOnly value={currentValue} /> + </> + ); +}; + +const lockOf = (userId: number, name: string): ContentFieldLock => ({ + expiresAt: new Date(Date.now() + 60_000).toISOString(), + field: "title", + locale: null, + user: { id: userId, name }, +}); + +const setup = ({ + children = <input aria-label="Title" defaultValue="Hello" />, + coEditing = false, + locks = [], + members = [], + target = fieldSpec, + value = "Hello", +}: { + children?: React.ReactNode; + coEditing?: boolean; + locks?: ContentFieldLock[]; + members?: ContentLiveMember[]; + target?: ContentFormFieldSpec; + value?: unknown; +} = {}) => { + const draftListeners = new Set<(event: ContentLiveDraftEvent) => void>(); + const session: ContentLiveSession = { + clientId: "tab-1", + focus: vi.fn(), + live: coEditing, + locks, + members, + onCommitted: () => () => {}, + onDraft: listener => { + draftListeners.add(listener); + + return () => { + draftListeners.delete(listener); + }; + }, + onReset: () => () => {}, + readDrafts: async () => await Promise.resolve(null), + readyState: 3, + refreshLocks: vi.fn(async () => { + await Promise.resolve(); + }), + rememberSelf: vi.fn(), + resetLocally: vi.fn(), + self: ME, + }; + const live: ContentLiveContextValue = { + autosave: { + flush: vi.fn(async () => { + await Promise.resolve(); + }), + queue: vi.fn(), + }, + coEditing, + discard: vi.fn(), + itemId: 7, + locale: target.localized === true ? "en" : null, + reloadDrafts: vi.fn(async () => { + await Promise.resolve(); + }), + richText: createContentRichTextRegistry(), + session, + spec, + status: { dirty: false, failed: false, savedAt: null, saving: false }, + }; + const lock = vi.fn<ContentFormTransport["lock"]>( + async (_type, _id, { action }) => + await Promise.resolve({ + lock: action === "release" ? null : lockOf(ME, "Me"), + status: 200, + }), + ); + const transport = { lock } as unknown as ContentFormTransport; + const field: AnyFormFieldApi = { + name: target.name, + onBlur: vi.fn(), + onChange: vi.fn(), + value, + }; + + const view = render( + <ContentFormTransportProvider value={transport}> + <LanguagesProvider languages={languages}> + <ContentLiveContext value={live}> + <ContentLiveField field={field} fieldSpec={target}> + {children} + </ContentLiveField> + <button type="button">Elsewhere</button> + </ContentLiveContext> + </LanguagesProvider> + </ContentFormTransportProvider>, + ); + + return { draftListeners, field, live, lock, view }; +}; + +const outlineTags = () => + [...document.querySelectorAll('[data-slot="content-live-tag"]')].map( + tag => tag.textContent, + ); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ContentLiveField", () => { + it("is read-only and names the holder while someone else edits it", () => { + setup({ locks: [lockOf(ANNA, "Anna")] }); + + expect(screen.getByRole("group", { description: /Anna/ })).toHaveProperty( + "disabled", + true, + ); + expect( + screen.getByRole("textbox", { name: "Title" }).matches(":disabled"), + ).toBe(true); + expect(outlineTags()).toEqual(["Anna"]); + }); + + it("stays editable when nobody holds it", () => { + setup(); + + expect( + screen.getByRole("textbox", { name: "Title" }).matches(":disabled"), + ).toBe(false); + expect(outlineTags()).toEqual([]); + }); + + it("stays editable when the lock is the editor's own", () => { + setup({ locks: [lockOf(ME, "Me")] }); + + expect( + screen.getByRole("textbox", { name: "Title" }).matches(":disabled"), + ).toBe(false); + }); + + it("takes the lock on focus and gives it back once focus has left", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { live, lock } = setup(); + + await act(async () => { + fireEvent.focus(screen.getByRole("textbox", { name: "Title" })); + await Promise.resolve(); + }); + expect(lock).toHaveBeenCalledWith("test.note", 7, { + action: "acquire", + field: "title", + locale: null, + }); + expect(live.session.focus).toHaveBeenCalledWith("title", null); + + await act(async () => { + fireEvent.blur(screen.getByRole("textbox", { name: "Title" }), { + relatedTarget: screen.getByRole("button", { name: "Elsewhere" }), + }); + vi.advanceTimersByTime(CONTENT_FIELD_LOCK_BLUR_GRACE_MS); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(lock).toHaveBeenLastCalledWith("test.note", 7, { + action: "release", + field: "title", + locale: null, + }); + }); + expect(live.autosave.flush).toHaveBeenCalled(); + }); + + it("asks for no lock on a field someone else holds", () => { + const { lock } = setup({ locks: [lockOf(ANNA, "Anna")] }); + + fireEvent.focus(screen.getByRole("textbox", { name: "Title" })); + + expect(lock).not.toHaveBeenCalled(); + }); + + it("shows another editor's autosaved value", () => { + const { draftListeners, field } = setup({ locks: [lockOf(ANNA, "Anna")] }); + + act(() => { + for (const listener of draftListeners) { + listener({ + by: { id: ANNA, name: "Anna" }, + locale: null, + updatedAt: new Date().toISOString(), + values: { title: "Hello from Anna" }, + }); + } + }); + + expect(field.onChange).toHaveBeenCalledWith("Hello from Anna"); + }); + + it("ignores drafts for other fields and other languages", () => { + const { draftListeners, field } = setup(); + + act(() => { + for (const listener of draftListeners) { + listener({ + by: null, + locale: null, + updatedAt: new Date().toISOString(), + values: { body: "Elsewhere" }, + }); + listener({ + by: null, + locale: "pl", + updatedAt: new Date().toISOString(), + values: { title: "Cześć" }, + }); + } + }); + + expect(field.onChange).not.toHaveBeenCalled(); + }); + + it("locks the language its switcher shows, and moves the lock with it", async () => { + const { live, lock } = setup({ + children: <SwitchableTitle />, + target: localizedTitle, + }); + + fireEvent.click(screen.getByRole("button", { name: "Polski" })); + await act(async () => { + fireEvent.focus(screen.getByRole("textbox", { name: "Title" })); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(lock).toHaveBeenCalledWith("test.note", 7, { + action: "acquire", + field: "title", + locale: "pl", + }); + }); + expect(live.session.focus).toHaveBeenLastCalledWith("title", "pl"); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "English" })); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(lock).toHaveBeenCalledWith("test.note", 7, { + action: "release", + field: "title", + locale: "pl", + }); + }); + await vi.waitFor(() => { + expect(lock).toHaveBeenLastCalledWith("test.note", 7, { + action: "acquire", + field: "title", + locale: "en", + }); + }); + }); + + it("outlines the field with who else is in it, in the language shown", async () => { + setup({ + children: <SwitchableTitle />, + coEditing: true, + members: [ + memberOf(ANNA, "Anna", { field: "title", locale: "pl" }), + memberOf(3, "Ben", { field: "title", locale: "en" }), + ], + target: localizedTitle, + }); + + expect(outlineTags()).toEqual(["Ben"]); + expect( + screen.getByRole("group", { + description: "core.content.live.presence.field", + }), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /Polski/ })); + + await waitFor(() => { + expect(outlineTags()).toEqual(["Anna"]); + }); + }); + + it("draws no outline around co-edited rich text", () => { + setup({ + children: <EditorProbe />, + coEditing: true, + members: [memberOf(ANNA, "Anna", { field: "content", locale: "en" })], + target: content, + value: [{ languageCode: "en", value: { type: "doc" } }], + }); + + expect(screen.getByText("shared editor")).toBeTruthy(); + expect(outlineTags()).toEqual([]); + }); + + it("marks the languages someone else is editing on the switcher", () => { + setup({ + children: <SwitchableTitle />, + coEditing: true, + members: [memberOf(ANNA, "Anna", { field: "title", locale: "pl" })], + target: localizedTitle, + }); + + const polish = screen.getByRole("button", { name: /Polski/ }); + expect(polish.textContent).toContain("(busy)"); + expect(within(polish).getByTitle("Anna")).toBeTruthy(); + expect( + screen.getByRole("button", { name: /English/ }).textContent, + ).not.toContain("(busy)"); + }); + + it("co-edits rich text: no lock, no draft autosave, the shared editor", async () => { + const { draftListeners, field, live, lock, view } = setup({ + children: ( + <> + <EditorProbe /> + <input aria-label="Content" /> + </> + ), + coEditing: true, + target: content, + value: [{ languageCode: "en", value: { type: "doc" } }], + }); + + expect(screen.getByText("shared editor")).toBeTruthy(); + + await act(async () => { + fireEvent.focus(screen.getByRole("textbox", { name: "Content" })); + await Promise.resolve(); + }); + expect(lock).not.toHaveBeenCalled(); + expect(live.session.focus).toHaveBeenCalledWith("content", "en"); + + act(() => { + for (const listener of draftListeners) { + listener({ + by: { id: ANNA, name: "Anna" }, + locale: "en", + updatedAt: new Date().toISOString(), + values: { content: { content: [], type: "doc" } }, + }); + } + }); + expect(field.onChange).not.toHaveBeenCalled(); + view.unmount(); + expect(live.autosave.queue).not.toHaveBeenCalled(); + }); + + it("keeps the local editor for rich text without a socket", () => { + setup({ children: <EditorProbe />, target: content, value: [] }); + + expect(screen.getByText("local editor")).toBeTruthy(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/live/field.tsx b/packages/vitnode/src/views/admin/views/content/live/field.tsx new file mode 100644 index 0000000000..e0255366a4 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/field.tsx @@ -0,0 +1,361 @@ +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { AnyFormFieldApi } from "@/components/form/auto-form"; +import type { ContentFormFieldSpec } from "@/content/admin/spec"; +import type { ContentFieldLock } from "@/content/live/protocol"; + +import { useMultiLangLanguage } from "@/components/form/fields/multi-lang-language"; +import { + type MultiLangPresence, + MultiLangPresenceContext, + MultiLangShownLanguageContext, +} from "@/components/form/fields/multi-lang-presence"; +import { EditorCollaborationContext } from "@/components/tiptap/collaboration"; +import { + contentApiValueToFormField, + contentFormFieldToApi, +} from "@/content/admin/spec"; +import { CONTENT_FIELD_LOCK_RENEW_MS } from "@/content/live/protocol"; + +import type { ContentLiveContextValue } from "./context"; + +import { useContentFormTransport } from "../form/transport"; +import { useContentLive } from "./context"; +import { ContentLiveFieldOutline } from "./outline"; +import { ContentLiveLanguagePresence, useContentLiveNames } from "./presence"; +import { + contentLiveFieldEditors, + contentLiveLanguageEditors, +} from "./presence-model"; +import { + ContentLiveRichTextFieldContext, + renderContentLiveCollaborativeEditor, +} from "./rich-text-field"; +import { sameContentValue } from "./values"; + +export const CONTENT_FIELD_LOCK_BLUR_GRACE_MS = 1_500; + +const isExpired = (lock: ContentFieldLock): boolean => + Date.parse(lock.expiresAt) <= Date.now(); + +const LockNotice = ({ + id, + lock, +}: { + id: string; + lock: ContentFieldLock | null; +}) => { + const t = useTranslations("core.content.live"); + + return ( + <p aria-live="polite" className="sr-only" id={id}> + {lock ? ( + <> + {lock.user.name} {t("is_editing")}. {t("locked_desc")} + </> + ) : null} + </p> + ); +}; + +const LiveField = ({ + children, + field, + fieldSpec, + live, +}: { + children: React.ReactNode; + field: AnyFormFieldApi; + fieldSpec: ContentFormFieldSpec; + live: ContentLiveContextValue; +}) => { + const t = useTranslations("core.content.live.presence"); + const names = useContentLiveNames(); + const transport = useContentFormTransport(); + const pinned = useMultiLangLanguage(); + const badgeId = React.useId(); + const presenceId = React.useId(); + const { autosave, session, spec } = live; + const name = fieldSpec.name; + const localized = fieldSpec.localized === true; + const [shown, setShown] = React.useState<null | string>(null); + const locale = localized ? (pinned ?? shown ?? live.locale) : null; + const coEdited = fieldSpec.kind === "richText" && live.coEditing; + const lockable = !coEdited; + + const [held, setHeld] = React.useState(false); + const heldRef = React.useRef(false); + const heldLocaleRef = React.useRef<null | string>(null); + const busyRef = React.useRef<Promise<void>>(Promise.resolve()); + const releaseTimerRef = + React.useRef<ReturnType<typeof setTimeout>>(undefined); + const focusedRef = React.useRef(false); + + const lock = + session.locks.find( + entry => + entry.field === name && entry.locale === locale && !isExpired(entry), + ) ?? null; + const lockedByOther = + lockable && + !held && + lock !== null && + (session.self === null || lock.user.id !== session.self); + + const markHeld = (value: boolean) => { + heldRef.current = value; + setHeld(value); + }; + + const request = async ( + action: "acquire" | "release" | "renew", + lockLocale: null | string, + ) => + await transport.lock(spec.contentTypeId, live.itemId, { + action, + field: name, + locale: lockLocale, + }); + + const acquire = async () => { + if (heldRef.current || !lockable) return; + + const target = locale; + const result = await request("acquire", target); + if (result.lock) { + session.rememberSelf(result.lock.user.id); + heldLocaleRef.current = target; + markHeld(focusedRef.current); + if (!focusedRef.current) await request("release", target); + + return; + } + void session.refreshLocks(); + }; + + const release = async () => { + if (!heldRef.current) return; + + await autosave.flush(); + markHeld(false); + await request("release", heldLocaleRef.current); + }; + + const renew = async () => { + if (!heldRef.current) return; + + const result = await request("renew", heldLocaleRef.current); + if (result.lock) return; + + markHeld(false); + if (focusedRef.current) await acquire(); + else void session.refreshLocks(); + }; + + const actionsRef = React.useRef({ acquire, release, renew }); + React.useEffect(() => { + actionsRef.current = { acquire, release, renew }; + }); + + const run = (action: "acquire" | "release" | "renew") => { + const task = async () => { + await actionsRef.current[action](); + }; + busyRef.current = busyRef.current.then(task, task); + }; + + React.useEffect(() => { + if (!held) return; + + const timer = setInterval(() => { + run("renew"); + }, CONTENT_FIELD_LOCK_RENEW_MS); + + return () => { + clearInterval(timer); + }; + }, [held]); + + React.useEffect( + () => () => { + clearTimeout(releaseTimerRef.current); + if (heldRef.current) run("release"); + }, + [], + ); + + React.useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler -- the language moves inside the field's own switcher, which knows nothing of locks + if (!heldRef.current || heldLocaleRef.current === locale) return; + + run("release"); + if (focusedRef.current) run("acquire"); + }, [locale]); + + const valueKey = JSON.stringify(field.value ?? null); + const baselineRef = React.useRef<null | string>(null); + const focusValueRef = React.useRef<null | string>(null); + const queueChange = React.useEffectEvent(() => { + if (!held || !lockable) { + baselineRef.current = null; + + return; + } + baselineRef.current ??= focusValueRef.current ?? valueKey; + if (baselineRef.current === valueKey) return; + + baselineRef.current = ""; + const converted = contentFormFieldToApi( + spec, + fieldSpec, + field.value, + locale, + ); + if (converted) autosave.queue(name, locale, converted.value); + }); + React.useEffect(() => { + queueChange(); + }, [held, valueKey]); + + const applyDraft = React.useEffectEvent( + (event: { locale: null | string; values: Record<string, unknown> }) => { + if (!lockable || !(name in event.values)) return; + if (heldRef.current && event.locale === locale) return; + if (localized ? event.locale === null : event.locale !== null) return; + + const next = contentApiValueToFormField( + fieldSpec, + event.values[name], + field.value, + event.locale, + ); + if (!sameContentValue(next, field.value)) field.onChange(next); + }, + ); + + const { onDraft } = session; + // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- a subscription to the session's draft events, nothing handed upwards + React.useEffect(() => onDraft(applyDraft), [onDraft]); + + const onFocusCapture = () => { + if (!focusedRef.current) focusValueRef.current = valueKey; + focusedRef.current = true; + clearTimeout(releaseTimerRef.current); + session.focus(name, locale); + if (!lockedByOther) run("acquire"); + }; + + const onBlurCapture = (event: React.FocusEvent<HTMLFieldSetElement>) => { + const next = event.relatedTarget; + if (next instanceof Node && event.currentTarget.contains(next)) return; + + focusedRef.current = false; + session.focus(null, locale); + clearTimeout(releaseTimerRef.current); + releaseTimerRef.current = setTimeout(() => { + if (!focusedRef.current) run("release"); + }, CONTENT_FIELD_LOCK_BLUR_GRACE_MS); + }; + + const editors = session.live + ? contentLiveFieldEditors(session.members, session, { field: name, locale }) + : []; + const holder = + lockedByOther && lock + ? { + avatarColor: + session.members.find(member => member.userId === lock.user.id) + ?.avatarColor ?? null, + name: lock.user.name, + userId: lock.user.id, + } + : null; + const outlined = coEdited + ? [] + : [ + ...(holder ? [holder] : []), + ...editors.filter(member => member.userId !== holder?.userId), + ]; + const describedBy = + [lockedByOther ? badgeId : null, editors.length > 0 ? presenceId : null] + .filter(Boolean) + .join(" ") || undefined; + const languagePresence: MultiLangPresence | null = + localized && session.live + ? { + busy: code => + contentLiveLanguageEditors(session.members, session, { + field: name, + locale: code, + }).length > 0, + marker: code => ( + <ContentLiveLanguagePresence + className="ms-auto" + field={name} + locale={code} + /> + ), + } + : null; + + let content = children; + if (coEdited) { + content = ( + <ContentLiveRichTextFieldContext value={name}> + <EditorCollaborationContext + value={renderContentLiveCollaborativeEditor} + > + {content} + </EditorCollaborationContext> + </ContentLiveRichTextFieldContext> + ); + } + + return ( + <div className="flex min-w-0 flex-col"> + <fieldset + aria-describedby={describedBy} + className="relative flex min-w-0 flex-col gap-3" + disabled={lockedByOther} + onBlurCapture={onBlurCapture} + onFocusCapture={onFocusCapture} + > + <MultiLangShownLanguageContext value={setShown}> + <MultiLangPresenceContext value={languagePresence}> + {content} + </MultiLangPresenceContext> + </MultiLangShownLanguageContext> + <ContentLiveFieldOutline members={outlined} /> + </fieldset> + {editors.length > 0 ? ( + <span className="sr-only" id={presenceId}> + {t("field", { + count: editors.length, + names: names(editors), + })} + </span> + ) : null} + <LockNotice id={badgeId} lock={lockedByOther ? lock : null} /> + </div> + ); +}; + +export const ContentLiveField = ({ + children, + field, + fieldSpec, +}: { + children: React.ReactNode; + field: AnyFormFieldApi; + fieldSpec: ContentFormFieldSpec; +}) => { + const live = useContentLive(); + if (!live) return <>{children}</>; + + return ( + <LiveField field={field} fieldSpec={fieldSpec} live={live}> + {children} + </LiveField> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/outline.tsx b/packages/vitnode/src/views/admin/views/content/live/outline.tsx new file mode 100644 index 0000000000..28afcbb417 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/outline.tsx @@ -0,0 +1,83 @@ +import { + AnimatePresence, + type Transition, + useReducedMotion, +} from "motion/react"; +import * as m from "motion/react-m"; + +import type { ContentLiveMember } from "@/content/live/protocol"; + +import { MotionFeatures } from "@/components/motion-features"; + +import { + contentLiveLabelColor, + contentLiveMemberColor, +} from "./presence-model"; + +export type ContentLiveOutlineMember = Pick< + ContentLiveMember, + "avatarColor" | "name" | "userId" +>; + +const GLIDE: Transition = { + layout: { duration: 0.38, ease: [0.65, 0, 0.25, 1] }, + opacity: { duration: 0.2, ease: [0.22, 1, 0.36, 1] }, +}; + +const EXIT: Transition = { duration: 0.15, ease: "easeIn" }; + +export const ContentLiveFieldOutline = ({ + members, +}: { + members: readonly ContentLiveOutlineMember[]; +}) => { + const reduced = useReducedMotion(); + const holder = members.at(0); + const transition = reduced ? { duration: 0 } : GLIDE; + const exit = { opacity: 0, transition: reduced ? { duration: 0 } : EXIT }; + const color = holder ? contentLiveMemberColor(holder.avatarColor) : ""; + + return ( + <MotionFeatures withLayoutAndDrag> + <AnimatePresence initial={false}> + {holder ? ( + <m.span + animate={{ opacity: 1 }} + aria-hidden + className="pointer-events-none absolute -inset-1.5 border-2" + data-slot="content-live-outline" + exit={exit} + initial={{ opacity: 0 }} + key={`ring-${holder.userId}`} + layoutId={`content-live-ring-${holder.userId}`} + style={{ borderColor: color, borderRadius: 10 }} + transition={transition} + /> + ) : null} + {holder ? ( + <m.span + animate={{ opacity: 1 }} + aria-hidden + className="pointer-events-none absolute -start-1.5 -top-1.5 z-10 inline-flex max-w-48 -translate-y-full items-center gap-1 px-2 py-1 text-xs leading-none font-semibold whitespace-nowrap select-none" + data-slot="content-live-tag" + exit={exit} + initial={{ opacity: 0 }} + key={`tag-${holder.userId}`} + layoutId={`content-live-tag-${holder.userId}`} + style={{ + backgroundColor: color, + borderRadius: "6px 6px 6px 0", + color: contentLiveLabelColor(color), + }} + transition={transition} + > + <span className="truncate">{holder.name}</span> + {members.length > 1 ? ( + <span className="tabular-nums">+{members.length - 1}</span> + ) : null} + </m.span> + ) : null} + </AnimatePresence> + </MotionFeatures> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/presence-model.ts b/packages/vitnode/src/views/admin/views/content/live/presence-model.ts new file mode 100644 index 0000000000..e8d8d4f888 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/presence-model.ts @@ -0,0 +1,80 @@ +import type { ContentLiveMember } from "@/content/live/protocol"; + +import { colorLuminance } from "@/lib/colors"; + +const CONTENT_LIVE_FALLBACK_COLOR = "71717a"; + +const HEX_COLOR = /^#?([0-9a-f]{6})$/i; + +export const contentLiveMemberColor = (avatarColor: null | string): string => { + const match = HEX_COLOR.exec(avatarColor?.trim() ?? ""); + + return `#${(match?.[1] ?? CONTENT_LIVE_FALLBACK_COLOR).toLowerCase()}`; +}; + +export const contentLiveLabelColor = (background: string): string => { + const light = colorLuminance(background) ?? 0; + const onWhite = 1.05 / (light + 0.05); + const onBlack = (light + 0.05) / 0.05; + + return onBlack >= onWhite ? "#0a0a0a" : "#ffffff"; +}; + +export interface ContentLiveViewer { + clientId: string; + self: null | number; +} + +const othersOnly = ( + members: readonly ContentLiveMember[], + { clientId, self }: ContentLiveViewer, +): ContentLiveMember[] => { + const me = + members.find(member => member.clientId === clientId)?.userId ?? self; + + return members.filter( + member => member.clientId !== clientId && member.userId !== me, + ); +}; + +const perPerson = (members: readonly ContentLiveMember[]) => { + const people = new Map<number, ContentLiveMember>(); + for (const member of members) { + const known = people.get(member.userId); + if (!known || (known.field === null && member.field !== null)) { + people.set(member.userId, member); + } + } + + return [...people.values()]; +}; + +export const contentLiveOthers = ( + members: readonly ContentLiveMember[], + viewer: ContentLiveViewer, +): ContentLiveMember[] => perPerson(othersOnly(members, viewer)); + +export const contentLiveFieldEditors = ( + members: readonly ContentLiveMember[], + viewer: ContentLiveViewer, + { field, locale }: { field: string; locale: null | string }, +): ContentLiveMember[] => + perPerson( + othersOnly(members, viewer).filter( + member => + member.field === field && (locale === null || member.locale === locale), + ), + ); + +export const contentLiveLanguageEditors = ( + members: readonly ContentLiveMember[], + viewer: ContentLiveViewer, + { field, locale }: { field?: string; locale: string }, +): ContentLiveMember[] => + perPerson( + othersOnly(members, viewer).filter( + member => + member.locale === locale && + (field === undefined || member.field === field), + ), + ); diff --git a/packages/vitnode/src/views/admin/views/content/live/presence.test.tsx b/packages/vitnode/src/views/admin/views/content/live/presence.test.tsx new file mode 100644 index 0000000000..bbf7507fe6 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/presence.test.tsx @@ -0,0 +1,256 @@ +// @vitest-environment jsdom +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentLiveMember } from "@/content/live/protocol"; + +import type { ContentLiveContextValue } from "./context"; + +import { ContentLiveContext } from "./context"; +import { + ContentLiveFieldPresence, + ContentLiveLanguagePresence, + ContentLivePresence, +} from "./presence"; +import { createContentRichTextRegistry } from "./rich-text"; + +const spec: ContentFormSpec = { + contentTypeId: "test.note", + defaultLocale: "en", + editorial: true, + fields: [ + { + kind: "text", + label: "Title", + localized: true, + name: "title", + nullable: false, + required: true, + }, + { + kind: "relation", + label: "Category", + name: "categoryId", + nullable: true, + required: false, + }, + ], + permissionModule: "notes", + pluginId: "@vitnode/example", + sections: [], + titleField: "title", +}; + +let nextUser = 10; +const member = ( + name: string, + overrides: Partial<ContentLiveMember> = {}, +): ContentLiveMember => { + nextUser += 1; + + return { + avatarColor: "2563eb", + clientId: `${name}-tab`, + field: null, + locale: "en", + name, + nameCode: name, + userId: nextUser, + ...overrides, + }; +}; + +const ME: ContentLiveMember = { + avatarColor: null, + clientId: "my-tab", + field: "title", + locale: "en", + name: "Me", + nameCode: "Me", + userId: 1, +}; + +const renderLive = ( + ui: React.ReactNode, + { live = true, members }: { live?: boolean; members: ContentLiveMember[] }, +) => { + const value: ContentLiveContextValue = { + autosave: { flush: vi.fn(), queue: vi.fn() }, + coEditing: live, + discard: vi.fn(), + itemId: 7, + locale: "en", + reloadDrafts: vi.fn(), + richText: createContentRichTextRegistry(), + session: { + clientId: ME.clientId, + focus: vi.fn(), + live, + locks: [], + members: [ME, ...members], + onCommitted: () => () => {}, + onDraft: () => () => {}, + onReset: () => () => {}, + readDrafts: async () => await Promise.resolve(null), + readyState: live ? 1 : 3, + refreshLocks: vi.fn(), + rememberSelf: vi.fn(), + resetLocally: vi.fn(), + self: ME.userId, + }, + spec, + status: { dirty: false, failed: false, savedAt: null, saving: false }, + }; + + return render(<ContentLiveContext value={value}>{ui}</ContentLiveContext>); +}; + +describe("ContentLivePresence", () => { + it("shows everyone else in the record, with where they are", () => { + renderLive(<ContentLivePresence />, { + members: [ + member("Anna", { field: "title", locale: "pl" }), + member("Ben", { field: "categoryId" }), + member("Cleo"), + ], + }); + + const group = screen.getByRole("group", { + name: "core.content.live.presence.label", + }); + expect( + within(group) + .getAllByRole("img") + .map(image => image.getAttribute("alt")), + ).toEqual(["Anna", "Ben", "Cleo"]); + expect( + within(group).getByText("core.content.live.presence.editing_field"), + ).toBeTruthy(); + expect( + within(group).getByText("core.content.live.presence.editing_shared"), + ).toBeTruthy(); + expect( + within(group).getByText("core.content.live.presence.in_language"), + ).toBeTruthy(); + expect(screen.queryByRole("img", { name: "Me" })).toBeNull(); + }); + + it("shows a person with two tabs once, where they are typing", () => { + const anna = member("Anna"); + renderLive(<ContentLivePresence />, { + members: [ + anna, + { ...anna, clientId: "anna-second-tab", field: "title" }, + { ...ME, clientId: "my-other-tab" }, + ], + }); + + expect( + screen.getAllByRole("img").map(image => image.getAttribute("alt")), + ).toEqual(["Anna"]); + expect( + screen.getByText("core.content.live.presence.editing_field"), + ).toBeTruthy(); + }); + + it("folds everyone past the limit into +N", () => { + renderLive(<ContentLivePresence max={2} />, { + members: ["Anna", "Ben", "Cleo", "Dan"].map(name => member(name)), + }); + + expect(screen.getAllByRole("img")).toHaveLength(2); + expect(screen.getByText("+2")).toBeTruthy(); + expect(screen.getByText("core.content.live.presence.more")).toBeTruthy(); + }); + + it("renders nothing when the editor is alone or the form is not live", () => { + const { container } = renderLive(<ContentLivePresence />, { members: [] }); + expect(container.childElementCount).toBe(0); + + const offline = renderLive(<ContentLivePresence />, { + live: false, + members: [member("Anna")], + }); + expect(offline.container.childElementCount).toBe(0); + }); +}); + +describe("ContentLiveFieldPresence", () => { + const members = [ + member("Anna", { field: "title", locale: "pl" }), + member("Ben", { field: "title", locale: "en" }), + member("Cleo", { field: "categoryId", locale: "pl" }), + member("Dan", { field: null, locale: "pl" }), + ]; + + it("shows the people in the field, in the language shown", () => { + renderLive(<ContentLiveFieldPresence field="title" locale="pl" />, { + members, + }); + + expect(screen.getByTitle("Anna")).toBeTruthy(); + expect(screen.queryByTitle("Ben")).toBeNull(); + expect(screen.queryByTitle("Cleo")).toBeNull(); + expect(screen.getByText("core.content.live.presence.field")).toBeTruthy(); + }); + + it("counts every language for a shared field", () => { + renderLive(<ContentLiveFieldPresence field="categoryId" locale={null} />, { + members, + }); + + expect(screen.getByTitle("Cleo")).toBeTruthy(); + expect(screen.queryByTitle("Anna")).toBeNull(); + }); + + it("leaves the announcement to the field when decorative", () => { + renderLive( + <ContentLiveFieldPresence decorative field="title" locale="en" />, + { members }, + ); + + expect(screen.getByTitle("Ben")).toBeTruthy(); + expect(screen.queryByText("core.content.live.presence.field")).toBeNull(); + }); +}); + +describe("ContentLiveLanguagePresence", () => { + it("shows who works in a language, whichever field they are in", () => { + renderLive(<ContentLiveLanguagePresence locale="pl" />, { + members: [ + member("Anna", { field: "title", locale: "pl" }), + member("Ben", { locale: "en" }), + member("Dan", { field: null, locale: "pl" }), + ], + }); + + expect(screen.getByTitle("Anna")).toBeTruthy(); + expect(screen.getByTitle("Dan")).toBeTruthy(); + expect(screen.queryByTitle("Ben")).toBeNull(); + expect( + screen.getByText("core.content.live.presence.language"), + ).toBeTruthy(); + }); + + it("narrows to one field when asked", () => { + renderLive(<ContentLiveLanguagePresence field="title" locale="pl" />, { + members: [ + member("Anna", { field: "title", locale: "pl" }), + member("Dan", { field: null, locale: "pl" }), + ], + }); + + expect(screen.getByTitle("Anna")).toBeTruthy(); + expect(screen.queryByTitle("Dan")).toBeNull(); + }); + + it("renders nothing for an empty language", () => { + const { container } = renderLive( + <ContentLiveLanguagePresence locale="de" />, + { members: [member("Anna", { locale: "pl" })] }, + ); + + expect(container.childElementCount).toBe(0); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/live/presence.tsx b/packages/vitnode/src/views/admin/views/content/live/presence.tsx new file mode 100644 index 0000000000..740c994861 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/presence.tsx @@ -0,0 +1,234 @@ +import { cn } from "cn"; +import { useReducedMotion } from "motion/react"; +import * as m from "motion/react-m"; +import React from "react"; +import { useLocale, useTranslations } from "use-intl"; + +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentLiveMember } from "@/content/live/protocol"; + +import { Avatar } from "@/components/avatar"; +import { + AvatarGroup, + AvatarGroupCount, + AvatarGroupItem, +} from "@/components/ui/avatar"; + +import { useContentLive } from "./context"; +import { + contentLiveFieldEditors, + contentLiveLanguageEditors, + contentLiveMemberColor, + contentLiveOthers, +} from "./presence-model"; + +const avatarUser = (member: ContentLiveMember) => ({ + avatarColor: contentLiveMemberColor(member.avatarColor).slice(1), + name: member.name, + nameCode: member.nameCode ?? member.name, +}); + +export const useContentLiveNames = () => { + const locale = useLocale(); + + return (members: readonly ContentLiveMember[]): string => + new Intl.ListFormat(locale, { type: "conjunction" }).format( + members.map(member => member.name), + ); +}; + +const useActivity = () => { + const t = useTranslations("core.content.live.presence"); + + return (member: ContentLiveMember, form: ContentFormSpec): string => { + const language = member.locale?.toUpperCase(); + if (member.field === null) { + return language && form.defaultLocale !== null + ? t("in_language", { language }) + : t("viewing"); + } + + const spec = form.fields.find(field => field.name === member.field); + const field = spec?.label ?? member.field; + + return spec?.localized === true && language + ? t("editing_field", { field, language }) + : t("editing_shared", { field }); + }; +}; + +export const ContentLivePresence = ({ + className, + max = 5, +}: { + className?: string; + max?: number; +}) => { + const t = useTranslations("core.content.live.presence"); + const live = useContentLive(); + const names = useContentLiveNames(); + const activity = useActivity(); + const reduced = useReducedMotion(); + if (!live?.session.live) return null; + + const others = contentLiveOthers(live.session.members, live.session); + if (others.length === 0) return null; + + const shown = others.slice(0, max); + const hidden = others.slice(max); + + return ( + <AvatarGroup + aria-label={t("label", { count: others.length })} + className={cn("items-center", className)} + role="group" + > + {shown.map(member => { + const where = activity(member, live.spec); + + return ( + <AvatarGroupItem + key={member.userId} + label={ + <span> + <span className="font-medium">{member.name}</span>, {where} + </span> + } + > + <m.span + animate={{ opacity: 1, scale: 1 }} + className="block rounded-full" + initial={{ opacity: 0, scale: 0.9 }} + transition={ + reduced + ? { duration: 0 } + : { duration: 0.22, ease: [0.22, 1, 0.36, 1] } + } + > + <Avatar + alt={member.name} + className={cn( + "ring-background ring-2", + member.field !== null && "outline-2 outline-offset-2", + )} + size={28} + style={ + member.field === null + ? undefined + : { + outlineColor: contentLiveMemberColor( + member.avatarColor, + ), + } + } + user={avatarUser(member)} + /> + </m.span> + <span className="sr-only">{where}</span> + </AvatarGroupItem> + ); + })} + {hidden.length > 0 ? ( + <AvatarGroupItem label={names(hidden)}> + <AvatarGroupCount className="size-7 text-xs"> + <span aria-hidden>+{hidden.length}</span> + <span className="sr-only"> + {t("more", { count: hidden.length, names: names(hidden) })} + </span> + </AvatarGroupCount> + </AvatarGroupItem> + ) : null} + </AvatarGroup> + ); +}; + +const SMALL_AVATARS_MAX = 3; + +const SmallAvatars = ({ + members, +}: { + members: readonly ContentLiveMember[]; +}) => ( + <span aria-hidden className="inline-flex items-center *:not-first:-ms-1.5"> + {members.slice(0, SMALL_AVATARS_MAX).map(member => ( + <Avatar + alt="" + className="ring-background ring-2" + key={member.userId} + size={20} + title={member.name} + user={avatarUser(member)} + /> + ))} + {members.length > SMALL_AVATARS_MAX ? ( + <span className="bg-muted text-muted-foreground ring-background inline-flex size-5 items-center justify-center rounded-full text-xs tabular-nums ring-2"> + +{members.length - SMALL_AVATARS_MAX} + </span> + ) : null} + </span> +); + +export const ContentLiveFieldPresence = ({ + className, + decorative = false, + field, + locale, +}: { + className?: string; + decorative?: boolean; + field: string; + locale: null | string; +}) => { + const t = useTranslations("core.content.live.presence"); + const live = useContentLive(); + const names = useContentLiveNames(); + if (!live?.session.live) return null; + + const editors = contentLiveFieldEditors(live.session.members, live.session, { + field, + locale, + }); + if (editors.length === 0) return null; + + return ( + <span className={cn("inline-flex items-center gap-1", className)}> + <SmallAvatars members={editors} /> + {decorative ? null : ( + <span className="sr-only"> + {t("field", { count: editors.length, names: names(editors) })} + </span> + )} + </span> + ); +}; + +export const ContentLiveLanguagePresence = ({ + className, + field, + locale, +}: { + className?: string; + field?: string; + locale: string; +}) => { + const t = useTranslations("core.content.live.presence"); + const live = useContentLive(); + const names = useContentLiveNames(); + if (!live?.session.live) return null; + + const editors = contentLiveLanguageEditors( + live.session.members, + live.session, + { field, locale }, + ); + if (editors.length === 0) return null; + + return ( + <span className={cn("inline-flex items-center gap-1", className)}> + <SmallAvatars members={editors} /> + <span className="sr-only"> + {t("language", { count: editors.length, names: names(editors) })} + </span> + </span> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/rich-text-field.tsx b/packages/vitnode/src/views/admin/views/content/live/rich-text-field.tsx new file mode 100644 index 0000000000..c1a3bc33cb --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/rich-text-field.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +import type { CollaborativeEditorProps } from "@/components/tiptap/collaboration"; + +import { EditorSkeleton } from "@/components/tiptap/editor-skeleton"; + +import { useContentLive } from "./context"; + +const ContentLiveRichTextEditor = React.lazy(async () => + import("./collaborative-editor").then(module => ({ + default: module.ContentLiveRichTextEditor, + })), +); + +export const ContentLiveRichTextFieldContext = React.createContext< + null | string +>(null); + +const ContentLiveCollaborativeEditor = (props: CollaborativeEditorProps) => { + const live = useContentLive(); + const field = React.use(ContentLiveRichTextFieldContext); + if (!live || field === null) return null; + + return ( + <React.Suspense fallback={<EditorSkeleton className={props.className} />}> + <ContentLiveRichTextEditor field={field} live={live} {...props} /> + </React.Suspense> + ); +}; + +export const renderContentLiveCollaborativeEditor = ( + props: CollaborativeEditorProps, +): React.ReactNode => <ContentLiveCollaborativeEditor {...props} />; diff --git a/packages/vitnode/src/views/admin/views/content/live/rich-text.test.ts b/packages/vitnode/src/views/admin/views/content/live/rich-text.test.ts new file mode 100644 index 0000000000..7f0d270a0a --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/rich-text.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { RichTextDocument } from "@/content/rich-text/document"; + +import { createContentRichTextRegistry } from "./rich-text"; + +const translated: RichTextDocument = { + content: [{ content: [{ text: "Cześć", type: "text" }], type: "paragraph" }], + type: "doc", +}; + +describe("createContentRichTextRegistry", () => { + it("writes through the open editor of that field and language", () => { + const registry = createContentRichTextRegistry(); + const english = { replace: vi.fn() }; + const polish = { replace: vi.fn() }; + registry.register("content", "en", english); + registry.register("content", "pl", polish); + + registry.replace("content", "pl", translated); + + expect(polish.replace).toHaveBeenCalledWith(translated); + expect(english.replace).not.toHaveBeenCalled(); + }); + + it("keeps a document for an editor that is not open yet", () => { + const registry = createContentRichTextRegistry(); + registry.replace("content", "pl", translated); + const polish = { replace: vi.fn() }; + + registry.register("content", "pl", polish); + registry.register("content", "pl", polish); + + expect(polish.replace).toHaveBeenCalledTimes(1); + expect(polish.replace).toHaveBeenCalledWith(translated); + }); + + it("forgets an editor once it closes, and tells subscribers", () => { + const registry = createContentRichTextRegistry(); + const listener = vi.fn(); + registry.subscribe(listener); + const editor = { replace: vi.fn() }; + + const close = registry.register("content", null, editor); + expect(registry.get("content", null)).toBe(editor); + + close(); + expect(registry.get("content", null)).toBeNull(); + expect(listener).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/live/rich-text.ts b/packages/vitnode/src/views/admin/views/content/live/rich-text.ts new file mode 100644 index 0000000000..113b74dcdc --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/rich-text.ts @@ -0,0 +1,102 @@ +import React from "react"; + +import type { RichTextDocument } from "@/content/rich-text/document"; + +import { useContentLive } from "./context"; + +export interface ContentRichTextEditorHandle { + replace: (document: RichTextDocument) => void; +} + +export interface ContentRichTextRegistry { + get: ( + field: string, + locale: null | string, + ) => ContentRichTextEditorHandle | null; + register: ( + field: string, + locale: null | string, + handle: ContentRichTextEditorHandle, + ) => () => void; + replace: ( + field: string, + locale: null | string, + document: RichTextDocument, + ) => void; + subscribe: (listener: () => void) => () => void; +} + +const keyOf = (field: string, locale: null | string): string => + `${field}\u0000${locale ?? ""}`; + +export const createContentRichTextRegistry = (): ContentRichTextRegistry => { + const editors = new Map<string, ContentRichTextEditorHandle>(); + const pending = new Map<string, RichTextDocument>(); + const listeners = new Set<() => void>(); + const notify = () => { + for (const listener of listeners) listener(); + }; + + return { + get: (field, locale) => editors.get(keyOf(field, locale)) ?? null, + register: (field, locale, handle) => { + const key = keyOf(field, locale); + editors.set(key, handle); + const waiting = pending.get(key); + if (waiting) { + pending.delete(key); + handle.replace(waiting); + } + notify(); + + return () => { + if (editors.get(key) !== handle) return; + editors.delete(key); + notify(); + }; + }, + replace: (field, locale, document) => { + const key = keyOf(field, locale); + const editor = editors.get(key); + if (editor) { + pending.delete(key); + editor.replace(document); + + return; + } + pending.set(key, document); + }, + subscribe: listener => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + }; +}; + +const subscribeNever = () => () => {}; +const getNull = () => null; + +export const useContentRichTextEditor = ( + field: string, + locale: null | string, +): ContentRichTextEditorHandle | null => { + const live = useContentLive(); + const registry = live?.richText; + + return React.useSyncExternalStore( + registry?.subscribe ?? subscribeNever, + registry ? () => registry.get(field, locale) : getNull, + getNull, + ); +}; + +export const useContentRichTextReplace = (): + | ((field: string, locale: null | string, document: RichTextDocument) => void) + | null => { + const live = useContentLive(); + + return live?.coEditing ? live.richText.replace : null; +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/root.tsx b/packages/vitnode/src/views/admin/views/content/live/root.tsx new file mode 100644 index 0000000000..7ec74d85fd --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/root.tsx @@ -0,0 +1,333 @@ +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentDrafts } from "@/content/live/http"; + +import { CONTENT_DRAFT_AUTOSAVE_MS } from "@/content/live/protocol"; + +import type { ContentRow, TranslationRow } from "../content-mutation"; +import type { + ContentLiveAutosaveStatus, + ContentLiveContextValue, +} from "./context"; +import type { ContentLiveResetReason } from "./use-session"; + +import { useContentFormNavigation } from "../form/navigation"; +import { useContentFormTransport } from "../form/transport"; +import { ContentLiveContext } from "./context"; +import { createContentRichTextRegistry } from "./rich-text"; +import { useContentLiveSession } from "./use-session"; +import { + contentDraftDiffers, + contentDraftMovedReferences, + contentDraftValue, + mergeContentDraft, +} from "./values"; + +export interface ContentLiveFormState { + drafts: ContentDrafts | null; + labels: Record<string, string>; + row: ContentRow; + translations: readonly TranslationRow[]; +} + +interface Reloaded { + for: ContentRow; + row: ContentRow; + translations: readonly TranslationRow[]; +} + +interface PendingValue { + field: string; + locale: null | string; + value: unknown; +} + +const OWN_DISCARD_ECHO_MS = 5_000; + +const IDLE: ContentLiveAutosaveStatus = { + dirty: false, + failed: false, + savedAt: null, + saving: false, +}; + +export const ContentLiveRoot = ({ + children, + data, + fallback, + spec, + translations, +}: { + children: (state: ContentLiveFormState) => React.ReactNode; + data: ContentRow; + fallback: React.ReactNode; + spec: ContentFormSpec; + translations: readonly TranslationRow[]; +}) => { + const t = useTranslations("core.content.live"); + const transport = useContentFormTransport(); + const { refresh } = useContentFormNavigation(); + const contentTypeId = spec.contentTypeId; + const itemId = data.id; + const session = useContentLiveSession({ + contentTypeId, + itemId, + locale: spec.defaultLocale, + }); + const { onCommitted, onDraft, onReset, readDrafts } = session; + + const [coEditing, setCoEditing] = React.useState(false); + if (session.live && !coEditing) setCoEditing(true); + const [richText] = React.useState(createContentRichTextRegistry); + + const [opening, setOpening] = React.useState< + undefined | { drafts: ContentDrafts | null; labels: Record<string, string> } + >(undefined); + const [drafts, setDrafts] = React.useState<ContentDrafts | null>(null); + const [reloaded, setReloaded] = React.useState<null | Reloaded>(null); + const [generation, setGeneration] = React.useState(0); + const [status, setStatus] = + React.useState<Omit<ContentLiveAutosaveStatus, "dirty">>(IDLE); + + const row = reloaded?.for === data ? reloaded.row : data; + const rows = reloaded?.for === data ? reloaded.translations : translations; + + const draftsRef = React.useRef(drafts); + React.useEffect(() => { + draftsRef.current = drafts; + }, [drafts]); + + const load = React.useEffectEvent(async (base?: ContentRow) => { + const read = await readDrafts(); + const moved = contentDraftMovedReferences(spec, base ?? row, read); + const found = await Promise.all( + moved.map(async ({ field, id }) => { + const [option] = await transport.loadOptions(contentTypeId, field, "", [ + id, + ]); + + return option ? ([[field, option.label]] as const) : []; + }), + ); + + return { labels: Object.fromEntries(found.flat()), read }; + }); + + React.useEffect(() => { + let active = true; + + void load().then(result => { + if (!active) return; + setOpening({ drafts: result.read, labels: result.labels }); + setDrafts(result.read); + }); + + return () => { + active = false; + }; + }, []); + + React.useEffect( + () => + onDraft(event => { + setDrafts(current => mergeContentDraft(current, event)); + }), + [onDraft], + ); + + const reloadDrafts = React.useCallback(async () => { + setDrafts(await readDrafts()); + }, [readDrafts]); + + React.useEffect( + () => + onCommitted(() => { + void reloadDrafts(); + }), + [onCommitted, reloadDrafts], + ); + + const pendingRef = React.useRef(new Map<string, PendingValue>()); + const timerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined); + const chainRef = React.useRef<Promise<void>>(Promise.resolve()); + + const saveNow = React.useCallback(async () => { + clearTimeout(timerRef.current); + timerRef.current = undefined; + + const batch = [...pendingRef.current.values()]; + pendingRef.current.clear(); + if (batch.length === 0) return; + + const byLocale = new Map<null | string, Record<string, unknown>>(); + for (const { field, locale, value } of batch) { + byLocale.set(locale, { ...byLocale.get(locale), [field]: value }); + } + + setStatus(current => ({ ...current, saving: true })); + let failed = false; + let savedAt: null | string = null; + + for (const [locale, values] of byLocale) { + const result = await transport.saveDraft(contentTypeId, itemId, { + locale, + values, + }); + + if (result.updatedAt === undefined) { + failed = true; + continue; + } + + const updatedAt = result.updatedAt; + savedAt = updatedAt; + setDrafts(current => + mergeContentDraft(current, { + by: null, + locale, + updatedAt, + values, + }), + ); + } + + setStatus(current => ({ + failed, + saving: false, + savedAt: savedAt ?? current.savedAt, + })); + }, [contentTypeId, itemId, transport]); + + const flush = React.useCallback(async () => { + chainRef.current = chainRef.current.then(saveNow, saveNow); + await chainRef.current; + }, [saveNow]); + + const queue = React.useCallback( + (field: string, locale: null | string, value: unknown) => { + const key = `${locale ?? ""}\u0000${field}`; + const stored = contentDraftValue(draftsRef.current, field, locale); + + if (stored && JSON.stringify(stored.value) === JSON.stringify(value)) { + pendingRef.current.delete(key); + + return; + } + + pendingRef.current.set(key, { field, locale, value }); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + void flush(); + }, CONTENT_DRAFT_AUTOSAVE_MS); + }, + [flush], + ); + + React.useEffect( + () => () => { + void flush(); + }, + [flush], + ); + + const ownDiscardRef = React.useRef<"handled" | "idle" | "pending">("idle"); + + const reset = React.useEffectEvent( + async (reason: ContentLiveResetReason, own: boolean) => { + pendingRef.current.clear(); + clearTimeout(timerRef.current); + + if (reason === "deleted") { + toast.warning(t("reset.deleted.title"), { + description: t("reset.deleted.desc"), + }); + refresh(); + + return; + } + + const [fresh, list] = await Promise.all([ + transport.reloadRow(contentTypeId, itemId), + spec.defaultLocale === null + ? Promise.resolve({ edges: [...rows] }) + : transport.listTranslations(contentTypeId, itemId), + ]); + const next = fresh.row ? { ...data, ...fresh.row } : row; + const { labels, read } = await load(next); + + setReloaded({ for: data, row: next, translations: list.edges }); + setOpening({ drafts: read, labels }); + setDrafts(read); + setStatus(IDLE); + setGeneration(current => current + 1); + if (own) { + toast.success(t("reset.discarded_own.title"), { + description: t("reset.discarded_own.desc"), + }); + } else { + toast.info(t(`reset.${reason}.title`), { + description: t(`reset.${reason}.desc`), + }); + } + refresh(); + }, + ); + + const discard = async (): Promise<boolean> => { + pendingRef.current.clear(); + clearTimeout(timerRef.current); + ownDiscardRef.current = "pending"; + + const result = await transport.discardDraft(contentTypeId, itemId); + if (result.error !== undefined) { + ownDiscardRef.current = "idle"; + toast.error(t("status.discard_failed")); + + return false; + } + + if (ownDiscardRef.current === "pending") session.resetLocally("discarded"); + window.setTimeout(() => { + ownDiscardRef.current = "idle"; + }, OWN_DISCARD_ECHO_MS); + + return true; + }; + + React.useEffect( + () => + onReset(reason => { + const own = reason === "discarded" && ownDiscardRef.current !== "idle"; + if (own && ownDiscardRef.current === "handled") return; + if (own) ownDiscardRef.current = "handled"; + void reset(reason, own); + }), + [onReset], + ); + + if (opening === undefined) return <>{fallback}</>; + + const value: ContentLiveContextValue = { + autosave: { flush, queue }, + coEditing, + discard, + itemId, + locale: spec.defaultLocale, + reloadDrafts, + richText, + session, + spec, + status: { ...status, dirty: contentDraftDiffers(row, rows, drafts) }, + }; + + return ( + <ContentLiveContext value={value}> + <React.Fragment key={generation}> + {children({ ...opening, row, translations: rows })} + </React.Fragment> + </ContentLiveContext> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/status.test.tsx b/packages/vitnode/src/views/admin/views/content/live/status.test.tsx new file mode 100644 index 0000000000..01016b165b --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/status.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + ContentLiveAutosaveStatus, + ContentLiveContextValue, +} from "./context"; + +import { ContentLiveContext } from "./context"; +import { ContentLiveStatus } from "./status"; + +const NOW = new Date("2026-10-10T12:00:00.000Z"); + +const renderStatus = ( + status: Partial<ContentLiveAutosaveStatus>, + discard: () => Promise<boolean> = async () => await Promise.resolve(true), +) => + render( + <IntlProvider locale="en" messages={{}} timeZone="UTC"> + <ContentLiveContext + value={ + { + discard, + status: { + dirty: false, + failed: false, + savedAt: null, + saving: false, + ...status, + }, + } as ContentLiveContextValue + } + > + <ContentLiveStatus /> + </ContentLiveContext> + </IntlProvider>, + ); + +describe("ContentLiveStatus", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("says the draft was saved just now right after an autosave", () => { + renderStatus({ savedAt: NOW.toISOString() }); + + expect( + screen.getByText("core.content.live.status.saved_just_now"), + ).toBeTruthy(); + }); + + it("switches to the relative time once a minute has passed", () => { + renderStatus({ savedAt: NOW.toISOString() }); + + act(() => { + vi.advanceTimersByTime(75_000); + }); + + expect( + screen.queryByText("core.content.live.status.saved_just_now"), + ).toBeNull(); + expect(screen.getByText("core.content.live.status.saved")).toBeTruthy(); + }); + + it("keeps the ticking time out of the live region", () => { + renderStatus({ savedAt: NOW.toISOString() }); + + const visible = screen.getByText("core.content.live.status.saved_just_now"); + + expect(visible.getAttribute("aria-hidden")).toBe("true"); + expect(visible.getAttribute("dateTime")).toBe(NOW.toISOString()); + expect( + screen.getByText("core.content.live.status.saved_at").className, + ).toContain("sr-only"); + }); + + it("shows saving and failure instead of the saved time", () => { + const { rerender } = renderStatus({ + saving: true, + savedAt: NOW.toISOString(), + }); + + expect(screen.getByText("core.content.live.status.saving")).toBeTruthy(); + expect( + screen.queryByText("core.content.live.status.saved_just_now"), + ).toBeNull(); + + rerender( + <IntlProvider locale="en" messages={{}} timeZone="UTC"> + <ContentLiveContext + value={ + { + status: { + dirty: true, + failed: true, + savedAt: NOW.toISOString(), + saving: false, + }, + } as ContentLiveContextValue + } + > + <ContentLiveStatus /> + </ContentLiveContext> + </IntlProvider>, + ); + + expect(screen.getByText("core.content.live.status.failed")).toBeTruthy(); + expect(screen.getByText("core.content.live.status.unsaved")).toBeTruthy(); + }); + + it("discards unsaved changes once the person confirms", async () => { + vi.useRealTimers(); + const discard = vi.fn(async () => await Promise.resolve(true)); + renderStatus({ dirty: true }, discard); + + fireEvent.click( + screen.getByRole("button", { + name: "core.content.live.status.discard_label", + }), + ); + expect(discard).not.toHaveBeenCalled(); + + fireEvent.click( + await screen.findByRole("button", { + name: "core.content.live.status.discard_confirm", + }), + ); + await waitFor(() => { + expect(discard).toHaveBeenCalledTimes(1); + }); + }); + + it("offers no discard while nothing is unsaved", () => { + renderStatus({ savedAt: NOW.toISOString() }); + + expect( + screen.queryByRole("button", { + name: "core.content.live.status.discard_label", + }), + ).toBeNull(); + }); + + it("renders nothing before the first change", () => { + const { container } = renderStatus({}); + + expect(container.textContent).toBe(""); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/live/status.tsx b/packages/vitnode/src/views/admin/views/content/live/status.tsx new file mode 100644 index 0000000000..767465855b --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/status.tsx @@ -0,0 +1,115 @@ +import { cn } from "cn"; +import { RotateCcwIcon } from "lucide-react"; +import { useFormatter, useNow, useTranslations } from "use-intl"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; + +import { useContentLive } from "./context"; + +const SAVED_JUST_NOW_MS = 60_000; +const SAVED_TICK_MS = 15_000; + +const SavedAt = ({ savedAt }: { savedAt: string }) => { + const t = useTranslations("core.content.live.status"); + const format = useFormatter(); + const now = useNow({ updateInterval: SAVED_TICK_MS }); + const date = new Date(savedAt); + const exact = format.dateTime(date, { timeStyle: "short" }); + const relative = + now.getTime() - date.getTime() < SAVED_JUST_NOW_MS + ? t("saved_just_now") + : t("saved", { time: format.relativeTime(date, now) }); + + return ( + <> + <time + aria-hidden + dateTime={savedAt} + suppressHydrationWarning + title={exact} + > + {relative} + </time> + <span className="sr-only">{t("saved_at", { time: exact })}</span> + </> + ); +}; + +const DIALOG_CLOSE_MS = 300; + +const DiscardChanges = ({ + onDiscard, +}: { + onDiscard: () => Promise<boolean>; +}) => { + const t = useTranslations("core.content.live.status"); + + return ( + <ConfirmActionAlertDialog + description={t("discard_desc")} + icon={<RotateCcwIcon />} + onSubmit={({ onClose }) => { + onClose(); + window.setTimeout(() => { + void onDiscard(); + }, DIALOG_CLOSE_MS); + }} + textSubmit={t("discard_confirm")} + title={t("discard_title")} + > + <Button + aria-label={t("discard_label")} + className="text-muted-foreground hover:text-foreground" + size="xs" + type="button" + variant="ghost" + > + <RotateCcwIcon aria-hidden /> + <span className="max-sm:sr-only">{t("discard")}</span> + </Button> + </ConfirmActionAlertDialog> + ); +}; + +export const ContentLiveStatus = ({ className }: { className?: string }) => { + const t = useTranslations("core.content.live.status"); + const live = useContentLive(); + if (!live) return null; + + const { dirty, failed, savedAt, saving } = live.status; + if (!dirty && !failed && !saving && !savedAt) return null; + + return ( + <div className={cn("flex min-w-0 items-center gap-1", className)}> + <p + className="text-muted-foreground flex min-w-0 items-center gap-2 text-sm leading-relaxed" + role="status" + > + {saving ? ( + <span className="flex items-center gap-1.5"> + <Spinner className="size-3.5" /> + <span className="max-sm:sr-only">{t("saving")}</span> + </span> + ) : failed ? ( + <span className="text-destructive">{t("failed")}</span> + ) : savedAt ? ( + <span className={dirty ? "sr-only" : "max-lg:sr-only"}> + <SavedAt savedAt={savedAt} /> + </span> + ) : null} + {dirty ? ( + <span className="text-foreground flex items-center gap-1.5 font-medium"> + <span + aria-hidden + className="bg-warn size-2 shrink-0 rounded-full" + /> + <span className="max-sm:sr-only">{t("unsaved")}</span> + </span> + ) : null} + </p> + {dirty ? <DiscardChanges onDiscard={live.discard} /> : null} + </div> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx b/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx new file mode 100644 index 0000000000..fe82fbbfb8 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx @@ -0,0 +1,437 @@ +// @vitest-environment jsdom +import { act, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + ContentLiveClientMessage, + ContentLiveServerMessage, +} from "@/content/live/protocol"; + +import { + CONTENT_LIVE_POLL_MS, + contentLiveChannel, +} from "@/content/live/protocol"; +import { VitNodeWebSocketProvider } from "@/ws/provider"; + +import type { ContentFormTransport } from "../form/transport"; +import type { ContentLiveDraftEvent, ContentLiveSession } from "./use-session"; + +import { ContentFormTransportProvider } from "../form/transport"; +import { useContentLiveSession } from "./use-session"; + +const ROOM = { contentTypeId: "test.note", itemId: 7 }; + +class FakeWebSocket { + constructor(url: string) { + this.url = url; + sockets.push(this); + } + + static readonly CLOSED = 3; + static readonly CLOSING = 2; + static readonly CONNECTING = 0; + static readonly OPEN = 1; + + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onopen: (() => void) | null = null; + readyState = FakeWebSocket.CONNECTING; + sent: ContentLiveClientMessage[] = []; + readonly url: string; + + close() { + this.readyState = FakeWebSocket.CLOSED; + } + + drop() { + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.(); + } + + open() { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + + push(data: ContentLiveServerMessage) { + this.onmessage?.({ + data: JSON.stringify({ data, id: contentLiveChannel.id }), + }); + } + + send(raw: string) { + const message = JSON.parse(raw) as { data: ContentLiveClientMessage }; + this.sent.push(message.data); + } +} + +let sockets: FakeWebSocket[] = []; + +const joinsOn = (socket: FakeWebSocket | undefined) => + socket?.sent.filter(message => message.type === "join") ?? []; + +const Probe = ({ + onDraft, + onSession, +}: { + onDraft?: (event: ContentLiveDraftEvent) => void; + onSession?: (session: ContentLiveSession) => void; +}) => { + const session = useContentLiveSession({ ...ROOM, locale: "en" }); + React.useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- hands the hook's API to the test + onSession?.(session); + }); + + React.useEffect(() => { + if (!onDraft) return; + + return session.onDraft(onDraft); + }, [onDraft, session]); + + return ( + <dl> + <dt>live</dt> + <dd data-testid="live">{String(session.live)}</dd> + <dt>members</dt> + <dd data-testid="members"> + {session.members.map(member => member.name).join(",")} + </dd> + <dt>locks</dt> + <dd data-testid="locks"> + {session.locks.map(lock => lock.field).join(",")} + </dd> + </dl> + ); +}; + +const transportOf = () => { + const listLocks = vi.fn<ContentFormTransport["listLocks"]>( + async () => await Promise.resolve({ locks: [] }), + ); + const readDraft = vi.fn<ContentFormTransport["readDraft"]>( + async () => + await Promise.resolve({ drafts: { shared: null, translations: {} } }), + ); + + return { + listLocks, + readDraft, + transport: { listLocks, readDraft } as unknown as ContentFormTransport, + }; +}; + +const mount = ( + onDraft?: (event: ContentLiveDraftEvent) => void, + onSession?: (session: ContentLiveSession) => void, +) => { + const fake = transportOf(); + + const view = render( + <ContentFormTransportProvider value={fake.transport}> + <VitNodeWebSocketProvider> + <Probe onDraft={onDraft} onSession={onSession} /> + </VitNodeWebSocketProvider> + </ContentFormTransportProvider>, + ); + + return { ...fake, view }; +}; + +const lockOf = (field: string) => ({ + expiresAt: new Date(Date.now() + 60_000).toISOString(), + field, + locale: null, + user: { id: 2, name: "Anna" }, +}); + +const member = (clientId: string, name: string) => ({ + avatarColor: null, + clientId, + field: null, + locale: "en", + name, + nameCode: null, + userId: 2, +}); + +describe("useContentLiveSession", () => { + describe("with a socket", () => { + beforeEach(() => { + sockets = []; + vi.stubEnv("VITNODE_API_URL", "http://localhost:8000"); + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("BroadcastChannel", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("joins the record's room once the socket opens", () => { + mount(); + + expect(joinsOn(sockets[0])).toEqual([]); + act(() => sockets[0]?.open()); + + expect(joinsOn(sockets[0])).toEqual([ + { + clientId: expect.any(String), + locale: "en", + room: ROOM, + type: "join", + }, + ]); + }); + + it("goes live on its own joined reply, not another tab's", () => { + mount(); + act(() => sockets[0]?.open()); + const clientId = joinsOn(sockets[0])[0]?.clientId ?? ""; + + act(() => + sockets[0]?.push({ + clientId: "another-tab", + members: [], + room: ROOM, + type: "joined", + }), + ); + expect(screen.getByTestId("live").textContent).toBe("false"); + + act(() => + sockets[0]?.push({ + clientId, + members: [member(clientId, "Anna")], + room: ROOM, + type: "joined", + }), + ); + expect(screen.getByTestId("live").textContent).toBe("true"); + expect(screen.getByTestId("members").textContent).toBe("Anna"); + }); + + it("keeps only its own room's locks, presence and drafts", () => { + const onDraft = vi.fn(); + mount(onDraft); + act(() => sockets[0]?.open()); + const other = { contentTypeId: "test.note", itemId: 8 }; + + act(() => { + sockets[0]?.push({ + locks: [lockOf("body")], + room: other, + type: "locks", + }); + sockets[0]?.push({ + members: [member("x", "Ben")], + room: other, + type: "presence", + }); + sockets[0]?.push({ + by: { id: 2, name: "Anna" }, + locale: null, + room: other, + type: "draft", + updatedAt: new Date().toISOString(), + values: { title: "Elsewhere" }, + }); + }); + expect(screen.getByTestId("locks").textContent).toBe(""); + expect(screen.getByTestId("members").textContent).toBe(""); + expect(onDraft).not.toHaveBeenCalled(); + + act(() => { + sockets[0]?.push({ + locks: [lockOf("title")], + room: ROOM, + type: "locks", + }); + sockets[0]?.push({ + by: { id: 2, name: "Anna" }, + locale: null, + room: ROOM, + type: "draft", + updatedAt: new Date().toISOString(), + values: { title: "Here" }, + }); + }); + expect(screen.getByTestId("locks").textContent).toBe("title"); + expect(onDraft).toHaveBeenCalledWith( + expect.objectContaining({ values: { title: "Here" } }), + ); + }); + + it("joins again after a reconnect, since the server forgot the tab", async () => { + mount(); + act(() => sockets[0]?.open()); + const clientId = joinsOn(sockets[0])[0]?.clientId; + + act(() => sockets[0]?.drop()); + await waitFor(() => expect(sockets).toHaveLength(2), { timeout: 4_000 }); + act(() => sockets[1]?.open()); + + expect(joinsOn(sockets[1])).toEqual([ + expect.objectContaining({ clientId, room: ROOM, type: "join" }), + ]); + }); + + it("leaves the room when the form goes away", () => { + const { transport, view } = mount(); + act(() => sockets[0]?.open()); + + view.rerender( + <ContentFormTransportProvider value={transport}> + <VitNodeWebSocketProvider>{null}</VitNodeWebSocketProvider> + </ContentFormTransportProvider>, + ); + + expect(sockets[0]?.sent.at(-1)).toMatchObject({ + room: ROOM, + type: "leave", + }); + }); + + it("takes its seat again when the server says it lost it", () => { + mount(); + act(() => sockets[0]?.open()); + const clientId = joinsOn(sockets[0])[0]?.clientId ?? ""; + act(() => + sockets[0]?.push({ + clientId, + members: [member(clientId, "Anna")], + room: ROOM, + type: "joined", + }), + ); + + act(() => { + sockets[0]?.push({ + clientId, + code: "NOT_JOINED", + room: ROOM, + type: "error", + }); + sockets[0]?.push({ + clientId, + code: "NOT_JOINED", + room: ROOM, + type: "error", + }); + }); + + expect(joinsOn(sockets[0])).toHaveLength(2); + expect(screen.getByTestId("live").textContent).toBe("true"); + }); + + it("keeps its seat over a document's error, and loses it when refused", () => { + mount(); + act(() => sockets[0]?.open()); + const clientId = joinsOn(sockets[0])[0]?.clientId ?? ""; + act(() => + sockets[0]?.push({ + clientId, + members: [member(clientId, "Anna")], + room: ROOM, + type: "joined", + }), + ); + + act(() => { + sockets[0]?.push({ + clientId, + code: "NOT_FOUND", + room: ROOM, + type: "error", + }); + sockets[0]?.push({ + clientId, + code: "INVALID_MESSAGE", + type: "error", + }); + }); + expect(screen.getByTestId("live").textContent).toBe("true"); + + act(() => + sockets[0]?.push({ + clientId, + code: "FORBIDDEN", + room: ROOM, + type: "error", + }), + ); + expect(screen.getByTestId("live").textContent).toBe("false"); + }); + + it("stays in its language while it moves to a shared field", () => { + let session: ContentLiveSession | undefined; + mount(undefined, current => { + session = current; + }); + act(() => sockets[0]?.open()); + + act(() => { + session?.focus("title", "pl"); + session?.focus("categoryId", null); + session?.focus(null, null); + }); + + expect( + sockets[0]?.sent.filter(message => message.type === "focus"), + ).toEqual([ + expect.objectContaining({ field: "title", locale: "pl" }), + expect.objectContaining({ field: "categoryId", locale: "pl" }), + expect.objectContaining({ field: null, locale: "pl" }), + ]); + }); + }); + + describe("without a socket", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("polls the locks and the draft over HTTP", async () => { + const onDraft = vi.fn(); + const { listLocks, readDraft } = mount(onDraft); + await waitFor(() => expect(listLocks).toHaveBeenCalledTimes(1)); + + readDraft.mockResolvedValueOnce({ + drafts: { + shared: { + baseVersion: 3, + updatedAt: "2026-10-08T12:00:00.000Z", + updatedBy: { id: 2, name: "Anna" }, + values: { title: "Polled" }, + }, + translations: {}, + }, + }); + listLocks.mockResolvedValueOnce({ locks: [lockOf("title")] }); + + await act(async () => { + vi.advanceTimersByTime(CONTENT_LIVE_POLL_MS); + await Promise.resolve(); + }); + + await waitFor(() => + expect(screen.getByTestId("locks").textContent).toBe("title"), + ); + expect(onDraft).toHaveBeenCalledWith( + expect.objectContaining({ + by: { id: 2, name: "Anna" }, + locale: null, + values: { title: "Polled" }, + }), + ); + expect(screen.getByTestId("live").textContent).toBe("false"); + }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/live/use-session.ts b/packages/vitnode/src/views/admin/views/content/live/use-session.ts new file mode 100644 index 0000000000..9adb61bb25 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/use-session.ts @@ -0,0 +1,368 @@ +import React from "react"; + +import type { ContentDrafts } from "@/content/live/http"; +import type { + ContentFieldLock, + ContentLiveMember, + ContentLiveResetReason, + ContentLiveRoomRef, + ContentLiveServerMessage, +} from "@/content/live/protocol"; + +import { + CONTENT_LIVE_HEARTBEAT_MS, + CONTENT_LIVE_POLL_MS, + contentLiveChannel, + sameContentLiveRoom, +} from "@/content/live/protocol"; +import { useVitNodeWebSocket } from "@/ws/use-websocket"; + +import { useContentFormTransport } from "../form/transport"; + +export interface ContentLiveDraftEvent { + by: null | { id: number; name: string }; + locale: null | string; + updatedAt: string; + values: Record<string, unknown>; +} + +export type { ContentLiveResetReason } from "@/content/live/protocol"; + +export interface ContentLiveSession { + clientId: string; + focus: (field: null | string, locale: null | string) => void; + live: boolean; + locks: ContentFieldLock[]; + members: ContentLiveMember[]; + onCommitted: (listener: () => void) => () => void; + onDraft: (listener: (event: ContentLiveDraftEvent) => void) => () => void; + onReset: (listener: (reason: ContentLiveResetReason) => void) => () => void; + readDrafts: () => Promise<ContentDrafts | null>; + readyState: number; + refreshLocks: () => Promise<void>; + rememberSelf: (userId: number) => void; + resetLocally: (reason: ContentLiveResetReason) => void; + self: null | number; +} + +const newClientId = (): string => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + +const draftKey = (locale: null | string): string => locale ?? ""; + +const draftEvents = (drafts: ContentDrafts): ContentLiveDraftEvent[] => [ + ...(drafts.shared + ? [ + { + by: drafts.shared.updatedBy, + locale: null, + updatedAt: drafts.shared.updatedAt, + values: drafts.shared.values, + }, + ] + : []), + ...Object.entries(drafts.translations).map(([locale, draft]) => ({ + by: draft.updatedBy, + locale, + updatedAt: draft.updatedAt, + values: draft.values, + })), +]; + +export const useContentLiveSession = ({ + contentTypeId, + itemId, + locale, +}: ContentLiveRoomRef & { locale: null | string }): ContentLiveSession => { + const transport = useContentFormTransport(); + const [clientId] = React.useState(newClientId); + const room = React.useMemo( + () => ({ contentTypeId, itemId }), + [contentTypeId, itemId], + ); + + const [joined, setJoined] = React.useState(false); + const [members, setMembers] = React.useState<ContentLiveMember[]>([]); + const [locks, setLocks] = React.useState<ContentFieldLock[]>([]); + const [self, setSelf] = React.useState<null | number>(null); + + const draftListenersRef = React.useRef( + new Set<(event: ContentLiveDraftEvent) => void>(), + ); + const resetListenersRef = React.useRef( + new Set<(reason: ContentLiveResetReason) => void>(), + ); + const committedListenersRef = React.useRef(new Set<() => void>()); + const seenDraftsRef = React.useRef(new Map<string, string>()); + const focusedRef = React.useRef<null | { + field: null | string; + locale: null | string; + }>(null); + const languageRef = React.useRef(locale); + const joiningRef = React.useRef(false); + const joinedRef = React.useRef(false); + + const emitDraft = React.useCallback((event: ContentLiveDraftEvent) => { + seenDraftsRef.current.set(draftKey(event.locale), event.updatedAt); + for (const listener of draftListenersRef.current) listener(event); + }, []); + + const loadLocks = React.useCallback(async () => { + const result = await transport.listLocks(contentTypeId, itemId); + + return result.error === undefined ? result.locks : null; + }, [contentTypeId, itemId, transport]); + + const refreshLocks = React.useCallback(async () => { + const next = await loadLocks(); + if (next) setLocks(next); + }, [loadLocks]); + + const readDrafts = React.useCallback(async () => { + const { drafts } = await transport.readDraft(contentTypeId, itemId); + if (!drafts) return null; + + seenDraftsRef.current = new Map( + draftEvents(drafts).map(event => [ + draftKey(event.locale), + event.updatedAt, + ]), + ); + + return drafts; + }, [contentTypeId, itemId, transport]); + + const pollDrafts = React.useCallback(async () => { + const { drafts } = await transport.readDraft(contentTypeId, itemId); + if (!drafts) return; + + for (const event of draftEvents(drafts)) { + if ( + seenDraftsRef.current.get(draftKey(event.locale)) !== event.updatedAt + ) { + emitDraft(event); + } + } + }, [contentTypeId, emitDraft, itemId, transport]); + + const onMessage = (message: ContentLiveServerMessage) => { + switch (message.type) { + case "committed": + if (!sameContentLiveRoom(message.room, room)) return; + for (const listener of committedListenersRef.current) listener(); + + return; + case "draft": + if (!sameContentLiveRoom(message.room, room)) return; + emitDraft({ + by: message.by, + locale: message.locale, + updatedAt: message.updatedAt, + values: message.values, + }); + + return; + case "error": + if (message.clientId !== clientId) return; + if (message.room && !sameContentLiveRoom(message.room, room)) return; + + if (message.code === "NOT_JOINED") { + if (!joiningRef.current && readyStateRef.current === 1) { + joiningRef.current = true; + send({ clientId, locale: languageRef.current, room, type: "join" }); + } + + return; + } + if (message.code === "INVALID_MESSAGE") return; + if (message.code === "NOT_FOUND" && joinedRef.current) return; + + joiningRef.current = false; + setJoined(false); + + return; + case "joined": { + if ( + message.clientId !== clientId || + !sameContentLiveRoom(message.room, room) + ) { + return; + } + joiningRef.current = false; + setJoined(true); + setMembers(message.members); + const me = message.members.find(member => member.clientId === clientId); + if (me) setSelf(me.userId); + + return; + } + case "locks": + if (sameContentLiveRoom(message.room, room)) setLocks(message.locks); + + return; + case "presence": + if (sameContentLiveRoom(message.room, room)) { + setMembers(message.members); + } + + return; + case "reset": + if (!sameContentLiveRoom(message.room, room)) return; + for (const listener of resetListenersRef.current) { + listener(message.reason); + } + + return; + default: + return; + } + }; + + const { readyState, send } = useVitNodeWebSocket(contentLiveChannel, { + onMessage, + }); + const readyStateRef = React.useRef(readyState); + React.useEffect(() => { + readyStateRef.current = readyState; + }, [readyState]); + + React.useEffect(() => { + if (readyState !== 1) return; + + joiningRef.current = true; + send({ clientId, locale: languageRef.current, room, type: "join" }); + + return () => { + joiningRef.current = false; + setJoined(false); + }; + }, [clientId, readyState, room, send]); + + React.useEffect(() => { + const leave = () => { + send({ clientId, room, type: "leave" }); + }; + window.addEventListener("pagehide", leave); + + return () => { + window.removeEventListener("pagehide", leave); + leave(); + }; + }, [clientId, room, send]); + + const live = joined && readyState === 1; + React.useEffect(() => { + joinedRef.current = live; + }, [live]); + + React.useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler -- `joined` arrives over the socket; this resyncs once per seat + if (!live) return; + + void loadLocks().then(next => { + if (next) setLocks(next); + }); + void pollDrafts(); + if (focusedRef.current) { + send({ clientId, room, type: "focus", ...focusedRef.current }); + } + }, [clientId, live, loadLocks, pollDrafts, room, send]); + + React.useEffect(() => { + if (!live) return; + + const timer = setInterval(() => { + send({ clientId, room, type: "heartbeat" }); + }, CONTENT_LIVE_HEARTBEAT_MS); + + return () => { + clearInterval(timer); + }; + }, [clientId, live, room, send]); + + React.useEffect(() => { + void loadLocks().then(next => { + if (next) setLocks(next); + }); + }, [loadLocks]); + + React.useEffect(() => { + if (live) return; + + const timer = setInterval(() => { + void loadLocks().then(next => { + if (next) setLocks(next); + }); + void pollDrafts(); + }, CONTENT_LIVE_POLL_MS); + + return () => { + clearInterval(timer); + }; + }, [live, loadLocks, pollDrafts]); + + const focus = React.useCallback( + (field: null | string, fieldLocale: null | string) => { + if (fieldLocale !== null) languageRef.current = fieldLocale; + const next = { field, locale: languageRef.current }; + focusedRef.current = next; + if (readyStateRef.current !== 1) return; + + send({ clientId, room, type: "focus", ...next }); + }, + [clientId, room, send], + ); + + const onDraft = React.useCallback( + (listener: (event: ContentLiveDraftEvent) => void) => { + draftListenersRef.current.add(listener); + + return () => { + draftListenersRef.current.delete(listener); + }; + }, + [], + ); + + const onCommitted = React.useCallback((listener: () => void) => { + committedListenersRef.current.add(listener); + + return () => { + committedListenersRef.current.delete(listener); + }; + }, []); + + const onReset = React.useCallback( + (listener: (reason: ContentLiveResetReason) => void) => { + resetListenersRef.current.add(listener); + + return () => { + resetListenersRef.current.delete(listener); + }; + }, + [], + ); + + const resetLocally = React.useCallback((reason: ContentLiveResetReason) => { + for (const listener of resetListenersRef.current) listener(reason); + }, []); + + return { + clientId, + focus, + live, + locks, + members, + onCommitted, + onDraft, + onReset, + readDrafts, + readyState, + refreshLocks, + rememberSelf: setSelf, + resetLocally, + self, + }; +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/values.test.ts b/packages/vitnode/src/views/admin/views/content/live/values.test.ts new file mode 100644 index 0000000000..dbbbeef207 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/values.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import type { ContentDrafts } from "@/content/live/http"; + +import { + contentDraftDiffers, + mergeContentDraft, + overlayContentDrafts, +} from "./values"; + +const draft = (values: Record<string, unknown>) => ({ + baseVersion: 1, + updatedAt: "2026-10-08T12:00:00.000Z", + updatedBy: { id: 2, name: "Anna" }, + values, +}); + +const drafts: ContentDrafts = { + shared: draft({ featured: true }), + translations: { + de: draft({ title: "Hallo" }), + pl: draft({ title: "Cześć" }), + }, +}; + +const record = { featured: false, id: 7, labels: {} }; +const translations = [ + { locale: "en", values: { body: "Body", title: "Hello" } }, + { locale: "pl", values: { body: "Treść", title: "Witaj" } }, +]; + +describe("overlayContentDrafts", () => { + it("opens the form on the draft, shared and per language", () => { + const overlaid = overlayContentDrafts(record, translations, drafts); + + expect(overlaid.data).toMatchObject({ featured: true, id: 7 }); + expect(overlaid.translations).toEqual([ + { locale: "en", values: { body: "Body", title: "Hello" } }, + { locale: "pl", values: { body: "Treść", title: "Cześć" } }, + { locale: "de", values: { title: "Hallo" } }, + ]); + }); + + it("leaves the committed record alone", () => { + overlayContentDrafts(record, translations, drafts); + + expect(record.featured).toBe(false); + expect(translations[1]?.values.title).toBe("Witaj"); + }); +}); + +describe("contentDraftDiffers", () => { + it("is true while the draft holds what the record does not", () => { + expect(contentDraftDiffers(record, translations, drafts)).toBe(true); + }); + + it("is false once Save committed the drafted values", () => { + expect( + contentDraftDiffers( + { ...record, featured: true }, + [ + ...translations.slice(0, 1), + { locale: "pl", values: { body: "Treść", title: "Cześć" } }, + { locale: "de", values: { title: "Hallo" } }, + ], + drafts, + ), + ).toBe(false); + }); +}); + +describe("mergeContentDraft", () => { + it("folds a change into the language it belongs to", () => { + const merged = mergeContentDraft(drafts, { + by: { id: 3, name: "Ben" }, + locale: "pl", + updatedAt: "2026-10-08T12:01:00.000Z", + values: { body: "Nowa treść" }, + }); + + expect(merged.translations.pl).toEqual({ + baseVersion: 1, + updatedAt: "2026-10-08T12:01:00.000Z", + updatedBy: { id: 3, name: "Ben" }, + values: { body: "Nowa treść", title: "Cześć" }, + }); + expect(merged.shared).toBe(drafts.shared); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/live/values.ts b/packages/vitnode/src/views/admin/views/content/live/values.ts new file mode 100644 index 0000000000..54d7c2ff41 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/values.ts @@ -0,0 +1,126 @@ +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentDrafts } from "@/content/live/http"; + +import { isReferenceKind } from "@/content/admin/spec"; + +import type { ContentLiveDraftEvent } from "./use-session"; + +interface TranslationValues { + locale: string; + values: Record<string, unknown>; +} + +export const sameContentValue = (a: unknown, b: unknown): boolean => + JSON.stringify(a ?? null) === JSON.stringify(b ?? null); + +export const contentDraftMovedReferences = ( + spec: ContentFormSpec, + data: Record<string, unknown>, + drafts: ContentDrafts | null, +): { field: string; id: number }[] => + Object.entries(drafts?.shared?.values ?? {}).flatMap(([name, value]) => { + const fieldSpec = spec.fields.find(field => field.name === name); + if ( + !fieldSpec || + fieldSpec.multiple === true || + !isReferenceKind(fieldSpec.kind) || + typeof value !== "number" || + sameContentValue(value, data[name]) + ) { + return []; + } + + return [{ field: name, id: value }]; + }); + +export const overlayContentDrafts = < + TData extends Record<string, unknown>, + TTranslation extends TranslationValues, +>( + data: TData, + translations: readonly TTranslation[], + drafts: ContentDrafts | null, + labels: Record<string, string> = {}, +): { data: TData; translations: TranslationValues[] } => { + if (!drafts) return { data, translations: [...translations] }; + + const overlaid: TData = drafts.shared + ? { + ...data, + ...drafts.shared.values, + labels: { + ...(typeof data.labels === "object" ? data.labels : null), + ...labels, + }, + } + : data; + + const rows: TranslationValues[] = translations.map(row => { + const draft = drafts.translations[row.locale]; + + return draft ? { ...row, values: { ...row.values, ...draft.values } } : row; + }); + for (const [locale, draft] of Object.entries(drafts.translations)) { + if (!rows.some(row => row.locale === locale)) { + rows.push({ locale, values: draft.values }); + } + } + + return { data: overlaid, translations: rows }; +}; + +export const mergeContentDraft = ( + drafts: ContentDrafts | null, + event: ContentLiveDraftEvent, +): ContentDrafts => { + const current = drafts ?? { shared: null, translations: {} }; + const previous = + event.locale === null ? current.shared : current.translations[event.locale]; + const next = { + baseVersion: previous?.baseVersion ?? 0, + updatedAt: event.updatedAt, + updatedBy: event.by ?? previous?.updatedBy ?? null, + values: { ...previous?.values, ...event.values }, + }; + + return event.locale === null + ? { ...current, shared: next } + : { + ...current, + translations: { ...current.translations, [event.locale]: next }, + }; +}; + +export const contentDraftDiffers = ( + data: Record<string, unknown>, + translations: readonly TranslationValues[], + drafts: ContentDrafts | null, +): boolean => { + if (!drafts) return false; + + const sharedDiffers = Object.entries(drafts.shared?.values ?? {}).some( + ([name, value]) => !sameContentValue(value, data[name]), + ); + if (sharedDiffers) return true; + + return Object.entries(drafts.translations).some(([locale, draft]) => { + const row = translations.find(entry => entry.locale === locale); + + return Object.entries(draft.values).some( + ([name, value]) => !sameContentValue(value, row?.values[name]), + ); + }); +}; + +export const contentDraftValue = ( + drafts: ContentDrafts | null, + field: string, + locale: null | string, +): undefined | { value: unknown } => { + const values = + locale === null + ? drafts?.shared?.values + : drafts?.translations[locale]?.values; + + return values && field in values ? { value: values[field] } : undefined; +}; diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.tsx index 5cb6ef335a..88121ae72e 100644 --- a/packages/vitnode/src/views/admin/views/content/table/cells.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/cells.tsx @@ -19,6 +19,8 @@ import { DateFormat } from "@/components/date-format"; import { Badge } from "@/components/ui/badge"; import { UserFormat } from "@/components/user-format"; import { isContentPublished } from "@/content/publication"; +import { isRichTextDocument } from "@/content/rich-text/document"; +import { richTextToPlainText } from "@/content/rich-text/plain-text"; export interface ContentRowData extends Record<string, unknown> { files?: Record<string, ContentFileFieldValue>; @@ -236,6 +238,13 @@ const ContentCellValue = ({ ); } + case "richText": + return ( + <span className="line-clamp-2 max-w-sm whitespace-normal"> + {isRichTextDocument(value) ? richTextToPlainText(value) : ""} + </span> + ); + case "system": return spec.name === "id" ? ( <span className="tabular-nums">{asText(value)}</span> diff --git a/packages/vitnode/src/views/admin/views/content/table/columns.test.ts b/packages/vitnode/src/views/admin/views/content/table/columns.test.ts index 04c5e2aca4..d6aaa47ca0 100644 --- a/packages/vitnode/src/views/admin/views/content/table/columns.test.ts +++ b/packages/vitnode/src/views/admin/views/content/table/columns.test.ts @@ -34,7 +34,7 @@ const articles = defineContentType({ searchableFields: ["title"], }, }, - publication: { enabled: true }, + publication: true, }) as AnyContentTypeDefinition; const notes = defineContentType({ @@ -50,7 +50,7 @@ const notes = defineContentType({ const localized = defineContentType({ id: "blog.page", tableName: "blog_page", - localization: { enabled: true, defaultLocale: "en", fallback: "default" }, + localization: { defaultLocale: "en", fallback: "default" }, fields: { heading: field.text({ localized: true, required: true }) }, admin: { titleField: "heading", list: { columns: ["heading"] } }, }) as AnyContentTypeDefinition; diff --git a/packages/vitnode/src/ws/instances.test.ts b/packages/vitnode/src/ws/instances.test.ts new file mode 100644 index 0000000000..ae338dcb2f --- /dev/null +++ b/packages/vitnode/src/ws/instances.test.ts @@ -0,0 +1,160 @@ +import type { WSContext } from "hono/ws"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { CacheClient } from "@/api/lib/cache"; + +import { + getRealtimeInstanceId, + initRealtimePubSub, + onInstanceMessage, + onRemoteRoomMessage, + publishToInstances, + wsRegistry, +} from "./registry"; + +const bus = () => { + const published: string[] = []; + let deliver: ((raw: string) => void) | undefined; + + const client = { + duplicate: () => { + const subscriber = { + connect: async () => Promise.resolve(), + on: () => subscriber, + subscribe: async ( + _channel: string, + listener: (raw: string) => void, + ) => { + deliver = listener; + + return Promise.resolve(); + }, + }; + + return subscriber; + }, + publish: async (_channel: string, raw: string) => { + published.push(raw); + + return Promise.resolve(1); + }, + }; + + return { + client: client as unknown as CacheClient, + fromOtherInstance: (message: Record<string, unknown>) => { + deliver?.(JSON.stringify({ origin: "other-instance", ...message })); + }, + published: () => + published.map(raw => JSON.parse(raw) as Record<string, unknown>), + }; +}; + +const pubsub = bus(); +initRealtimePubSub(pubsub.client); +await Promise.resolve(); +await Promise.resolve(); + +const sockets: WSContext[] = []; + +const socket = () => { + const sent: unknown[] = []; + const ws = { + send: (raw: string) => { + sent.push(JSON.parse(raw)); + }, + } as unknown as WSContext; + wsRegistry.add(ws, 1); + sockets.push(ws); + + return { sent, ws }; +}; + +afterEach(() => { + sockets.splice(0).forEach(ws => wsRegistry.remove(ws)); +}); + +describe("instance messages", () => { + it("are published with this instance as the origin", () => { + publishToInstances("presence", { n: 1 }); + + expect(pubsub.published().at(-1)).toEqual({ + data: { n: 1 }, + id: "presence", + origin: getRealtimeInstanceId(), + type: "instance", + }); + }); + + it("reach instance listeners of the other instances, never a socket", () => { + const member = socket(); + wsRegistry.join(member.ws, "room", "tab-a"); + const listener = vi.fn(); + const rooms = vi.fn(); + const stop = onInstanceMessage(listener); + const stopRooms = onRemoteRoomMessage(rooms); + + pubsub.fromOtherInstance({ + data: { n: 2 }, + id: "presence", + type: "instance", + }); + stop(); + stopRooms(); + + expect(listener).toHaveBeenCalledWith({ + data: { n: 2 }, + origin: "other-instance", + topic: "presence", + }); + expect(rooms).not.toHaveBeenCalled(); + expect(member.sent).toEqual([]); + }); + + it("are ignored when they come back from this instance", () => { + const listener = vi.fn(); + const stop = onInstanceMessage(listener); + + pubsub.fromOtherInstance({ + data: {}, + id: "presence", + origin: getRealtimeInstanceId(), + type: "instance", + }); + stop(); + + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("room messages from another instance", () => { + it("are observed first, then delivered to the room's local members", () => { + const member = socket(); + const outside = socket(); + wsRegistry.join(member.ws, "content:test.note:1", "tab-a"); + const observed: unknown[] = []; + const stop = onRemoteRoomMessage((room, id, data) => { + observed.push({ data, id, room, sentBefore: member.sent.length }); + }); + + pubsub.fromOtherInstance({ + data: { type: "reset" }, + id: "live", + room: "content:test.note:1", + type: "room", + }); + stop(); + + expect(observed).toEqual([ + { + data: { type: "reset" }, + id: "live", + room: "content:test.note:1", + sentBefore: 0, + }, + ]); + expect(member.sent).toEqual([{ data: { type: "reset" }, id: "live" }]); + expect(outside.sent).toEqual([]); + }); +}); diff --git a/packages/vitnode/src/ws/registry.ts b/packages/vitnode/src/ws/registry.ts index 73e402878e..a2278c44a1 100644 --- a/packages/vitnode/src/ws/registry.ts +++ b/packages/vitnode/src/ws/registry.ts @@ -6,6 +6,15 @@ import type { VitNodeWSChannel, VitNodeWSMessage } from "./types"; const connections = new Map<WSContext, null | number>(); +const rooms = new Map<string, Map<WSContext, Set<string>>>(); + +type ConnectionCloseListener = ( + ws: WSContext, + left: { memberId: string; room: string }[], +) => void; + +const closeListeners = new Set<ConnectionCloseListener>(); + let webSocketEnabled = false; /** Called once when the app mounts the `/ws` handler. */ @@ -32,8 +41,66 @@ export const wsRegistry = { broadcast: (id: string, data: unknown): void => { connections.forEach((_userId, ws) => sendTo(ws, id, data)); }, + join: (ws: WSContext, room: string, memberId: string): boolean => { + const sockets = rooms.get(room) ?? new Map<WSContext, Set<string>>(); + rooms.set(room, sockets); + const members = sockets.get(ws) ?? new Set<string>(); + sockets.set(ws, members); + + if (members.has(memberId)) return false; + members.add(memberId); + + return true; + }, + leave: (ws: WSContext, room: string, memberId: string): boolean => { + const sockets = rooms.get(room); + const members = sockets?.get(ws); + if (!sockets || !members?.delete(memberId)) return false; + + if (members.size === 0) sockets.delete(ws); + if (sockets.size === 0) rooms.delete(room); + + return true; + }, + membersOf: (room: string): { memberId: string; ws: WSContext }[] => + [...(rooms.get(room) ?? new Map<WSContext, Set<string>>())].flatMap( + ([ws, members]) => [...members].map(memberId => ({ memberId, ws })), + ), + onConnectionClose: (listener: ConnectionCloseListener): (() => void) => { + closeListeners.add(listener); + + return () => { + closeListeners.delete(listener); + }; + }, remove: (ws: WSContext): void => { connections.delete(ws); + + const left: { memberId: string; room: string }[] = []; + rooms.forEach((sockets, room) => { + const members = sockets.get(ws); + if (!members) return; + + members.forEach(memberId => left.push({ memberId, room })); + sockets.delete(ws); + if (sockets.size === 0) rooms.delete(room); + }); + + if (left.length === 0) return; + + closeListeners.forEach(listener => { + try { + listener(ws, left); + } catch (error) { + // eslint-disable-next-line no-console + console.error("WebSocket close listener error:", error); + } + }); + }, + roomsOf: (ws: WSContext): string[] => + [...rooms].filter(([, sockets]) => sockets.has(ws)).map(([room]) => room), + toRoom: (room: string, id: string, data: unknown): void => { + rooms.get(room)?.forEach((_members, ws) => sendTo(ws, id, data)); }, /** Send `{ id, data }` only to the connections owned by `userId`. */ sendToUser: (userId: number, id: string, data: unknown): void => { @@ -63,7 +130,8 @@ interface RealtimePubSubMessage { data: unknown; id: string; origin: string; - type: "broadcast" | "sendToUser"; + room?: string; + type: "broadcast" | "instance" | "room" | "sendToUser"; userId?: number; } @@ -75,9 +143,45 @@ const deliverLocally = (message: RealtimePubSubMessage): void => { typeof message.userId === "number" ) { wsRegistry.sendToUser(message.userId, message.id, message.data); + } else if (message.type === "room" && typeof message.room === "string") { + wsRegistry.toRoom(message.room, message.id, message.data); } }; +const roomListeners = new Set< + (room: string, id: string, data: unknown) => void +>(); + +export const onRemoteRoomMessage = ( + listener: (room: string, id: string, data: unknown) => void, +): (() => void) => { + roomListeners.add(listener); + + return () => { + roomListeners.delete(listener); + }; +}; + +type InstanceMessageListener = (message: { + data: unknown; + origin: string; + topic: string; +}) => void; + +const instanceListeners = new Set<InstanceMessageListener>(); + +export const onInstanceMessage = ( + listener: InstanceMessageListener, +): (() => void) => { + instanceListeners.add(listener); + + return () => { + instanceListeners.delete(listener); + }; +}; + +export const getRealtimeInstanceId = (): string => instanceId; + const publish = (message: Omit<RealtimePubSubMessage, "origin">): void => { if (!publisher) return; @@ -115,6 +219,18 @@ export const initRealtimePubSub = (client: CacheClient | null): void => { const message = JSON.parse(raw) as RealtimePubSubMessage; // Skip our own echo - the publisher already delivered locally. if (message.origin === instanceId) return; + if (message.type === "instance") { + const { data, id: topic, origin } = message; + instanceListeners.forEach(listener => { + listener({ data, origin, topic }); + }); + + return; + } + if (message.type === "room" && typeof message.room === "string") { + const { data, id, room } = message; + roomListeners.forEach(listener => listener(room, id, data)); + } deliverLocally(message); } catch { // Ignore malformed payloads. @@ -126,6 +242,10 @@ export const initRealtimePubSub = (client: CacheClient | null): void => { }); }; +export const publishToInstances = (topic: string, data: unknown): void => { + publish({ data, id: topic, type: "instance" }); +}; + export interface VitNodeRealtime { broadcast: <Receive>( channel: VitNodeWSChannel<unknown, Receive>, @@ -140,6 +260,11 @@ export interface VitNodeRealtime { channel: VitNodeWSChannel<unknown, Receive>, data: Receive, ) => void; + toRoom: <Receive>( + room: string, + channel: VitNodeWSChannel<unknown, Receive>, + data: Receive, + ) => void; } export const realtime: VitNodeRealtime = { @@ -151,4 +276,8 @@ export const realtime: VitNodeRealtime = { wsRegistry.sendToUser(userId, channel.id, data); publish({ data, id: channel.id, type: "sendToUser", userId }); }, + toRoom: (room, channel, data) => { + wsRegistry.toRoom(room, channel.id, data); + publish({ data, id: channel.id, room, type: "room" }); + }, }; diff --git a/packages/vitnode/src/ws/rooms.test.ts b/packages/vitnode/src/ws/rooms.test.ts new file mode 100644 index 0000000000..8117fe6415 --- /dev/null +++ b/packages/vitnode/src/ws/rooms.test.ts @@ -0,0 +1,130 @@ +import type { WSContext } from "hono/ws"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { realtime, wsRegistry } from "./registry"; +import { createWebSocketChannel } from "./types"; + +const sockets: WSContext[] = []; + +const socket = (userId: null | number = 1) => { + const sent: { data: unknown; id: string }[] = []; + const ws = { + send: (raw: string) => { + sent.push(JSON.parse(raw) as { data: unknown; id: string }); + }, + } as unknown as WSContext; + + wsRegistry.add(ws, userId); + sockets.push(ws); + + return { sent, ws }; +}; + +afterEach(() => { + sockets.splice(0).forEach(ws => wsRegistry.remove(ws)); +}); + +describe("rooms", () => { + it("delivers a room message only to sockets with a member in the room", () => { + const inside = socket(); + const outside = socket(); + + wsRegistry.join(inside.ws, "content:blog.post:1", "tab-a"); + wsRegistry.toRoom("content:blog.post:1", "live", { hello: true }); + + expect(inside.sent).toEqual([{ data: { hello: true }, id: "live" }]); + expect(outside.sent).toEqual([]); + }); + + it("sends once per socket, however many of its tabs joined", () => { + const shared = socket(); + + wsRegistry.join(shared.ws, "room", "tab-a"); + wsRegistry.join(shared.ws, "room", "tab-b"); + wsRegistry.toRoom("room", "live", 1); + + expect(shared.sent).toHaveLength(1); + }); + + it("keeps the socket in the room until its last tab leaves", () => { + const shared = socket(); + + wsRegistry.join(shared.ws, "room", "tab-a"); + wsRegistry.join(shared.ws, "room", "tab-b"); + wsRegistry.leave(shared.ws, "room", "tab-a"); + + expect(wsRegistry.roomsOf(shared.ws)).toEqual(["room"]); + + wsRegistry.leave(shared.ws, "room", "tab-b"); + + expect(wsRegistry.roomsOf(shared.ws)).toEqual([]); + expect(wsRegistry.membersOf("room")).toEqual([]); + }); + + it("reports whether a join or a leave changed anything", () => { + const { ws } = socket(); + + expect(wsRegistry.join(ws, "room", "tab-a")).toBe(true); + expect(wsRegistry.join(ws, "room", "tab-a")).toBe(false); + expect(wsRegistry.leave(ws, "room", "tab-a")).toBe(true); + expect(wsRegistry.leave(ws, "room", "tab-a")).toBe(false); + }); + + it("lists every member with its socket", () => { + const first = socket(); + const second = socket(2); + + wsRegistry.join(first.ws, "room", "tab-a"); + wsRegistry.join(second.ws, "room", "tab-b"); + + expect(wsRegistry.membersOf("room")).toEqual([ + { memberId: "tab-a", ws: first.ws }, + { memberId: "tab-b", ws: second.ws }, + ]); + }); + + it("tells close listeners which members left with a closed socket", () => { + const { ws } = socket(); + const listener = vi.fn(); + const unsubscribe = wsRegistry.onConnectionClose(listener); + + wsRegistry.join(ws, "first", "tab-a"); + wsRegistry.join(ws, "second", "tab-b"); + wsRegistry.remove(ws); + unsubscribe(); + + expect(listener).toHaveBeenCalledWith(ws, [ + { memberId: "tab-a", room: "first" }, + { memberId: "tab-b", room: "second" }, + ]); + expect(wsRegistry.membersOf("first")).toEqual([]); + }); + + it("does not call close listeners for a socket that was in no room", () => { + const { ws } = socket(); + const listener = vi.fn(); + const unsubscribe = wsRegistry.onConnectionClose(listener); + + wsRegistry.remove(ws); + unsubscribe(); + + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("realtime.toRoom", () => { + it("delivers to the room's local members by channel id", () => { + const channel = createWebSocketChannel<never, { n: number }>({ + id: "live", + module: "content", + pluginId: "@vitnode/core", + }); + const member = socket(); + + wsRegistry.join(member.ws, "room", "tab-a"); + realtime.toRoom("room", channel, { n: 1 }); + + expect(member.sent).toEqual([{ data: { n: 1 }, id: channel.id }]); + }); +}); diff --git a/plans/content-live-editing.md b/plans/content-live-editing.md new file mode 100644 index 0000000000..9d7e88cb72 --- /dev/null +++ b/plans/content-live-editing.md @@ -0,0 +1,251 @@ +# Content Engine: live editing, autosave and presence + +Status: implemented on canary (phases 0-5). Owner: Content Engine. + +## Goals + +1. **Autosave.** Nobody loses work. Edits are saved continuously to a shared + working copy (the _draft_), never to the published record. `Save` and + `Publish` stay explicit commits. +2. **Presence.** Editors see who else is in the record and _where_ they are: + which field and which language. +3. **Hard field locks.** A plain field (title, slug, excerpt, relations, files, + ...) is edited by one person at a time. Everyone else sees it read-only with + "Anna is editing", and gets the value live. +4. **Co-editing in the rich text editor.** The rich text field is the exception: + many people type in it at once, with live carets (Yjs). +5. **JSON content + SSR without the editor.** Rich text is stored as ProseMirror + JSON and rendered on the server by a pure renderer. The public bundle never + ships Tiptap, ProseMirror or Yjs. + +Non-goals: offline-first editing, comments and suggestions (see "Later"). + +## Constraints found in the code + +- Every editorial `PUT` bumps `version`, writes a revision (retention 20) and + runs the commit effects (events, search indexing, revalidation). Autosave + must not go through it. +- A draft is not a separate copy: `status = "draft"` lives on the same row, so + autosaving a published record would change live content. Autosave writes to + separate tables instead. +- The WebSocket layer (`packages/vitnode/src/ws`) knows users, not rooms. The + socket is only mounted by `apps/api` (long-lived Node). Vercel/serverless + has no socket. +- The socket only knows the _user_ session. The AdminCP session cookie + (`path=/`) does reach the upgrade request, so a handler can resolve it with + `SessionAdminModel` and check `can_edit` itself. +- The editor (Tiptap v3) stores HTML today and is uncontrolled after mount. + +## Decisions + +| Topic | Decision | +| --- | --- | +| Where autosave goes | `core_content_drafts` (plain fields) and `core_content_documents` (Yjs state of rich text fields). Never the record. | +| Draft scope | One shared draft per record and language, not per user. It is the team's working copy. | +| Plain fields | Hard lock per field and language, a lease in `core_content_field_locks`. | +| Rich text | `field.richText()`, ProseMirror JSON in `jsonb`, co-edited through Yjs. | +| Transport | Existing `/api/ws` socket, one core channel, rooms on top of the registry. Redis pub/sub for multi-instance. | +| No socket (serverless) | Locks and drafts work over HTTP with polling. Rich text falls back to a single-editor lock. | +| Migration | Canary: none. `blog.post.content` changes from `textarea` to `richText` (breaking). A one-time HTML to JSON script is written before the stable release. | +| New packages | `yjs`, `y-protocols`, `@tiptap/extension-collaboration`, `@tiptap/extension-collaboration-caret`, `@tiptap/y-tiptap` (core only). | + +## Architecture + +``` +AdminCP form ──HTTP──▶ /content/{module}/{id}/locks (acquire, renew, release) + │ /content/{module}/{id}/draft (read, write locked fields) + │ PUT /{id}, /{id}/localized (commit = Save, unchanged) + │ + └──WS /api/ws──▶ channel "@vitnode/core_content_live" + join/leave/focus ─▶ presence of the record room + doc:* (Yjs sync) ─▶ rich text documents + ◀─ presence, locks, draft, doc:update, doc:awareness, reset + │ + Redis "vitnode:ws" (room fan-out across instances) +``` + +### Rooms (WebSocket layer) + +`ws/registry.ts` gains rooms: + +- `wsRegistry.join(ws, room)`, `wsRegistry.leave(ws, room)`, + `wsRegistry.roomsOf(ws)`, `wsRegistry.toRoom(room, id, data, { except })`. +- `realtime.toRoom(room, channel, data)` delivers locally and publishes a + `room` message on `vitnode:ws`, so other instances deliver to their members. +- `onConnectionClose(listener)` lets features clean up (presence, locks) when a + socket goes away. + +Room names: `content:{contentTypeId}:{itemId}` for a record and +`content-doc:{contentTypeId}:{itemId}:{field}:{locale|_}` for one rich text +document. + +### Protocol (`content/live/protocol.ts`) + +One channel, `createWebSocketChannel({ pluginId: "@vitnode/core", module: "content", id: "live" })`. +JSON messages, binary Yjs payloads as base64. + +Client to server: + +| type | payload | effect | +| --- | --- | --- | +| `join` | `room`, `locale` | Authorize (AdminCP session + `can_edit`), join the room, reply `joined` with members. | +| `leave` | `room` | Leave the room and every document room of it. | +| `focus` | `room`, `field`, `locale` | Update presence ("Anna is in Title (PL)"). | +| `doc:open` | `doc`, `stateVector` | Join the document room. Server replies with `doc:update` (what the client misses) and `doc:state-vector`. | +| `doc:update` | `doc`, `update` | Apply on the server doc, relay to the document room, schedule persistence. | +| `doc:awareness` | `doc`, `update` | Relay carets and selections. | +| `doc:close` | `doc` | Leave the document room. | + +Server to client: `joined`, `presence`, `locks`, `draft`, `doc:update`, +`doc:state-vector`, `doc:awareness`, `doc:seed`, `reset`, `error`. + +### Presence + +Per record room, the server keeps `{ connectionId, userId, name, avatar, +locale, field }` for each member and broadcasts the list on every change. A +member leaves on `leave`, on socket close, or after a missed heartbeat. + +### Field locks (`core_content_field_locks`) + +- Key: `(content_type_id, item_id, field, language)`. `language` is `""` for + shared fields. Value: `user_id`, `acquired_at`, `expires_at`. +- `POST /{id}/locks` with `{ field, locale, action: "acquire" | "renew" | "release" }`. + Acquire is one atomic upsert that only succeeds when the lock is free, + expired, or already held by the caller. A conflict is a `409` naming the + holder. +- Lease 60 s, renewed every 20 s while the field has focus. +- `GET /{id}/locks` lists live locks (also the polling fallback). +- Every change is broadcast to the record room as `locks`. +- A socket close releases that user's locks in the room when they have no + other connection in it. +- `can_edit` is required for everything. Rich text fields are never locked + while the socket is up. + +### Drafts (`core_content_drafts`) + +- Key: `(content_type_id, item_id, language)`. `values` is `jsonb`. + `base_version`, `updated_at`, `updated_by`. +- `GET /{id}/draft` returns the shared and per-language drafts. +- `PUT /{id}/draft` with `{ locale, values }` merges only fields the caller + holds a lock for (otherwise `409`). The result is broadcast as `draft`. +- The form opens with record values overlaid by draft values, and shows + "Draft saved at 14:32" and "Unsaved changes" (draft differs from record). +- `Save` commits the overlaid values through the existing routes. The draft + stays; it now equals the record. Restoring a revision deletes the drafts and + documents of that record and broadcasts `reset`. +- The editorial cleanup cron deletes drafts and documents older than 30 days + whose `base_version` is behind the record. + +### Rich text (`field.richText()`) + +- New field kind `richText`, `localized` allowed. Column `jsonb`. Value is a + ProseMirror document `{ type: "doc", content: [...] }`. +- Validation: recursive Zod schema with a node allowlist, max depth and max + serialized size. +- `RichTextContent` (`@vitnode/core/components/rich-text`) renders the JSON to + React on the server and the client. A registry maps node and mark types to + renderers; unknown nodes render their children. Links are sanitized. +- `richTextToPlainText()` feeds search, SEO descriptions, excerpts and + revision diffs. `richTextToHtml()` is a string renderer for AI prompts and + emails. +- The admin editor works in JSON mode: `onChange(editor.getJSON())`. + +### Co-editing (Yjs) + +- One `Y.Doc` per `(record, field, language)`. The Tiptap `Collaboration` + extension binds the editor to it, `CollaborationCaret` shows carets with the + member's name and color. Undo and redo come from Yjs (per user). +- Client provider (`content/live/provider.ts`) speaks the protocol above over + the shared socket and owns an `Awareness`. +- Server (`content/server/live/documents.ts`) keeps loaded documents in memory + per instance, applies and relays updates, mirrors them to other instances + over Redis, and persists the merged state to `core_content_documents` + (debounced 2 s, and when the last member leaves). +- Seeding: when no state is stored, the server asks the first client to seed + the document from the record JSON (`doc:seed`). Only one client is chosen. +- `Save` sends the editor JSON in the normal payload. The server never has to + turn Yjs into JSON. +- Without the socket, the editor is a normal JSON editor guarded by the field + lock. + +### AdminCP and the blog + +- `ContentForm` gets a live session (`useContentLiveSession`) when the record + exists: join, presence, locks, draft overlay, autosave indicator. +- Locked fields render read-only with the holder's avatar. Focus acquires the + lock; blur releases it. +- The page header shows the avatars of everyone in the record. The language + switcher shows who is in each language. +- The blog switches `content` to `field.richText({ localized: true, required: true })`, + renders it with `RichTextContent`, and its AI tools (excerpt, translate) + read text with `richTextToPlainText` / `richTextToHtml` and write back + through editor commands, so changes flow through Yjs to everyone. + +## Phases + +| Phase | Scope | Notes | +| --- | --- | --- | +| 0 | Rooms in the WS registry, protocol contract, shared constants | Foundation for everything below. | +| 1 | `field.richText`, renderer, plain text and HTML helpers, editor JSON mode, blog switch | Independent. | +| 2 | Live server: WS handler, auth, presence, Yjs document sync and persistence | Builds on 0. | +| 3 | Locks and drafts: tables, routes, broadcasts, cleanup | Builds on 0. | +| 4 | AdminCP client: live session, presence UI, lock UI, autosave, collaborative editor, blog editor | Builds on 1-3. | +| 5 | Docs and migration files | Last. | + +Phases 1, 2 and 3 run in parallel. + +## Testing + +Vitest, following `AGENTS.md`: no `vi.mock` where a seam exists. + +- Rooms: join, leave, `toRoom` with `except`, cleanup on close, pub/sub + delivery to room members only. +- Live handler: rejects without an AdminCP session or without `can_edit`; + presence lists; focus updates. +- Documents: two clients converge; updates relay to the document room only; + persistence and reload; seeding picks one client. +- Locks: acquire, conflict `409`, renew, expiry, release, release on close. +- Drafts: merge only locked fields, reject unlocked, read overlay, reset on + restore. +- Rich text: schema accepts valid docs and rejects bad ones; renderer DOM + output for each node and mark; link sanitizing; plain text extraction. +- UI: locked field is read-only and names the holder; presence avatars. + +## Docs + +- `dev/content-engine/live-editing.mdx`: autosave, presence, locks, co-editing, + deployment notes (socket and Redis). +- `dev/content-engine/fields.mdx`: `field.richText`. +- `dev/content-engine/rich-text.mdx`: rendering with `RichTextContent`, + custom node renderers, helpers. +- `dev/websocket.mdx`: rooms. +- `guides/blog.mdx`: what editors see. + +## Later + +Anchored comments, suggestion mode, AI as a visible collaborator, version +history from Yjs snapshots, co-editing the visual page editor. + +## Known limitations + +Found while building and testing; none blocks the feature. + +- **Rate limit.** In production the API allows 80 requests a minute per IP. + One idle editor makes about 18. Autosave and lock renewals add more while + typing, so a large team behind one office IP can reach the limit. + Candidates: send autosave over the socket when it is live, or exempt + AdminCP sessions. +- **Seeding race.** If someone types into a brand-new shared document before + the chosen tab seeds it from the record, the seed is skipped. +- **Rich text and "Unsaved changes".** The status comes from the draft, which + never holds co-edited rich text. +- **AI writes to plain fields** (excerpt, alt text) are not autosaved or + broadcast, because they are not made while holding the field's lock. +- **Cross-instance edges.** An instance may persist a document just before it + hears about a reset; a user who leaves and rejoins at the same moment can + lose their locks. Leases expire within 60 s either way. +- **Translation delete** does not reset the live state of that language. +- **Data migration.** Existing blog HTML keeps its words as one paragraph. A + full HTML to JSON conversion script is still to be written before the + stable release. + diff --git a/plugins/blog/package.json b/plugins/blog/package.json index b7448e11b4..16cc7083f8 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -54,6 +54,7 @@ }, "devDependencies": { "@react-email/ui": "^6.11.1", + "@testing-library/react": "^16.3.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", diff --git a/plugins/blog/src/api/ai/actions.ts b/plugins/blog/src/api/ai/actions.ts index 78fa46a907..d2fb1d4c48 100644 --- a/plugins/blog/src/api/ai/actions.ts +++ b/plugins/blog/src/api/ai/actions.ts @@ -2,7 +2,11 @@ import { defineAiAction } from "@vitnode/core/api/lib/ai/action"; import { assertSameHtmlStructure } from "@vitnode/core/api/lib/ai/html-structure"; import { aiActionRef } from "@vitnode/core/api/lib/ai/registry"; import { checkStaffPermission } from "@vitnode/core/api/lib/check-staff-permission"; -import { stripHtml } from "@vitnode/core/lib/strip-html"; +import { + createRichTextDocumentSchema, + richTextToHtml, + richTextToPlainText, +} from "@vitnode/core/content/rich-text"; import { z } from "zod"; import { @@ -15,15 +19,30 @@ import { CONFIG_PLUGIN } from "@/const"; const zodLocale = z.string().min(2).max(16); -export const zodTranslateAiSchema = z.object({ - format: z.enum(["html", "text"]), - from: zodLocale, - text: z.string().trim().min(1).max(100_000), - to: zodLocale, -}); +export const zodTranslateAiSchema = z.discriminatedUnion("format", [ + z.object({ + format: z.literal("text"), + from: zodLocale, + text: z.string().trim().min(1).max(100_000), + to: zodLocale, + }), + z.object({ + document: createRichTextDocumentSchema({ required: true }), + format: z.literal("richText"), + from: zodLocale, + to: zodLocale, + }), +]); + +type TranslateAiInput = z.infer<typeof zodTranslateAiSchema>; + +const translateSource = (input: TranslateAiInput) => + input.format === "richText" + ? { format: "html" as const, text: richTextToHtml(input.document) } + : { format: "text" as const, text: input.text }; export const zodExcerptAiSchema = z.object({ - content: z.string().trim().min(1).max(200_000), + content: createRichTextDocumentSchema({ required: true }), locale: zodLocale, title: z.string().trim().min(1).max(255), }); @@ -43,7 +62,10 @@ const canEditPosts = async ({ const translateFieldAiAction = defineAiAction({ authorize: canEditPosts, buildPrompt: (input, { instructions }) => - buildTranslatePrompt(input, instructions), + buildTranslatePrompt( + { ...translateSource(input), from: input.from, to: input.to }, + instructions, + ), defaults: { maxInputCharacters: 100_000, maxOutputTokens: 32_000, @@ -54,13 +76,13 @@ const translateFieldAiAction = defineAiAction({ id: "field.translate", title: "ai_actions.@vitnode/blog.field_translate.title", inputSchema: zodTranslateAiSchema, - measureInput: input => input.text.length, + measureInput: input => translateSource(input).text.length, output: "text", outputSchema: z.string().min(1), parseText: (text, input) => { if (input.format === "text") return unquote(text); const translated = text.trim(); - assertSameHtmlStructure(input.text, translated); + assertSameHtmlStructure(translateSource(input).text, translated); return translated; }, @@ -72,7 +94,10 @@ const translateFieldAiAction = defineAiAction({ const excerptAiAction = defineAiAction({ authorize: canEditPosts, buildPrompt: (input, { instructions }) => - buildExcerptPrompt(input, instructions), + buildExcerptPrompt( + { ...input, content: richTextToPlainText(input.content) }, + instructions, + ), defaults: { maxInputCharacters: 12_500, maxOutputTokens: 300, @@ -84,7 +109,8 @@ const excerptAiAction = defineAiAction({ title: "ai_actions.@vitnode/blog.excerpt_generate.title", inputSchema: zodExcerptAiSchema, measureInput: input => - input.title.length + excerptSource(input.content).length, + input.title.length + + excerptSource(richTextToPlainText(input.content)).length, output: "text", outputSchema: z.string().min(1).max(300), parseText: text => unquote(text), @@ -105,6 +131,12 @@ const zodArticleReview = z.object({ .array( z.object({ area: z.enum(ARTICLE_REVIEW_AREAS), + fix: z + .object({ + quote: z.string().min(1).max(1_000), + replacement: z.string().max(1_500), + }) + .nullable(), message: z.string().min(1).max(400), priority: z.enum(["high", "low"]), }), @@ -116,19 +148,20 @@ const zodArticleReview = z.object({ const articleReviewAiAction = defineAiAction({ authorize: canEditPosts, buildPrompt: (input, { instructions }) => ({ - prompt: `Title: ${input.title}\n\nExcerpt: ${input.excerpt?.trim() ? input.excerpt : "(none)"}\n\nArticle:\n${stripHtml(input.content).slice(0, 30_000)}`, + prompt: `Title: ${input.title}\n\nExcerpt: ${input.excerpt?.trim() ? input.excerpt : "(none)"}\n\nArticle:\n${richTextToPlainText(input.content).slice(0, 30_000)}`, system: [ "You review a blog article draft before it is published and suggest improvements an editor can act on.", "Focus on clarity, completeness (missing context, undefined terms, an unclear conclusion), structure and tone.", "Do not judge whether facts are true: you cannot verify them. Never claim something is accurate or inaccurate.", "Give at most 8 specific suggestions, most useful first. Mark a suggestion high priority only when it clearly hurts readers.", + "When a suggestion is about specific wording, add a fix: quote is a passage copied character for character from the article, at most one paragraph long, and replacement is the rewritten passage as plain text on one line, or an empty string to delete it. Otherwise set fix to null.", `Write the summary and suggestions in the article's language (${input.locale}).`, ...(instructions ? [instructions] : []), ].join("\n"), }), defaults: { maxInputCharacters: 31_000, - maxOutputTokens: 1_500, + maxOutputTokens: 4_000, timeoutMs: 90_000, }, description: "ai_actions.@vitnode/blog.article_review.description", @@ -136,7 +169,7 @@ const articleReviewAiAction = defineAiAction({ id: "article.review", title: "ai_actions.@vitnode/blog.article_review.title", inputSchema: z.object({ - content: z.string().trim().min(1).max(200_000), + content: createRichTextDocumentSchema({ required: true }), excerpt: z.string().max(1_000).optional(), locale: zodLocale, title: z.string().trim().min(1).max(255), @@ -144,11 +177,11 @@ const articleReviewAiAction = defineAiAction({ measureInput: input => input.title.length + (input.excerpt?.length ?? 0) + - Math.min(stripHtml(input.content).length, 30_000), + Math.min(richTextToPlainText(input.content).length, 30_000), output: "object", outputSchema: zodArticleReview, permission: { defaultGranted: true, key: "review" }, - promptVersion: 1, + promptVersion: 2, requiredCapabilities: ["text", "structured-output"], }); diff --git a/plugins/blog/src/api/lib/ai-writing.ts b/plugins/blog/src/api/lib/ai-writing.ts index 04715e281d..3189fdc3b3 100644 --- a/plugins/blog/src/api/lib/ai-writing.ts +++ b/plugins/blog/src/api/lib/ai-writing.ts @@ -1,7 +1,6 @@ import type { AiPrompt } from "@vitnode/core/api/lib/ai/action"; import { languageName } from "@vitnode/core/api/lib/ai/language-name"; -import { stripHtml } from "@vitnode/core/lib/strip-html"; export type AiTextFormat = "html" | "text"; @@ -41,8 +40,8 @@ export const buildTranslatePrompt = ( prompt: text, }); -export const excerptSource = (content: string) => - stripHtml(content).slice(0, EXCERPT_SOURCE_CHARACTERS); +export const excerptSource = (plainText: string) => + plainText.slice(0, EXCERPT_SOURCE_CHARACTERS); export const buildExcerptPrompt = ( { diff --git a/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts b/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts index 4f579f4e69..c626e58383 100644 --- a/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts +++ b/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts @@ -64,6 +64,11 @@ const grant = async (cache: CacheModel, canEdit: boolean) => { ); }; +const bodyDocument = { + content: [{ content: [{ text: "Body", type: "text" }], type: "paragraph" }], + type: "doc", +}; + const modelAnswering = (text: string) => new MockLanguageModelV4({ doGenerate: { @@ -157,20 +162,49 @@ describe("blog AI admin routes", () => { expect(promptOf(model.doGenerateCalls[0])).toContain("Hello, world"); }); - it("keeps HTML intact when translating content", async () => { + it("translates a rich text document as HTML, keeping its markup", async () => { const { app, model } = await createApp({ - model: modelAnswering("<p>Cześć</p>\n"), + model: modelAnswering("<p><strong>Cześć</strong></p>\n"), }); const response = await post(app, "/translate", { - format: "html", + document: { + content: [ + { + content: [ + { marks: [{ type: "bold" }], text: "Hello", type: "text" }, + ], + type: "paragraph", + }, + ], + type: "doc", + }, + format: "richText", from: "en", - text: "<p>Hello</p>", to: "pl", }); - expect(await response.json()).toEqual({ text: "<p>Cześć</p>" }); + expect(await response.json()).toEqual({ + text: "<p><strong>Cześć</strong></p>", + }); expect(promptOf(model.doGenerateCalls[0])).toContain("Keep every tag"); + expect(promptOf(model.doGenerateCalls[0])).toContain( + "<p><strong>Hello</strong></p>", + ); + }); + + it("refuses an empty document before asking the model", async () => { + const { app, model } = await createApp(); + + const response = await post(app, "/translate", { + document: { content: [{ type: "paragraph" }], type: "doc" }, + format: "richText", + from: "en", + to: "pl", + }); + + expect(response.status).toBe(400); + expect(model.doGenerateCalls).toHaveLength(0); }); it("writes an excerpt from the article text", async () => { @@ -181,7 +215,20 @@ describe("blog AI admin routes", () => { }); const response = await post(app, "/excerpt", { - content: "<h2>Why</h2><p>We rebuilt the AdminCP.</p>", + content: { + content: [ + { + attrs: { level: 2 }, + content: [{ text: "Why", type: "text" }], + type: "heading", + }, + { + content: [{ text: "We rebuilt the AdminCP.", type: "text" }], + type: "paragraph", + }, + ], + type: "doc", + }, locale: "en", title: "VitNode 2.0", }); @@ -193,7 +240,7 @@ describe("blog AI admin routes", () => { expect(promptOf(model.doGenerateCalls[0])).toContain( "We rebuilt the AdminCP.", ); - expect(promptOf(model.doGenerateCalls[0])).not.toContain("<p>"); + expect(promptOf(model.doGenerateCalls[0])).not.toContain("paragraph"); }); it("refuses staff who cannot edit articles", async () => { @@ -214,7 +261,12 @@ describe("blog AI admin routes", () => { const { app, model } = await createApp({ configured: false }); const response = await post(app, "/excerpt", { - content: "<p>Body</p>", + content: { + content: [ + { content: [{ text: "Body", type: "text" }], type: "paragraph" }, + ], + type: "doc", + }, locale: "en", title: "Title", }); @@ -250,9 +302,26 @@ describe("blog AI admin routes", () => { }); const response = await post(app, "/translate", { - format: "html", + document: { + content: [ + { + content: [ + { text: "Hello ", type: "text" }, + { + marks: [ + { attrs: { href: "https://vitnode.com" }, type: "link" }, + ], + text: "link", + type: "text", + }, + ], + type: "paragraph", + }, + ], + type: "doc", + }, + format: "richText", from: "en", - text: '<p>Hello <a href="https://vitnode.com">link</a></p>', to: "pl", }); @@ -268,7 +337,7 @@ describe("blog AI admin routes", () => { }); const response = await post(app, "/excerpt", { - content: "<p>Body</p>", + content: bodyDocument, locale: "en", title: "Title", }); @@ -283,7 +352,21 @@ describe("blog AI admin routes", () => { it("returns a structured, validated review and never claims to check facts", async () => { const review = { suggestions: [ - { area: "clarity", message: "Define RLS first.", priority: "high" }, + { + area: "clarity", + fix: { + quote: "RLS", + replacement: "row level security (RLS)", + }, + message: "Define RLS first.", + priority: "high", + }, + { + area: "structure", + fix: null, + message: "Add a conclusion.", + priority: "low", + }, ], summary: "Clear overall.", }; @@ -322,7 +405,7 @@ describe("blog AI admin routes", () => { app.post("/review", async c => { const result = await c.get("ai").run({ action: ARTICLE_REVIEW_AI_ACTION, - input: { content: "<p>Body</p>", locale: "en", title: "Title" }, + input: { content: bodyDocument, locale: "en", title: "Title" }, }); return c.json(result.output); @@ -334,6 +417,9 @@ describe("blog AI admin routes", () => { expect(await response.json()).toEqual(review); expect(promptOf(model.doGenerateCalls[0])).toContain("cannot verify them"); + expect(promptOf(model.doGenerateCalls[0])).toContain( + "copied character for character", + ); }); it("refuses the review on a model without structured output", async () => { @@ -341,7 +427,7 @@ describe("blog AI admin routes", () => { app.post("/review", async c => { await c.get("ai").run({ action: ARTICLE_REVIEW_AI_ACTION, - input: { content: "<p>Body</p>", locale: "en", title: "Title" }, + input: { content: bodyDocument, locale: "en", title: "Title" }, }); return c.json({}); diff --git a/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts b/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts index 2c0a458aaa..25966908c7 100644 --- a/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts +++ b/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts @@ -26,7 +26,8 @@ export const translateAiAdminRoute = buildRoute({ content: { "application/json": { schema: z.object({ text: z.string() }) }, }, - description: "The translated text", + description: + "The translated text, or HTML when a rich text document was sent", }, 400: { description: "No AI model is configured or the input is invalid" }, 403: { description: "No access to posts or to this AI feature" }, diff --git a/plugins/blog/src/content/category.ts b/plugins/blog/src/content/category.ts index bf3190643d..cf6eee15a7 100644 --- a/plugins/blog/src/content/category.ts +++ b/plugins/blog/src/content/category.ts @@ -5,7 +5,6 @@ export const blogCategoryContentType = defineContentType({ tableName: "blog_categories", localization: { - enabled: true, // The language every category is first written in. `en` is the locale // VitNode installs seed, and the boot guard says so loudly if an install // does not have it rather than failing on the first write. diff --git a/plugins/blog/src/content/post.ts b/plugins/blog/src/content/post.ts index 4b3d90a6cc..fe3baf55b3 100644 --- a/plugins/blog/src/content/post.ts +++ b/plugins/blog/src/content/post.ts @@ -7,20 +7,19 @@ export const blogPostContentType = defineContentType({ tableName: "blog_posts", localization: { - enabled: true, defaultLocale: "en", // A locale with no translation of its own is served the default language's. fallback: "default", }, - publication: { enabled: true }, + publication: true, editorial: { - enabled: true, revisions: { retention: 20 }, - preview: { enabled: true, expiresInMinutes: 30 }, - scheduling: { enabled: true }, + preview: { expiresInMinutes: 30 }, + scheduling: true, }, + liveEditing: true, fields: { categoryId: field.relation({ @@ -50,7 +49,7 @@ export const blogPostContentType = defineContentType({ maxLength: 255, source: "title", }), - content: field.textarea({ localized: true, required: true }), + content: field.richText({ localized: true, required: true }), excerpt: field.textarea({ ai: { action: "@vitnode/blog:excerpt.generate", @@ -76,7 +75,6 @@ export const blogPostContentType = defineContentType({ }, publicApi: { - enabled: true, path: "blog", fields: [ // Delivery resolves localized alternates by identifier, so a localized @@ -107,7 +105,6 @@ export const blogPostContentType = defineContentType({ }, search: { - enabled: true, titleField: "title", contentFields: ["title", "content"], pathTemplate: "/blog/{slug}", @@ -115,14 +112,13 @@ export const blogPostContentType = defineContentType({ }, delivery: { - enabled: true, - redirects: { enabled: true }, + redirects: true, seo: { titleField: "title", descriptionField: "excerpt", fallbackDescriptionField: "content", }, - sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, + sitemap: { changeFrequency: "weekly", priority: 0.7 }, hreflang: { xDefault: "defaultLocale" }, }, diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index a6366a20f8..d70bf11375 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -99,7 +99,6 @@ "not_configured": "No AI model is configured for this site." }, "publish": { - "open": "Ready to publish: {done} of {total} checks done", "title": "Ready to publish", "checks": { "title": "Title fits search results", @@ -116,23 +115,12 @@ "outdated_detail": "{languages} changed less recently than {source}.", "open": "Open {language}", "write": "Write with AI" - }, - "previews": { - "title": "Previews", - "language": "Preview language", - "search": "Search result", - "social": "Social card", - "fallback": "{language} readers see the {source} text for missing fields.", - "no_cover": "Add a cover image to see the social card." - }, - "details": "Details", - "cover": "Cover image" + } }, "review": { - "title": "AI review (optional)", + "title": "AI review", "run": "Review with AI", "again": "Review again", - "disclaimer": "Suggestions about clarity and completeness. It does not check facts and never blocks publishing.", "nothing": "No suggestions - nice work.", "high": "Worth fixing", "areas": { @@ -140,7 +128,41 @@ "completeness": "Completeness", "structure": "Structure", "tone": "Tone" + }, + "open": "AI review", + "intro": "AI reads the article, says what to change and drafts fixes for the wording. It does not check facts, and nothing changes until you press Apply.", + "verdict": "{high} worth fixing, {low} nice to have", + "fix": { + "show": "Show fix", + "apply": "Apply", + "dismiss": "Dismiss", + "undo": "Undo", + "applied": "Applied", + "dismissed": "Dismissed", + "before": "Current text", + "after": "Suggested text", + "removes": "The passage is removed.", + "stale": "This passage changed after the review, so the fix can't be applied. Run the review again." } + }, + "seo": { + "open": "SEO", + "open_issues": "SEO: {count, plural, one {# thing to fix} other {# things to fix}}", + "title": "SEO", + "desc": "How this article looks in search results and when it is shared.", + "left": "Left: {items}", + "done": "Done", + "needs_work": "Needs work", + "rows": { + "title": "Title", + "excerpt": "Excerpt", + "url": "URL", + "cover": "Social image", + "alt": "Image alt text" + }, + "url_missing": "No URL in this language yet.", + "cover_cropped": "The cover, cropped to fit social cards.", + "alt_edit": "Edit under the cover" } } }, diff --git a/plugins/blog/src/pages/post-page.tsx b/plugins/blog/src/pages/post-page.tsx index f99f72991d..db1a529e6f 100644 --- a/plugins/blog/src/pages/post-page.tsx +++ b/plugins/blog/src/pages/post-page.tsx @@ -1,6 +1,9 @@ import { DateFormat } from "@vitnode/core/components/date-format"; -import { EditorContent } from "@vitnode/core/components/ui/editor-content"; -import { zodContentFileDescriptor } from "@vitnode/core/content"; +import { RichTextContent } from "@vitnode/core/components/rich-text"; +import { + zodContentFileDescriptor, + zodRichTextDocument, +} from "@vitnode/core/content"; import { resolveImageAlt } from "@vitnode/core/lib/files/resolve-alt"; import { definePluginRoute, @@ -18,7 +21,7 @@ import { z } from "zod"; import { CONFIG_PLUGIN } from "@/const"; const zodBlogPost = z.object({ - content: z.string(), + content: zodRichTextDocument, coverImage: zodContentFileDescriptor.nullable(), coverImageAlt: z.string().nullable(), publishedAt: z.string().nullable(), @@ -122,9 +125,10 @@ const BlogPostPage = ({ /> ) : null} - <div className="text-foreground leading-relaxed text-pretty"> - <EditorContent content={item.content} /> - </div> + <RichTextContent + className="text-foreground leading-relaxed text-pretty" + content={item.content} + /> </article> ); }; diff --git a/plugins/blog/src/views/admin/article/editor-field.tsx b/plugins/blog/src/views/admin/article/editor-field.tsx index 89884e44da..ca26d4138b 100644 --- a/plugins/blog/src/views/admin/article/editor-field.tsx +++ b/plugins/blog/src/views/admin/article/editor-field.tsx @@ -19,6 +19,7 @@ export const BlogArticleEditorField = (props: ItemAutoFormComponentProps) => { <AutoFormEditor className="[&_.ProseMirror]:min-h-[50dvh] [&_.ProseMirror]:leading-relaxed [&>:first-child]:top-14" disableScroll + format="json" label={t("content.label")} {...props} /> diff --git a/plugins/blog/src/views/admin/article/editor/ai-review.test.tsx b/plugins/blog/src/views/admin/article/editor/ai-review.test.tsx new file mode 100644 index 0000000000..a04eef3240 --- /dev/null +++ b/plugins/blog/src/views/admin/article/editor/ai-review.test.tsx @@ -0,0 +1,134 @@ +import type { RichTextDocument } from "@vitnode/core/content/rich-text"; + +import { fireEvent, render, screen } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import en from "@/locales/en.json"; + +import { ArticleAiReview } from "./ai-review"; + +const article = (sentence: string): RichTextDocument => ({ + content: [ + { + content: [{ text: `${sentence} The rest stays.`, type: "text" }], + type: "paragraph", + }, + ], + type: "doc", +}); + +const review = { + suggestions: [ + { + area: "clarity", + fix: { quote: "Ignore this text.", replacement: "Read this text." }, + message: "Open with what the article is about.", + priority: "high", + }, + { + area: "structure", + fix: null, + message: "Add a conclusion.", + priority: "low", + }, + ], + summary: "Mostly fine.", +}; + +const json = (body: unknown) => + new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); + +const stubAiApi = () => { + vi.stubEnv("VITNODE_API_URL", "http://localhost:8000"); + vi.stubGlobal( + "fetch", + vi.fn(async () => json({ output: review, runId: 1 })), + ); +}; + +const renderReview = (content: RichTextDocument) => { + const onContentChange = vi.fn(); + render( + <IntlProvider + locale="en" + messages={en} + onError={() => { + return; + }} + > + <ArticleAiReview + content={content} + excerpt="" + locale="en" + onContentChange={onContentChange} + title="Title" + /> + </IntlProvider>, + ); + + return { onContentChange }; +}; + +const runReview = async () => { + fireEvent.click( + await screen.findByRole("button", { name: "Review with AI" }), + ); + await screen.findByText("1 worth fixing, 1 nice to have"); +}; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("ArticleAiReview", () => { + it("applies a suggested fix to the article", async () => { + stubAiApi(); + const { onContentChange } = renderReview(article("Ignore this text.")); + await runReview(); + + fireEvent.click(screen.getByRole("button", { name: "Show fix" })); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(onContentChange).toHaveBeenCalledWith(article("Read this text.")); + expect( + screen.getByText("Applied: Open with what the article is about."), + ).toBeTruthy(); + }); + + it("offers no fix for a suggestion without one", async () => { + stubAiApi(); + renderReview(article("Ignore this text.")); + await runReview(); + + expect(screen.getAllByRole("button", { name: "Show fix" })).toHaveLength(1); + }); + + it("refuses a fix whose passage changed after the review", async () => { + stubAiApi(); + const { onContentChange } = renderReview(article("Something new.")); + await runReview(); + + fireEvent.click(screen.getByRole("button", { name: "Show fix" })); + + expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); + expect(screen.getByText(/changed after the review/)).toBeTruthy(); + expect(onContentChange).not.toHaveBeenCalled(); + }); + + it("brings a dismissed suggestion back on undo", async () => { + stubAiApi(); + renderReview(article("Ignore this text.")); + await runReview(); + + fireEvent.click(screen.getAllByRole("button", { name: "Dismiss" })[1]); + expect(screen.getByText("Dismissed: Add a conclusion.")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Undo" })); + expect(screen.getByText("Add a conclusion.")).toBeTruthy(); + }); +}); diff --git a/plugins/blog/src/views/admin/article/editor/ai-review.tsx b/plugins/blog/src/views/admin/article/editor/ai-review.tsx index e91304579d..d7bb468798 100644 --- a/plugins/blog/src/views/admin/article/editor/ai-review.tsx +++ b/plugins/blog/src/views/admin/article/editor/ai-review.tsx @@ -1,21 +1,28 @@ +import type { RichTextDocument } from "@vitnode/core/content/rich-text"; + import { useAvailableAiActions } from "@vitnode/core/components/ai/use-available-ai-actions"; import { Badge } from "@vitnode/core/components/ui/badge"; import { Button } from "@vitnode/core/components/ui/button"; import { Spinner } from "@vitnode/core/components/ui/spinner"; +import { isRichTextEmpty } from "@vitnode/core/content/rich-text"; import { aiErrorCodeOf, requestAiAssist, } from "@vitnode/core/lib/ai/assist-client"; -import { SparklesIcon } from "lucide-react"; +import { cn } from "cn"; +import { CheckIcon, SparklesIcon, WandSparklesIcon } from "lucide-react"; import React from "react"; import { useTranslations } from "use-intl"; import { CONFIG_PLUGIN } from "@/const"; +import { applyPassageFix, type PassageFix } from "./passage-fix"; + const ARTICLE_REVIEW_ACTION = `${CONFIG_PLUGIN.pluginId}:article.review`; interface ArticleReviewSuggestion { area: "clarity" | "completeness" | "structure" | "tone"; + fix?: null | PassageFix; message: string; priority: "high" | "low"; } @@ -25,66 +32,171 @@ interface ArticleReview { summary: string; } +type SuggestionState = "applied" | "dismissed" | "idle" | "open"; + +export const useArticleReviewAvailable = () => { + const { data: available = [] } = useAvailableAiActions("admin"); + + return available.includes(ARTICLE_REVIEW_ACTION); +}; + const isReview = (value: unknown): value is ArticleReview => typeof value === "object" && value !== null && typeof (value as { summary?: unknown }).summary === "string" && Array.isArray((value as { suggestions?: unknown }).suggestions); -const ArticleReviewResult = ({ review }: { review: ArticleReview }) => { - const t = useTranslations("@vitnode/blog.admin.article.editor.review"); +const FixPreview = ({ fix }: { fix: PassageFix }) => { + const t = useTranslations("@vitnode/blog.admin.article.editor.review.fix"); return ( - <div className="flex flex-col gap-2"> - <p className="text-foreground text-sm leading-relaxed text-pretty"> - {review.summary} + <div className="flex flex-col gap-1.5 text-sm leading-relaxed"> + <p className="bg-destructive/10 rounded-md px-2.5 py-1.5 text-pretty line-through decoration-1"> + <span className="sr-only">{t("before")}: </span> + {fix.quote} </p> - {review.suggestions.length === 0 ? ( - <p className="text-muted-foreground text-sm">{t("nothing")}</p> + {fix.replacement ? ( + <p className="bg-success/10 rounded-md px-2.5 py-1.5 text-pretty"> + <span className="sr-only">{t("after")}: </span> + {fix.replacement} + </p> ) : ( - <ul className="flex flex-col gap-2"> - {review.suggestions.map(suggestion => ( - <li - className="flex flex-col gap-1 rounded-md border p-2" - key={`${suggestion.area}:${suggestion.message}`} - > - <div className="flex items-center gap-2"> - <Badge variant="outline">{t(`areas.${suggestion.area}`)}</Badge> - {suggestion.priority === "high" ? ( - <Badge variant="secondary">{t("high")}</Badge> - ) : null} - </div> - <p className="text-sm leading-relaxed text-pretty"> - {suggestion.message} - </p> - </li> - ))} - </ul> + <p className="text-muted-foreground px-2.5 text-xs">{t("removes")}</p> )} </div> ); }; +const SuggestionCard = ({ + content, + onApply, + suggestion, +}: { + content: null | RichTextDocument; + onApply: (document: RichTextDocument) => void; + suggestion: ArticleReviewSuggestion; +}) => { + const t = useTranslations("@vitnode/blog.admin.article.editor.review"); + const [state, setState] = React.useState<SuggestionState>("idle"); + const { fix } = suggestion; + const fixed = fix ? applyPassageFix(content, fix) : null; + + if (state === "applied" || state === "dismissed") { + return ( + <li className="text-muted-foreground flex items-center gap-2 py-1 text-sm"> + <CheckIcon + aria-hidden + className={cn( + "size-4 shrink-0", + state === "applied" && "text-success", + )} + /> + <span className="min-w-0 flex-1 truncate"> + {t(`fix.${state}`)}: {suggestion.message} + </span> + {state === "dismissed" ? ( + <Button + onClick={() => { + setState("idle"); + }} + size="xs" + type="button" + variant="ghost" + > + {t("fix.undo")} + </Button> + ) : null} + </li> + ); + } + + return ( + <li className="flex flex-col gap-3 rounded-lg p-3 ring-1 ring-black/8 dark:ring-white/10"> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium"> + {t(`areas.${suggestion.area}`)} + </span> + {suggestion.priority === "high" ? ( + <Badge variant="warning">{t("high")}</Badge> + ) : null} + </div> + <p className="text-sm leading-relaxed text-pretty"> + {suggestion.message} + </p> + {state === "open" && fix ? ( + <> + <FixPreview fix={fix} /> + {fixed ? null : ( + <p className="text-warn text-xs leading-relaxed text-pretty"> + {t("fix.stale")} + </p> + )} + </> + ) : null} + <div className="-ms-2 flex flex-wrap gap-1"> + {state === "open" && fixed ? ( + <Button + className="ms-2" + onClick={() => { + onApply(fixed); + setState("applied"); + }} + size="xs" + type="button" + > + <CheckIcon aria-hidden /> + {t("fix.apply")} + </Button> + ) : null} + {fix && state === "idle" ? ( + <Button + className="text-primary" + onClick={() => { + setState("open"); + }} + size="xs" + type="button" + variant="ghost" + > + <WandSparklesIcon aria-hidden /> + {t("fix.show")} + </Button> + ) : null} + <Button + onClick={() => { + setState("dismissed"); + }} + size="xs" + type="button" + variant="ghost" + > + {t("fix.dismiss")} + </Button> + </div> + </li> + ); +}; + export const ArticleAiReview = ({ content, excerpt, locale, + onContentChange, title, }: { - content: string; + content: null | RichTextDocument; excerpt: string; locale: string; + onContentChange: (document: RichTextDocument) => void; title: string; }) => { const t = useTranslations("@vitnode/blog.admin.article.editor.review"); const tError = useTranslations("core.global.ai_assist.error"); - const { data: available = [] } = useAvailableAiActions("admin"); const [pending, setPending] = React.useState(false); const [review, setReview] = React.useState<ArticleReview | null>(null); + const [runId, setRunId] = React.useState(0); const [error, setError] = React.useState<null | string>(null); - if (!available.includes(ARTICLE_REVIEW_ACTION)) return null; - const run = async () => { setPending(true); setError(null); @@ -93,7 +205,10 @@ export const ArticleAiReview = ({ action: ARTICLE_REVIEW_ACTION, input: { content, excerpt: excerpt || undefined, locale, title }, }); - if (isReview(output)) setReview(output); + if (isReview(output)) { + setReview(output); + setRunId(current => current + 1); + } } catch (caught) { setError(tError(aiErrorCodeOf(caught))); } finally { @@ -101,37 +216,62 @@ export const ArticleAiReview = ({ } }; - return ( - <section - aria-labelledby="article-ai-review" - className="flex flex-col gap-3" + const runButton = ( + <Button + className="self-start" + disabled={pending || !title.trim() || isRichTextEmpty(content)} + onClick={() => void run()} + size="sm" + type="button" + variant="outline" > - <div className="flex items-center justify-between gap-2"> - <h2 className="text-sm font-semibold" id="article-ai-review"> - {t("title")} - </h2> - <Button - disabled={pending || !title.trim() || !content.trim()} - onClick={() => void run()} - size="xs" - type="button" - variant="outline" - > - {pending ? <Spinner /> : <SparklesIcon aria-hidden />} - {review ? t("again") : t("run")} - </Button> - </div> - <p className="text-muted-foreground text-xs leading-relaxed text-pretty"> - {t("disclaimer")} - </p> - <div aria-live="polite"> - {error ? ( - <p className="text-destructive text-sm" role="alert"> - {error} + {pending ? <Spinner /> : <SparklesIcon aria-hidden />} + {review ? t("again") : t("run")} + </Button> + ); + const high = review + ? review.suggestions.filter(item => item.priority === "high").length + : 0; + + return ( + <div aria-live="polite" className="flex flex-col gap-5"> + {review ? ( + <div className="bg-muted/60 flex flex-col gap-1.5 rounded-xl p-4"> + <p className="text-base font-semibold text-balance"> + {review.suggestions.length === 0 + ? t("nothing") + : t("verdict", { + high, + low: review.suggestions.length - high, + })} </p> - ) : null} - {review ? <ArticleReviewResult review={review} /> : null} - </div> - </section> + <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {review.summary} + </p> + </div> + ) : ( + <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {t("intro")} + </p> + )} + {error ? ( + <p className="text-destructive text-sm" role="alert"> + {error} + </p> + ) : null} + {review && review.suggestions.length > 0 ? ( + <ul className="flex flex-col gap-3" key={runId}> + {review.suggestions.map(suggestion => ( + <SuggestionCard + content={content} + key={`${suggestion.area}:${suggestion.message}`} + onApply={onContentChange} + suggestion={suggestion} + /> + ))} + </ul> + ) : null} + {runButton} + </div> ); }; diff --git a/plugins/blog/src/views/admin/article/editor/ai.ts b/plugins/blog/src/views/admin/article/editor/ai.ts index 9baa37fe86..8f74c69aaf 100644 --- a/plugins/blog/src/views/admin/article/editor/ai.ts +++ b/plugins/blog/src/views/admin/article/editor/ai.ts @@ -1,3 +1,7 @@ +import type { EditorEmojiSection } from "@vitnode/core/components/editor-provider"; +import type { RichTextDocument } from "@vitnode/core/content/rich-text"; + +import { richTextDocumentFromHtml } from "@vitnode/core/components/tiptap/rich-text-json"; import { AiRequestError, requestAiAssist, @@ -17,7 +21,6 @@ export class ArticleAiError extends Error { } export const translateArticleText = async (body: { - format: "html" | "text"; from: string; text: string; to: string; @@ -27,7 +30,7 @@ export const translateArticleText = async (body: { method: "post", module: "admin/ai", path: "/translate", - args: { body }, + args: { body: { ...body, format: "text" } }, }); if (response.status !== 200) throw new ArticleAiError(response.status); @@ -36,8 +39,33 @@ export const translateArticleText = async (body: { return text; }; +export const translateArticleContent = async ({ + customEmojis, + document, + from, + to, +}: { + customEmojis?: EditorEmojiSection[]; + document: RichTextDocument; + from: string; + to: string; +}): Promise<RichTextDocument> => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/ai", + path: "/translate", + args: { body: { document, format: "richText", from, to } }, + }); + if (response.status !== 200) throw new ArticleAiError(response.status); + + const { text } = await response.json(); + + return richTextDocumentFromHtml(text, { customEmojis }); +}; + export const writeArticleExcerpt = async (input: { - content: string; + content: RichTextDocument; locale: string; title: string; }): Promise<string> => { diff --git a/plugins/blog/src/views/admin/article/editor/article-editor.tsx b/plugins/blog/src/views/admin/article/editor/article-editor.tsx index bd454eb5c9..6a162268f7 100644 --- a/plugins/blog/src/views/admin/article/editor/article-editor.tsx +++ b/plugins/blog/src/views/admin/article/editor/article-editor.tsx @@ -1,14 +1,30 @@ +import type { RichTextDocument } from "@vitnode/core/content/rich-text"; + import { Link } from "@tanstack/react-router"; +import { useEditorConfig } from "@vitnode/core/components/editor-provider"; +import { AutoFormFileDisplayContext } from "@vitnode/core/components/form/fields/file-display"; import { MultiLangLanguageContext } from "@vitnode/core/components/form/fields/multi-lang-language"; import { useLanguages } from "@vitnode/core/components/languages-provider"; +import { RichTextContent } from "@vitnode/core/components/rich-text"; +import { Badge } from "@vitnode/core/components/ui/badge"; import { Button } from "@vitnode/core/components/ui/button"; -import { EditorContent } from "@vitnode/core/components/ui/editor-content"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@vitnode/core/components/ui/sheet"; import { ContentFormActions, ContentFormField, ContentFormStatusSwitch, + ContentLivePresence, + ContentLiveStatus, useContentForm, useContentFormValues, + useContentLive, + useContentRichTextReplace, useSetContentFormValue, useTranslationFreshness, } from "@vitnode/core/content/admin-form"; @@ -18,25 +34,34 @@ import { upsertLangValue, } from "@vitnode/core/lib/helpers/multi-lang"; import { cn } from "cn"; -import { ArrowLeftIcon, ListChecksIcon } from "lucide-react"; +import { ArrowLeftIcon, SearchIcon, SparklesIcon } from "lucide-react"; import React from "react"; import { useTranslations } from "use-intl"; -import { translateArticleText, writeArticleExcerpt } from "./ai"; -import { ArticleAiReview } from "./ai-review"; +import type { CheckAction } from "./readiness-actions"; + +import { + translateArticleContent, + translateArticleText, + writeArticleExcerpt, +} from "./ai"; +import { ArticleAiReview, useArticleReviewAvailable } from "./ai-review"; import { type ArticleFieldActions, ArticleFieldActionsContext, } from "./field-actions"; -import { type CheckAction, Previews, ReadinessList } from "./publish-panel"; import { type ArticleCheck, articleChecks, + articleContent, + type ArticleTextField, type ArticleValues, + fieldText, REQUIRED_TRANSLATED_FIELDS, type TranslatedField, translatedFieldStatus, } from "./readiness"; +import { SeoAudit } from "./seo-audit"; import { ActiveTranslation, AiButton, @@ -71,11 +96,15 @@ export const ArticleEditor = ({ valuesRef.current = values; }); const setFormValue = useSetContentFormValue(); + const live = useContentLive(); + const replaceRichText = useContentRichTextReplace(); + const { emojis } = useEditorConfig(); const ai = useArticleAi(); const [target, setTarget] = React.useState<null | string>(null); const [contentRevision, setContentRevision] = React.useState(0); const [dismissed, setDismissed] = React.useState<string[]>([]); - const panelRef = React.useRef<HTMLElement>(null); + const [sheet, setSheet] = React.useState<"review" | "seo" | null>(null); + const reviewAvailable = useArticleReviewAvailable(); markHeaderRendered?.(); @@ -96,7 +125,7 @@ export const ArticleEditor = ({ const ready = checks.filter(check => check.ok).length; const setLangValue = ( - field: TranslatedField, + field: ArticleTextField, locale: string, text: string, ) => { @@ -104,7 +133,23 @@ export const ArticleEditor = ({ field, upsertLangValue(valuesRef.current[field], locale, text), ); - if (field === "content") setContentRevision(revision => revision + 1); + }; + + const setContent = (locale: string, document: RichTextDocument) => { + setFormValue( + "content", + upsertLangValue<null | RichTextDocument>( + valuesRef.current.content, + locale, + document, + ), + ); + if (replaceRichText) { + replaceRichText("content", locale, document); + + return; + } + setContentRevision(revision => revision + 1); }; const statusOf = (field: TranslatedField, locale: string) => @@ -123,11 +168,27 @@ export const ArticleEditor = ({ await ai.run( `${field}:${to}`, async () => { + if (field === "content") { + const document = articleContent(valuesRef.current, source); + if (!document) return; + + setContent( + to, + await translateArticleContent({ + customEmojis: emojis, + document, + from: source, + to, + }), + ); + + return; + } + setLangValue( field, to, await translateArticleText({ - format: field === "content" ? "html" : "text", from: source, text: getLangValue(valuesRef.current[field], source), to, @@ -175,9 +236,9 @@ export const ArticleEditor = ({ "excerpt", locale, await writeArticleExcerpt({ - content: - getLangValue(current.content, locale) || - getLangValue(current.content, source), + content: (fieldText(current, "content", locale) + ? articleContent(current, locale) + : articleContent(current, source)) ?? { type: "doc" }, locale, title: getLangValue(current.title, locale) || @@ -190,6 +251,7 @@ export const ArticleEditor = ({ }; const openLanguage = (code: string) => { + setSheet(null); setTarget(code); window.scrollTo({ top: 0 }); }; @@ -217,6 +279,7 @@ export const ArticleEditor = ({ ai: true, label: t("publish.checks.write"), run: () => { + setSheet("seo"); for (const code of check.missing) void writeExcerpt(code); }, }, @@ -242,6 +305,26 @@ export const ArticleEditor = ({ }; const fieldLocale = target ?? source; + const unready = + checks.length - + ready + + (getLangValue(values.friendlyUrl, fieldLocale).trim() ? 0 : 1); + + const editAlt = () => { + setSheet(null); + window.setTimeout(() => { + const input = document.querySelector<HTMLInputElement>( + 'input[name="coverImageAlt"]', + ); + input?.scrollIntoView({ behavior: "smooth", block: "center" }); + input?.focus({ preventScroll: true }); + }, 350); + }; + + const focusLanguage = live?.session.focus; + React.useEffect(() => { + focusLanguage?.(null, fieldLocale); + }, [fieldLocale, focusLanguage]); const excerptPending = ai.isPending(`excerpt:${fieldLocale}`); const fieldActions: ArticleFieldActions = ai.available ? { @@ -259,20 +342,6 @@ export const ArticleEditor = ({ pendingLabel={t("ai.writing")} /> ), - ...(target && getLangValue(values.coverImageAlt, source).trim() - ? { - coverImageAlt: ( - <AiButton - label={t("translate.field")} - onClick={() => { - void translateField("coverImageAlt", target); - }} - pending={ai.isPending(`coverImageAlt:${target}`)} - pendingLabel={t("ai.translating")} - /> - ), - } - : {}), } : {}; @@ -344,56 +413,83 @@ export const ArticleEditor = ({ ) : null} <h1 className="sr-only">{header?.title ?? t("heading")}</h1> <div className="flex-1" /> - {translationLanguages.length > 0 ? ( - target ? ( - <ActiveTranslation - onExit={() => { - setTarget(null); + <ContentLiveStatus /> + <ContentLivePresence /> + <div className="flex items-center gap-2 max-sm:order-last max-sm:-mx-4 max-sm:grow max-sm:basis-full max-sm:border-t max-sm:px-4 max-sm:pt-2"> + {translationLanguages.length > 0 ? ( + target ? ( + <ActiveTranslation + onExit={() => { + setTarget(null); + }} + sourceName={sourceName} + targetName={languageName(target)} + /> + ) : ( + <TranslateMenu + languages={translationLanguages} + onPick={openLanguage} + sourceName={sourceName} + /> + ) + ) : null} + <Button + aria-label={ + unready > 0 + ? t("seo.open_issues", { count: unready }) + : t("seo.open") + } + onClick={() => { + setSheet("seo"); + }} + size="sm" + type="button" + variant="ghost" + > + <SearchIcon aria-hidden /> + <span className="hidden sm:inline">{t("seo.open")}</span> + {unready > 0 ? <Badge variant="warning">{unready}</Badge> : null} + </Button> + {reviewAvailable ? ( + <Button + aria-label={t("review.open")} + onClick={() => { + setSheet("review"); }} - sourceName={sourceName} - targetName={languageName(target)} - /> - ) : ( - <TranslateMenu - languages={translationLanguages} - onPick={openLanguage} - sourceName={sourceName} - /> - ) - ) : null} - <Button - aria-label={t("publish.open", { - done: ready, - total: checks.length, - })} - onClick={() => { - panelRef.current?.scrollIntoView({ block: "start" }); - panelRef.current?.focus({ preventScroll: true }); - }} - size="sm" - type="button" - variant="ghost" - > - <ListChecksIcon aria-hidden /> - <span className="tabular-nums"> - {ready}/{checks.length} - </span> - </Button> - <ContentFormStatusSwitch /> + size="sm" + type="button" + variant="ghost" + > + <SparklesIcon aria-hidden /> + <span className="hidden sm:inline">{t("review.open")}</span> + </Button> + ) : null} + <ContentFormStatusSwitch className="max-sm:ms-auto" /> + </div> <ContentFormActions withPublicationToggle={false} /> </header> - <div - className={cn( - "grid grid-cols-1 items-start gap-8 px-4 py-8 sm:px-6 xl:grid-cols-[minmax(0,1fr)_22rem]", - )} - > + <div className="px-4 py-8 sm:px-6"> <div className={cn( "mx-auto flex w-full min-w-0 flex-col gap-6", !target && "max-w-3xl", )} > + <div className="mx-auto flex w-full max-w-3xl flex-col gap-4"> + <div className="flex flex-col gap-2"> + <AutoFormFileDisplayContext value="cover"> + <ContentFormField name="coverImage" /> + </AutoFormFileDisplayContext> + </div> + {target ? null : ( + <MultiLangLanguageContext value={source}> + <div className="flex flex-col gap-2"> + <ContentFormField name="coverImageAlt" /> + </div> + </MultiLangLanguageContext> + )} + </div> {target ? ( <> {outdated.includes(target) && !dismissed.includes(target) ? ( @@ -458,6 +554,20 @@ export const ArticleEditor = ({ ) : null} <MultiLangLanguageContext value={target}> + <PairRow + action={fieldAction("coverImageAlt", target)} + source={ + <p className="pt-7 text-sm leading-relaxed" lang={source}> + {getLangValue(values.coverImageAlt, source) || + t("translate.empty_source", { language: sourceName })} + </p> + } + sourceName={sourceName} + status={statusOf("coverImageAlt", target)} + targetName={languageName(target)} + > + <ContentFormField name="coverImageAlt" /> + </PairRow> <PairRow action={fieldAction("title", target)} source={ @@ -497,8 +607,8 @@ export const ArticleEditor = ({ action={fieldAction("content", target)} source={ <div className="pt-16" lang={source}> - <EditorContent - content={getLangValue(values.content, source)} + <RichTextContent + content={articleContent(values, source)} /> </div> } @@ -521,69 +631,72 @@ export const ArticleEditor = ({ </MultiLangLanguageContext> )} </div> + </div> + + <Sheet + onOpenChange={open => { + if (!open) setSheet(null); + }} + open={sheet === "seo"} + > + <SheetContent className="w-full sm:max-w-md" keepMounted> + <SheetHeader> + <SheetTitle>{t("seo.title")}</SheetTitle> + <SheetDescription>{t("seo.desc")}</SheetDescription> + </SheetHeader> + <div className="overflow-y-auto px-4 pb-6"> + <SeoAudit + actionsFor={actionsFor} + checks={checks} + cover={files?.coverImage} + excerptField={ + <MultiLangLanguageContext value={fieldLocale}> + {target && getLangValue(values.excerpt, source).trim() ? ( + <p + className="bg-muted text-muted-foreground rounded-md px-2.5 py-2 text-sm leading-relaxed" + lang={source} + > + {getLangValue(values.excerpt, source)} + </p> + ) : null} + <ContentFormField name="excerpt" /> + </MultiLangLanguageContext> + } + languageName={languageName} + locale={fieldLocale} + onEditAlt={editAlt} + sourceName={sourceName} + values={values} + /> + </div> + </SheetContent> + </Sheet> - <aside - aria-labelledby="article-ready" - className="flex scroll-mt-20 flex-col gap-8 outline-none xl:sticky xl:top-20 xl:max-h-[calc(100dvh-6rem)] xl:overflow-y-auto xl:pe-1" - ref={panelRef} - tabIndex={-1} + {reviewAvailable ? ( + <Sheet + onOpenChange={open => { + if (!open) setSheet(null); + }} + open={sheet === "review"} > - <ReadinessList - actionsFor={actionsFor} - checks={checks} - languageName={languageName} - sourceName={sourceName} - /> - <ArticleAiReview - content={getLangValue(values.content, fieldLocale)} - excerpt={getLangValue(values.excerpt, fieldLocale)} - locale={fieldLocale} - title={getLangValue(values.title, fieldLocale)} - /> - <Previews - cover={files?.coverImage} - languages={languages} - source={source} - values={values} - /> - <section - aria-labelledby="article-details" - className="flex flex-col gap-5" - > - <h2 className="text-sm font-semibold" id="article-details"> - {t("publish.details")} - </h2> - <MultiLangLanguageContext value={fieldLocale}> - <div className="flex flex-col gap-2"> - {target && getLangValue(values.excerpt, source).trim() ? ( - <p - className="bg-muted text-muted-foreground rounded-md px-2.5 py-2 text-sm leading-relaxed" - lang={source} - > - {getLangValue(values.excerpt, source)} - </p> - ) : null} - <ContentFormField name="excerpt" /> - </div> - <div className="flex flex-col gap-2"> - <ContentFormField name="coverImage" /> - </div> - <div className="flex flex-col gap-2"> - {target && - getLangValue(values.coverImageAlt, source).trim() ? ( - <p - className="bg-muted text-muted-foreground rounded-md px-2.5 py-2 text-sm leading-relaxed" - lang={source} - > - {getLangValue(values.coverImageAlt, source)} - </p> - ) : null} - <ContentFormField name="coverImageAlt" /> - </div> - </MultiLangLanguageContext> - </section> - </aside> - </div> + <SheetContent className="w-full sm:max-w-md" keepMounted> + <SheetHeader> + <SheetTitle>{t("review.title")}</SheetTitle> + </SheetHeader> + <div className="overflow-y-auto px-4 pb-6"> + <ArticleAiReview + content={articleContent(values, fieldLocale)} + excerpt={getLangValue(values.excerpt, fieldLocale)} + locale={fieldLocale} + onContentChange={next => { + setContent(fieldLocale, next); + }} + title={getLangValue(values.title, fieldLocale)} + /> + </div> + </SheetContent> + </Sheet> + ) : null} </div> </ArticleFieldActionsContext> ); diff --git a/plugins/blog/src/views/admin/article/editor/fields.tsx b/plugins/blog/src/views/admin/article/editor/fields.tsx index c114ff7c36..91fafd7828 100644 --- a/plugins/blog/src/views/admin/article/editor/fields.tsx +++ b/plugins/blog/src/views/admin/article/editor/fields.tsx @@ -3,6 +3,7 @@ import type { ItemAutoFormComponentProps } from "@vitnode/core/components/form/a import { AutoFormLabel } from "@vitnode/core/components/form/common/label"; import { type MultiLangFieldProps, + MultiLangLabel, useMultiLangField, } from "@vitnode/core/components/form/fields/multi-lang"; import { FormControl, FormMessage } from "@vitnode/core/components/ui/form"; @@ -54,7 +55,7 @@ export const ArticleTitleField = ({ field }: ItemAutoFormComponentProps) => { </AutoFormLabel> <FormControl> <textarea - className="placeholder:text-muted-foreground/60 aria-invalid:text-destructive field-sizing-content w-full resize-none bg-transparent text-3xl leading-tight font-bold tracking-tight text-balance outline-none sm:text-4xl" + className="placeholder:text-muted-foreground/60 aria-invalid:text-destructive field-sizing-content w-full resize-none bg-transparent text-3xl leading-tight font-bold tracking-tight text-balance transition-opacity duration-200 ease-out outline-none disabled:cursor-not-allowed disabled:opacity-60 motion-reduce:transition-none sm:text-4xl" lang={selected} name={field.name} onBlur={field.onBlur} @@ -66,7 +67,7 @@ export const ArticleTitleField = ({ field }: ItemAutoFormComponentProps) => { value={currentValue} /> </FormControl> - <div className="flex justify-end"> + <div className="flex items-center justify-end gap-2"> <CharacterCount field="title" value={currentValue} /> </div> <FormMessage /> @@ -89,7 +90,7 @@ export const ArticleSlugField = ({ field }: ItemAutoFormComponentProps) => { </span> <FormControl> <input - className="hover:bg-muted focus-visible:bg-muted focus-visible:ring-ring/50 text-foreground min-w-0 flex-1 truncate rounded-sm bg-transparent px-1 py-0.5 text-base outline-none focus-visible:ring-3 md:text-sm" + className="hover:bg-muted focus-visible:bg-muted focus-visible:ring-ring/50 text-foreground min-w-0 flex-1 truncate rounded-sm bg-transparent px-1 py-0.5 text-base outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-60 md:text-sm" name={field.name} onBlur={field.onBlur} onChange={event => { @@ -149,9 +150,14 @@ export const ArticleExcerptField = ({ field }: ItemAutoFormComponentProps) => { export const ArticleCoverAltField = ({ field }: ItemAutoFormComponentProps) => { const t = useTranslations("@vitnode/blog"); const labelRight = useArticleFieldAction("coverImageAlt"); - const { currentValue, selected, setValue } = useMultiLangField( - field as MultiLangFieldProps["field"], - ); + const { + canSelect, + currentValue, + languages, + selected, + setSelected, + setValue, + } = useMultiLangField(field as MultiLangFieldProps["field"]); const { files } = useContentForm(); const cover = files?.coverImage; const fileAlt = @@ -159,9 +165,15 @@ export const ArticleCoverAltField = ({ field }: ItemAutoFormComponentProps) => { return ( <> - <AutoFormLabel isOptional labelRight={labelRight}> - {t("content.post.fields.coverImageAlt")} - </AutoFormLabel> + <MultiLangLabel + canSelect={canSelect} + isOptional + label={t("content.post.fields.coverImageAlt")} + labelRight={labelRight} + languages={languages} + onSelect={setSelected} + selected={selected} + /> <FormControl> <input className={cn(fieldClassName, "h-9")} diff --git a/plugins/blog/src/views/admin/article/editor/passage-fix.test.ts b/plugins/blog/src/views/admin/article/editor/passage-fix.test.ts new file mode 100644 index 0000000000..cc8222b2f4 --- /dev/null +++ b/plugins/blog/src/views/admin/article/editor/passage-fix.test.ts @@ -0,0 +1,130 @@ +import type { + RichTextDocument, + RichTextNode, +} from "@vitnode/core/content/rich-text"; + +import { describe, expect, it } from "vitest"; + +import { applyPassageFix } from "./passage-fix"; + +const doc = (...content: RichTextNode[]): RichTextDocument => ({ + content, + type: "doc", +}); + +const paragraph = ( + ...content: { marks?: { type: string }[]; text?: string; type: string }[] +) => ({ + content, + type: "paragraph", +}); + +const text = (value: string, marks?: { type: string }[]) => + marks ? { marks, text: value, type: "text" } : { text: value, type: "text" }; + +describe("applyPassageFix", () => { + it("replaces a passage inside one text node", () => { + const result = applyPassageFix( + doc(paragraph(text("Do not read this. It is a test."))), + { quote: "Do not read this.", replacement: "Read this." }, + ); + + expect(result).toEqual(doc(paragraph(text("Read this. It is a test.")))); + }); + + it("keeps the formatting around a passage that spans marked text", () => { + const result = applyPassageFix( + doc( + paragraph( + text("Start "), + text("bold words", [{ type: "bold" }]), + text(" end."), + ), + ), + { quote: "Start bold", replacement: "Opening" }, + ); + + expect(result).toEqual( + doc( + paragraph( + text("Opening"), + text(" words", [{ type: "bold" }]), + text(" end."), + ), + ), + ); + }); + + it("matches a quote whose whitespace the model collapsed", () => { + const result = applyPassageFix( + doc(paragraph(text("HELLO-FROM-A HELLO-FROM-B aa"))), + { quote: "HELLO-FROM-A HELLO-FROM-B", replacement: "Hello" }, + ); + + expect(result).toEqual(doc(paragraph(text("Hello aa")))); + }); + + it("removes a paragraph the fix deletes entirely", () => { + const result = applyPassageFix( + doc(paragraph(text("Keep me.")), paragraph(text("wow xdd"))), + { quote: "wow xdd", replacement: "" }, + ); + + expect(result).toEqual(doc(paragraph(text("Keep me.")))); + }); + + it("leaves an empty paragraph when the only text is deleted", () => { + const result = applyPassageFix(doc(paragraph(text("wow xdd"))), { + quote: "wow xdd", + replacement: "", + }); + + expect(result).toEqual(doc({ type: "paragraph" })); + }); + + it("finds a passage nested inside a list", () => { + const result = applyPassageFix( + doc({ + content: [ + { content: [paragraph(text("First item"))], type: "listItem" }, + ], + type: "bulletList", + }), + { quote: "First", replacement: "Only" }, + ); + + expect(result?.content?.[0]?.content?.[0]?.content?.[0]).toEqual( + paragraph(text("Only item")), + ); + }); + + it("does not match across an inline atom", () => { + const result = applyPassageFix( + doc(paragraph(text("Hello "), { type: "hardBreak" }, text("world"))), + { quote: "Hello world", replacement: "Hi" }, + ); + + expect(result).toBeNull(); + }); + + it("returns null when the passage is not in the article", () => { + expect( + applyPassageFix(doc(paragraph(text("Something else."))), { + quote: "Not here", + replacement: "x", + }), + ).toBeNull(); + expect(applyPassageFix(null, { quote: "x", replacement: "y" })).toBeNull(); + }); + + it("flattens line breaks in the replacement into one paragraph", () => { + const result = applyPassageFix(doc(paragraph(text("One. Two."))), { + quote: "One.", + replacement: "First line.\n\nSecond line.", + }); + + expect(result).toEqual( + doc(paragraph(text("First line. Second line. Two."))), + ); + }); +}); diff --git a/plugins/blog/src/views/admin/article/editor/passage-fix.ts b/plugins/blog/src/views/admin/article/editor/passage-fix.ts new file mode 100644 index 0000000000..89366d20e3 --- /dev/null +++ b/plugins/blog/src/views/admin/article/editor/passage-fix.ts @@ -0,0 +1,138 @@ +import type { + RichTextDocument, + RichTextNode, +} from "@vitnode/core/content/rich-text"; + +export interface PassageFix { + quote: string; + replacement: string; +} + +const INLINE_ATOM = ""; + +const escapeRegExp = (text: string) => + text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const passagePattern = (quote: string) => + new RegExp(quote.trim().split(/\s+/).map(escapeRegExp).join("\\s+"), "u"); + +const isTextBlock = (node: RichTextNode) => + node.content?.some(child => child.type === "text") ?? false; + +const inlineLength = (node: RichTextNode) => + node.type === "text" ? (node.text ?? "").length : INLINE_ATOM.length; + +const textNode = (text: string, marks: RichTextNode["marks"]): RichTextNode => + marks?.length ? { marks, text, type: "text" } : { text, type: "text" }; + +const sameMarks = (a: RichTextNode, b: RichTextNode) => + JSON.stringify(a.marks ?? []) === JSON.stringify(b.marks ?? []); + +const mergeTextNodes = (nodes: RichTextNode[]) => + nodes.reduce<RichTextNode[]>((merged, node) => { + if (node.type === "text" && !node.text) return merged; + const last = merged.at(-1); + if ( + last?.type === "text" && + node.type === "text" && + sameMarks(last, node) + ) { + merged[merged.length - 1] = textNode( + `${last.text ?? ""}${node.text ?? ""}`, + last.marks, + ); + + return merged; + } + merged.push(node); + + return merged; + }, []); + +const spliceTextBlock = ( + block: RichTextNode, + pattern: RegExp, + replacement: string, +): null | RichTextNode => { + const children = block.content ?? []; + const flat = children + .map(child => (child.type === "text" ? (child.text ?? "") : INLINE_ATOM)) + .join(""); + const match = pattern.exec(flat); + if (!match) return null; + + const start = match.index; + const end = start + match[0].length; + const next: RichTextNode[] = []; + let offset = 0; + let inserted = false; + + for (const child of children) { + const childStart = offset; + const childEnd = offset + inlineLength(child); + offset = childEnd; + + if (childEnd <= start || childStart >= end) { + next.push(child); + continue; + } + + const text = child.text ?? ""; + if (start > childStart) { + next.push(textNode(text.slice(0, start - childStart), child.marks)); + } + if (!inserted) { + inserted = true; + if (replacement) next.push(textNode(replacement, child.marks)); + } + if (childEnd > end) { + next.push(textNode(text.slice(end - childStart), child.marks)); + } + } + + return { ...block, content: mergeTextNodes(next) }; +}; + +const replaceIn = ( + node: RichTextNode, + pattern: RegExp, + replacement: string, +): null | RichTextNode => { + if (isTextBlock(node)) return spliceTextBlock(node, pattern, replacement); + + const children = node.content ?? []; + for (const [index, child] of children.entries()) { + const replaced = replaceIn(child, pattern, replacement); + if (!replaced) continue; + + const emptied = isTextBlock(child) && (replaced.content?.length ?? 0) === 0; + const content = emptied + ? children.filter((_, at) => at !== index) + : children.map((item, at) => (at === index ? replaced : item)); + + return { + ...node, + content: + content.length > 0 || node.type !== "doc" + ? content + : [{ type: "paragraph" }], + }; + } + + return null; +}; + +export const applyPassageFix = ( + document: null | RichTextDocument, + { quote, replacement }: PassageFix, +): null | RichTextDocument => { + if (!document || !quote.trim()) return null; + + const replaced = replaceIn( + document, + passagePattern(quote), + replacement.replace(/\s*\n+\s*/g, " "), + ); + + return replaced ? { ...replaced, type: "doc" } : null; +}; diff --git a/plugins/blog/src/views/admin/article/editor/publish-panel.tsx b/plugins/blog/src/views/admin/article/editor/publish-panel.tsx deleted file mode 100644 index 01a9768576..0000000000 --- a/plugins/blog/src/views/admin/article/editor/publish-panel.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import type { ContentFileFieldValue } from "@vitnode/core/content/files"; - -import { Button } from "@vitnode/core/components/ui/button"; -import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { stripHtml } from "@vitnode/core/lib/strip-html"; -import { cn } from "cn"; -import { CircleAlertIcon, CircleCheckIcon, SparklesIcon } from "lucide-react"; -import React from "react"; -import { useTranslations } from "use-intl"; - -import { - type ArticleCheck, - type ArticleValues, - RECOMMENDED_LENGTH, -} from "./readiness"; - -export interface CheckAction { - ai?: boolean; - label: string; - run: () => void; -} - -const CheckRow = ({ - actions, - detail, - label, - ok, -}: { - actions?: CheckAction[]; - detail?: React.ReactNode; - label: string; - ok: boolean; -}) => ( - <li className="flex gap-2 py-1.5"> - {ok ? ( - <CircleCheckIcon - aria-hidden - className="text-success mt-0.5 size-4 shrink-0" - /> - ) : ( - <CircleAlertIcon - aria-hidden - className="text-warn mt-0.5 size-4 shrink-0" - /> - )} - <div className="flex min-w-0 flex-col gap-0.5"> - <span className="text-sm">{label}</span> - {!ok && detail ? ( - <span className="text-muted-foreground text-xs leading-relaxed text-pretty"> - {detail} - </span> - ) : null} - {!ok && actions?.length ? ( - <div className="-ms-2 flex flex-wrap gap-1"> - {actions.map(action => ( - <Button - className={cn(action.ai && "text-primary")} - key={action.label} - onClick={action.run} - size="xs" - type="button" - variant="ghost" - > - {action.ai ? <SparklesIcon /> : null} - {action.label} - </Button> - ))} - </div> - ) : null} - </div> - </li> -); - -export const ReadinessList = ({ - actionsFor, - checks, - languageName, - sourceName, -}: { - actionsFor: (check: ArticleCheck) => CheckAction[]; - checks: ArticleCheck[]; - languageName: (code: string) => string; - sourceName: string; -}) => { - const t = useTranslations("@vitnode/blog.admin.article.editor.publish"); - const done = checks.filter(check => check.ok).length; - const list = (codes: readonly string[]) => codes.map(languageName).join(", "); - - const detailOf = (check: ArticleCheck): React.ReactNode => { - switch (check.id) { - case "alt": - return t("checks.alt_detail", { languages: list(check.missing) }); - case "cover": - return t("checks.cover_detail"); - case "excerpt": - return t("checks.excerpt_detail", { languages: list(check.missing) }); - case "outdated": - return t("checks.outdated_detail", { - languages: list(check.outdated), - source: sourceName, - }); - case "title": - return t("checks.title_detail", { - languages: list(check.tooLong), - max: RECOMMENDED_LENGTH.title, - }); - case "translations": - return ( - <span className="flex flex-col"> - {Object.entries(check.missing).map(([code, count]) => ( - <span key={code}> - {t("checks.translations_detail", { - count, - language: languageName(code), - })} - </span> - ))} - </span> - ); - } - }; - - return ( - <section aria-labelledby="article-ready" className="flex flex-col gap-2"> - <div className="flex items-center justify-between"> - <h2 className="text-sm font-semibold" id="article-ready"> - {t("title")} - </h2> - <span className="text-muted-foreground text-xs tabular-nums"> - {done}/{checks.length} - </span> - </div> - <div - aria-label={t("title")} - aria-valuemax={checks.length} - aria-valuemin={0} - aria-valuenow={done} - className="bg-muted h-1 overflow-hidden rounded-full" - role="progressbar" - > - <div - className="bg-success h-full rounded-full transition-[width] duration-300 ease-out motion-reduce:transition-none" - style={{ width: `${(done / checks.length) * 100}%` }} - /> - </div> - <ul className="flex flex-col"> - {checks.map(check => ( - <CheckRow - actions={actionsFor(check)} - detail={detailOf(check)} - key={check.id} - label={t(`checks.${check.id}`)} - ok={check.ok} - /> - ))} - </ul> - </section> - ); -}; - -const subscribeNever = () => () => {}; - -const coverUrlOf = ( - value: unknown, - file: ContentFileFieldValue | undefined, -): null | string => { - if (!file || Array.isArray(file)) return null; - - return file.id === value ? file.url : null; -}; - -export const Previews = ({ - cover, - languages, - source, - values, -}: { - cover: ContentFileFieldValue | undefined; - languages: { code: string; name: string }[]; - source: string; - values: ArticleValues; -}) => { - const t = useTranslations( - "@vitnode/blog.admin.article.editor.publish.previews", - ); - const [locale, setLocale] = React.useState(source); - const pick = (field: "excerpt" | "friendlyUrl" | "title") => { - const own = getLangValue(values[field], locale).trim(); - - return own - ? { fallback: false, text: own } - : { - fallback: locale !== source, - text: getLangValue(values[field], source), - }; - }; - const title = pick("title"); - const slug = pick("friendlyUrl"); - const excerpt = pick("excerpt"); - const description = - excerpt.text.trim() || - stripHtml( - getLangValue(values.content, locale) || - getLangValue(values.content, source), - ) - .trim() - .slice(0, RECOMMENDED_LENGTH.excerpt); - const searchTitle = - title.text.length > RECOMMENDED_LENGTH.title - ? `${title.text.slice(0, RECOMMENDED_LENGTH.title - 1).trimEnd()}…` - : title.text; - const coverUrl = coverUrlOf(values.coverImage, cover); - const host = React.useSyncExternalStore( - subscribeNever, - () => window.location.host, - () => "", - ); - const sourceName = - languages.find(language => language.code === source)?.name ?? source; - const fallbacks = [title, slug, excerpt].some(item => item.fallback); - - return ( - <section aria-labelledby="article-previews" className="flex flex-col gap-3"> - <div className="flex items-center justify-between gap-2"> - <h2 className="text-sm font-semibold" id="article-previews"> - {t("title")} - </h2> - {languages.length > 1 ? ( - <div - aria-label={t("language")} - className="bg-muted flex gap-0.5 rounded-md p-0.5" - role="radiogroup" - > - {languages.map(language => ( - <button - aria-checked={locale === language.code} - aria-label={language.name} - className={cn( - "text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 relative h-6 rounded-sm px-2 text-xs font-medium uppercase transition-[color,background-color,box-shadow] duration-150 ease-out outline-none before:absolute before:inset-x-0 before:-inset-y-2 focus-visible:ring-3 motion-reduce:transition-none", - locale === language.code && - "bg-card text-foreground shadow-xs", - )} - key={language.code} - onClick={() => { - setLocale(language.code); - }} - role="radio" - type="button" - > - {language.code} - </button> - ))} - </div> - ) : null} - </div> - - {fallbacks ? ( - <p className="text-warn text-xs leading-relaxed text-pretty"> - {t("fallback", { - language: - languages.find(language => language.code === locale)?.name ?? - locale, - source: sourceName, - })} - </p> - ) : null} - - <figure className="flex flex-col gap-1"> - <figcaption className="text-muted-foreground text-xs"> - {t("search")} - </figcaption> - <div className="bg-card flex flex-col gap-1 rounded-lg p-3 shadow-xs ring-1 ring-black/8 dark:ring-white/10"> - <span className="text-muted-foreground truncate text-xs"> - {[host, locale === source ? null : locale, "blog", slug.text] - .filter(Boolean) - .join(" › ")} - </span> - <span className="text-primary text-base leading-snug"> - {searchTitle} - </span> - <span className="text-muted-foreground line-clamp-2 text-sm leading-relaxed"> - {description} - </span> - </div> - </figure> - - <figure className="flex flex-col gap-1"> - <figcaption className="text-muted-foreground text-xs"> - {t("social")} - </figcaption> - <div className="bg-card overflow-hidden rounded-lg shadow-xs ring-1 ring-black/8 dark:ring-white/10"> - {coverUrl ? ( - <img - alt="" - className="aspect-[1.91/1] w-full object-cover" - decoding="async" - height={420} - loading="lazy" - src={coverUrl} - width={800} - /> - ) : ( - <div className="bg-muted text-muted-foreground grid aspect-[1.91/1] place-items-center px-6 text-center text-xs leading-relaxed text-pretty"> - {t("no_cover")} - </div> - )} - <div className="flex flex-col gap-0.5 p-3"> - <span className="text-muted-foreground text-xs">{host}</span> - <span className="line-clamp-2 text-sm font-medium"> - {title.text} - </span> - </div> - </div> - </figure> - </section> - ); -}; diff --git a/plugins/blog/src/views/admin/article/editor/readiness-actions.tsx b/plugins/blog/src/views/admin/article/editor/readiness-actions.tsx new file mode 100644 index 0000000000..e3e9216f31 --- /dev/null +++ b/plugins/blog/src/views/admin/article/editor/readiness-actions.tsx @@ -0,0 +1,87 @@ +import type { ContentFileFieldValue } from "@vitnode/core/content/files"; + +import { Button } from "@vitnode/core/components/ui/button"; +import { cn } from "cn"; +import { SparklesIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { type ArticleCheck, RECOMMENDED_LENGTH } from "./readiness"; + +export interface CheckAction { + ai?: boolean; + label: string; + run: () => void; +} + +export const CheckActions = ({ actions }: { actions: CheckAction[] }) => ( + <div className="-ms-2 flex flex-wrap gap-1"> + {actions.map(action => ( + <Button + className={cn(action.ai && "text-primary")} + key={action.label} + onClick={action.run} + size="xs" + type="button" + variant="ghost" + > + {action.ai ? <SparklesIcon /> : null} + {action.label} + </Button> + ))} + </div> +); + +export const useCheckDetail = ({ + languageName, + sourceName, +}: { + languageName: (code: string) => string; + sourceName: string; +}) => { + const t = useTranslations("@vitnode/blog.admin.article.editor.publish"); + const list = (codes: readonly string[]) => codes.map(languageName).join(", "); + + return (check: ArticleCheck): React.ReactNode => { + switch (check.id) { + case "alt": + return t("checks.alt_detail", { languages: list(check.missing) }); + case "cover": + return t("checks.cover_detail"); + case "excerpt": + return t("checks.excerpt_detail", { languages: list(check.missing) }); + case "outdated": + return t("checks.outdated_detail", { + languages: list(check.outdated), + source: sourceName, + }); + case "title": + return t("checks.title_detail", { + languages: list(check.tooLong), + max: RECOMMENDED_LENGTH.title, + }); + case "translations": + return ( + <span className="flex flex-col"> + {Object.entries(check.missing).map(([code, count]) => ( + <span key={code}> + {t("checks.translations_detail", { + count, + language: languageName(code), + })} + </span> + ))} + </span> + ); + } + }; +}; + +export const coverUrlOf = ( + value: unknown, + file: ContentFileFieldValue | undefined, +): null | string => { + if (!file || Array.isArray(file)) return null; + + return file.id === value ? file.url : null; +}; diff --git a/plugins/blog/src/views/admin/article/editor/readiness.test.ts b/plugins/blog/src/views/admin/article/editor/readiness.test.ts index 069cbefd97..9220645f37 100644 --- a/plugins/blog/src/views/admin/article/editor/readiness.test.ts +++ b/plugins/blog/src/views/admin/article/editor/readiness.test.ts @@ -1,19 +1,34 @@ +import type { RichTextDocument } from "@vitnode/core/content/rich-text"; + import { describe, expect, it } from "vitest"; import { articleChecks, type ArticleValues, + fieldText, translatedFieldStatus, } from "./readiness"; -const lang = (entries: Record<string, string>) => +const lang = <TValue = string>(entries: Record<string, TValue>) => Object.entries(entries).map(([languageCode, value]) => ({ languageCode, value, })); +const doc = (...paragraphs: string[]): RichTextDocument => ({ + content: paragraphs.map(text => + text === "" + ? { type: "paragraph" } + : { content: [{ text, type: "text" }], type: "paragraph" }, + ), + type: "doc", +}); + const values: ArticleValues = { - content: lang({ en: "<p>Hello world</p>", pl: "<p></p>" }), + content: lang<null | RichTextDocument>({ + en: doc("Hello world"), + pl: doc(""), + }), coverImage: 12, coverImageAlt: lang({ en: "A dashboard" }), excerpt: lang({ en: "Short summary" }), @@ -24,8 +39,33 @@ const values: ArticleValues = { }), }; +describe("fieldText", () => { + it("reads the article body as the words of its document", () => { + expect( + fieldText( + { + content: lang<null | RichTextDocument>({ en: doc("One", "", "Two") }), + }, + "content", + "en", + ), + ).toBe("One\nTwo"); + }); + + it("reads a language with no document as empty", () => { + expect( + fieldText( + { content: lang<null | RichTextDocument>({ pl: null }) }, + "content", + "pl", + ), + ).toBe(""); + expect(fieldText(values, "content", "de")).toBe(""); + }); +}); + describe("translatedFieldStatus", () => { - it("treats an editor holding only empty markup as missing", () => { + it("treats an editor holding only an empty paragraph as missing", () => { expect( translatedFieldStatus(values, "content", { source: "en", target: "pl" }), ).toBe("missing"); diff --git a/plugins/blog/src/views/admin/article/editor/readiness.ts b/plugins/blog/src/views/admin/article/editor/readiness.ts index ebdee1b1cf..ea599cd138 100644 --- a/plugins/blog/src/views/admin/article/editor/readiness.ts +++ b/plugins/blog/src/views/admin/article/editor/readiness.ts @@ -1,7 +1,11 @@ +import type { RichTextDocument } from "@vitnode/core/content/rich-text"; import type { MultiLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { stripHtml } from "@vitnode/core/lib/strip-html"; +import { richTextToPlainText } from "@vitnode/core/content/rich-text"; +import { + findLangValue, + getLangValue, +} from "@vitnode/core/lib/helpers/multi-lang"; export const TRANSLATED_FIELDS = [ "title", @@ -25,19 +29,28 @@ export const RECOMMENDED_LENGTH = { title: 60, } as const; -export type ArticleValues = Partial<Record<TranslatedField, MultiLangValue>> & { +export type ArticleTextField = Exclude<TranslatedField, "content">; + +export type ArticleValues = Partial< + Record<ArticleTextField, MultiLangValue> +> & { + content?: MultiLangValue<null | RichTextDocument>; coverImage?: unknown; }; +export const articleContent = ( + values: ArticleValues, + locale: string, +): null | RichTextDocument => findLangValue(values.content, locale) ?? null; + export const fieldText = ( values: ArticleValues, field: TranslatedField, locale: string, -): string => { - const value = getLangValue(values[field], locale); - - return field === "content" ? stripHtml(value).trim() : value.trim(); -}; +): string => + field === "content" + ? richTextToPlainText(articleContent(values, locale)) + : getLangValue(values[field], locale).trim(); export const hasFieldText = ( values: ArticleValues, diff --git a/plugins/blog/src/views/admin/article/editor/seo-audit.tsx b/plugins/blog/src/views/admin/article/editor/seo-audit.tsx new file mode 100644 index 0000000000..2abd8d029c --- /dev/null +++ b/plugins/blog/src/views/admin/article/editor/seo-audit.tsx @@ -0,0 +1,219 @@ +import type { ContentFileFieldValue } from "@vitnode/core/content/files"; + +import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; +import { cn } from "cn"; +import { CircleAlertIcon, CircleCheckIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { CharacterCount } from "./fields"; +import { + type ArticleCheck, + type ArticleValues, + RECOMMENDED_LENGTH, +} from "./readiness"; +import { + type CheckAction, + CheckActions, + coverUrlOf, + useCheckDetail, +} from "./readiness-actions"; + +const Segments = ({ done, total }: { done: number; total: number }) => ( + <div aria-hidden className="flex gap-1"> + {Array.from({ length: total }, (_, index) => ( + <span + className={cn( + "h-1.5 flex-1 rounded-full transition-colors duration-200 ease-out motion-reduce:transition-none", + index < done ? "bg-success" : "bg-muted", + )} + key={index} + /> + ))} + </div> +); + +const LengthMeter = ({ max, value }: { max: number; value: string }) => ( + <div aria-hidden className="bg-muted h-1 flex-1 overflow-hidden rounded-full"> + <div + className={cn( + "h-full rounded-full transition-[width] duration-200 ease-out motion-reduce:transition-none", + value.length > max ? "bg-warn" : "bg-success", + )} + style={{ width: `${Math.min(value.length / max, 1) * 100}%` }} + /> + </div> +); + +const AuditRow = ({ + children, + label, + ok, +}: { + children: React.ReactNode; + label?: string; + ok: boolean; +}) => { + const t = useTranslations("@vitnode/blog.admin.article.editor.seo"); + + return ( + <li className="flex gap-3 py-3"> + {ok ? ( + <CircleCheckIcon + aria-label={t("done")} + className="text-success mt-0.5 size-4 shrink-0" + /> + ) : ( + <CircleAlertIcon + aria-label={t("needs_work")} + className="text-warn mt-0.5 size-4 shrink-0" + /> + )} + <div className="flex min-w-0 flex-1 flex-col gap-1.5"> + {label ? <span className="text-sm font-medium">{label}</span> : null} + {children} + </div> + </li> + ); +}; + +const Detail = ({ children }: { children: React.ReactNode }) => ( + <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {children} + </p> +); + +export const SeoAudit = ({ + actionsFor, + checks, + cover, + excerptField, + languageName, + locale, + onEditAlt, + sourceName, + values, +}: { + actionsFor: (check: ArticleCheck) => CheckAction[]; + checks: ArticleCheck[]; + cover: ContentFileFieldValue | undefined; + excerptField: React.ReactNode; + languageName: (code: string) => string; + locale: string; + onEditAlt: () => void; + sourceName: string; + values: ArticleValues; +}) => { + const t = useTranslations("@vitnode/blog.admin.article.editor.seo"); + const tPublish = useTranslations( + "@vitnode/blog.admin.article.editor.publish", + ); + const detailOf = useCheckDetail({ languageName, sourceName }); + const checkOf = (id: ArticleCheck["id"]) => + checks.find(check => check.id === id); + const title = getLangValue(values.title, locale); + const slug = getLangValue(values.friendlyUrl, locale).trim(); + const coverUrl = coverUrlOf(values.coverImage, cover); + + const titleCheck = checkOf("title"); + const excerptCheck = checkOf("excerpt"); + const coverCheck = checkOf("cover"); + const altCheck = checkOf("alt"); + const translationChecks = checks.filter( + check => check.id === "translations" || check.id === "outdated", + ); + const rows = [ + { label: t("rows.title"), ok: titleCheck?.ok ?? true }, + { label: t("rows.excerpt"), ok: excerptCheck?.ok ?? true }, + { label: t("rows.url"), ok: slug !== "" }, + { label: t("rows.cover"), ok: coverCheck?.ok ?? true }, + { label: t("rows.alt"), ok: altCheck?.ok ?? true }, + ...translationChecks.map(check => ({ + label: tPublish(`checks.${check.id}`), + ok: check.ok, + })), + ]; + const left = rows.filter(row => !row.ok); + const missing = (check: ArticleCheck | undefined) => + check && !check.ok ? <Detail>{detailOf(check)}</Detail> : null; + + return ( + <div className="flex flex-col gap-6"> + <div className="flex flex-col gap-2"> + <div className="flex items-baseline justify-between gap-3"> + <h3 className="text-sm font-semibold" id="article-ready"> + {tPublish("title")} + </h3> + <span className="text-muted-foreground text-xs tabular-nums"> + {rows.length - left.length}/{rows.length} + </span> + </div> + <Segments done={rows.length - left.length} total={rows.length} /> + {left.length > 0 ? ( + <p className="text-muted-foreground text-xs leading-relaxed text-pretty"> + {t("left", { items: left.map(row => row.label).join(", ") })} + </p> + ) : null} + </div> + + <ul className="flex flex-col divide-y"> + <AuditRow label={t("rows.title")} ok={titleCheck?.ok ?? true}> + <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {title} + </p> + <div className="flex items-center gap-3"> + <LengthMeter max={RECOMMENDED_LENGTH.title} value={title} /> + <CharacterCount field="title" value={title} /> + </div> + {missing(titleCheck)} + </AuditRow> + + <AuditRow ok={excerptCheck?.ok ?? true}> + <div className="flex flex-col gap-2">{excerptField}</div> + {missing(excerptCheck)} + </AuditRow> + + <AuditRow label={t("rows.url")} ok={slug !== ""}> + <Detail>{slug ? `/blog/${slug}` : t("url_missing")}</Detail> + </AuditRow> + + <AuditRow label={t("rows.cover")} ok={coverCheck?.ok ?? true}> + {coverUrl ? ( + <div className="flex items-center gap-3"> + <img + alt="" + className="aspect-[1.91/1] w-20 rounded-md object-cover" + decoding="async" + loading="lazy" + src={coverUrl} + /> + <Detail>{t("cover_cropped")}</Detail> + </div> + ) : null} + {missing(coverCheck)} + </AuditRow> + + <AuditRow label={t("rows.alt")} ok={altCheck?.ok ?? true}> + {missing(altCheck)} + <CheckActions + actions={[ + { label: t("alt_edit"), run: onEditAlt }, + ...(altCheck && !altCheck.ok ? actionsFor(altCheck) : []), + ]} + /> + </AuditRow> + + {translationChecks.map(check => ( + <AuditRow + key={check.id} + label={tPublish(`checks.${check.id}`)} + ok={check.ok} + > + {missing(check)} + {check.ok ? null : <CheckActions actions={actionsFor(check)} />} + </AuditRow> + ))} + </ul> + </div> + ); +}; diff --git a/plugins/blog/src/views/admin/article/editor/translate.tsx b/plugins/blog/src/views/admin/article/editor/translate.tsx index 6709246bdc..fc572b7b1d 100644 --- a/plugins/blog/src/views/admin/article/editor/translate.tsx +++ b/plugins/blog/src/views/admin/article/editor/translate.tsx @@ -7,6 +7,7 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from "@vitnode/core/components/ui/dropdown-menu"; +import { ContentLiveLanguagePresence } from "@vitnode/core/content/admin-form"; import { cn } from "cn"; import { ArrowRightIcon, @@ -77,6 +78,7 @@ export const TranslateMenu = ({ > <StatusDot status={status} /> <span className="flex-1">{language.name}</span> + <ContentLiveLanguagePresence locale={language.code} /> <span className="text-muted-foreground text-xs"> {status === "missing" ? t("missing", { count: language.missing }) diff --git a/plugins/blog/src/views/admin/article/form-layout.tsx b/plugins/blog/src/views/admin/article/form-layout.tsx index 80df747939..033b79b42a 100644 --- a/plugins/blog/src/views/admin/article/form-layout.tsx +++ b/plugins/blog/src/views/admin/article/form-layout.tsx @@ -1,45 +1,97 @@ import type { ContentFormLayoutProps } from "@vitnode/core/lib/plugin"; import { Skeleton } from "@vitnode/core/components/ui/skeleton"; -import { - ContentFormField, - useContentForm, -} from "@vitnode/core/content/admin-form"; +import { useContentForm } from "@vitnode/core/content/admin-form"; import React from "react"; +const loadArticleEditor = async () => await import("./editor/article-editor"); + const ArticleEditor = React.lazy(async () => - import("./editor/article-editor").then(module => ({ - default: module.ArticleEditor, - })), + loadArticleEditor().then(module => ({ default: module.ArticleEditor })), +); + +const FieldSkeleton = ({ + label, + control, +}: { + control: string; + label: string; +}) => ( + <div className="flex flex-col gap-2"> + <Skeleton className={label} /> + <Skeleton className={control} /> + </div> ); const ArticleEditorSkeleton = () => { - const { markHeaderRendered } = useContentForm(); + const { fieldNames, markHeaderRendered, markRendered, mode, skeleton } = + useContentForm(); + const creating = mode === "create"; markHeaderRendered?.(); + if (!skeleton) for (const name of fieldNames) markRendered?.(name); + + React.useEffect(() => { + void loadArticleEditor(); + }, []); return ( - <div aria-busy="true" className="-m-6 flex flex-col"> - <div className="flex h-14 items-center gap-2 border-b px-4 sm:px-6"> - <Skeleton className="h-8 w-24" /> + <div aria-busy="true" className="-m-6 flex min-h-full flex-col"> + <div className="flex min-h-14 flex-wrap items-center gap-2 border-b px-4 py-2 sm:px-6"> + <Skeleton className="h-8 w-9 sm:w-36" /> <div className="flex-1" /> - <Skeleton className="h-8 w-40" /> - <Skeleton className="h-9 w-32" /> + <div className="flex items-center gap-2 max-sm:order-last max-sm:-mx-4 max-sm:grow max-sm:basis-full max-sm:border-t max-sm:px-4 max-sm:pt-2"> + <Skeleton className="h-8 w-9 sm:w-28" /> + <Skeleton className="h-8 w-14 sm:w-20" /> + <Skeleton className="h-8 w-9 sm:w-28" /> + {creating ? null : <Skeleton className="h-9 w-44 max-sm:ms-auto" />} + </div> + {creating ? ( + <> + <Skeleton className="h-9 w-32" /> + <Skeleton className="h-9 w-28" /> + </> + ) : ( + <Skeleton className="h-9 w-36" /> + )} </div> - <div className="grid grid-cols-1 items-start gap-8 px-4 py-8 sm:px-6 xl:grid-cols-[minmax(0,1fr)_22rem]"> - <div className="mx-auto flex w-full max-w-3xl flex-col gap-6"> - <ContentFormField name="title" /> - <ContentFormField name="friendlyUrl" /> - <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> - <ContentFormField name="categoryId" /> - <ContentFormField name="authorId" /> + + <div className="px-4 py-8 sm:px-6"> + <div className="mx-auto flex w-full max-w-3xl min-w-0 flex-col gap-6"> + <div className="flex flex-col gap-4"> + {creating ? ( + <div className="flex flex-col gap-2"> + <Skeleton className="h-4 w-28" /> + <Skeleton className="h-8 w-48" /> + <Skeleton className="h-36 w-full rounded-xl" /> + </div> + ) : ( + <Skeleton className="aspect-2/1 w-full rounded-xl" /> + )} + <FieldSkeleton control="h-9 w-full" label="h-4 w-36" /> + </div> + <div className="flex flex-col gap-3"> + <Skeleton className="h-9 w-3/4 sm:h-10" /> + <Skeleton className="h-5 w-40" /> + </div> + <div className="grid grid-cols-1 gap-4 border-y py-4 md:grid-cols-2"> + <FieldSkeleton control="h-11 w-full" label="h-4 w-24" /> + <FieldSkeleton control="h-11 w-full" label="h-4 w-20" /> + </div> + <div className="flex flex-col gap-2"> + <Skeleton className="h-4 w-16" /> + <div className="flex flex-col rounded-lg border"> + <div className="flex items-center gap-2 border-b p-2"> + <Skeleton className="h-7 w-28" /> + <Skeleton className="h-7 w-48" /> + </div> + <div className="flex min-h-96 flex-col gap-3 p-5"> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-11/12" /> + <Skeleton className="h-4 w-4/5" /> + </div> + </div> </div> - <ContentFormField name="content" /> - </div> - <div className="flex flex-col gap-6"> - <ContentFormField name="excerpt" /> - <ContentFormField name="coverImage" /> - <ContentFormField name="coverImageAlt" /> </div> </div> </div> diff --git a/plugins/example/src/content/advanced-article.ts b/plugins/example/src/content/advanced-article.ts index a797f70c06..2eb312874e 100644 --- a/plugins/example/src/content/advanced-article.ts +++ b/plugins/example/src/content/advanced-article.ts @@ -7,18 +7,17 @@ export const advancedArticleContentType = defineContentType({ tableName: "example_advanced_articles", localization: { - enabled: true, defaultLocale: "en", fallback: "default", }, - publication: { enabled: true }, + publication: true, editorial: { - enabled: true, revisions: { retention: 20 }, - preview: { enabled: true, expiresInMinutes: 30 }, + preview: { expiresInMinutes: 30 }, }, + liveEditing: true, fields: { title: field.text({ @@ -88,7 +87,6 @@ export const advancedArticleContentType = defineContentType({ }, publicApi: { - enabled: true, path: "advanced-articles", fields: [ // Exposed because Stage 8 needs it: alternates and `hreflang` are resolved by @@ -117,7 +115,6 @@ export const advancedArticleContentType = defineContentType({ }, search: { - enabled: true, titleField: "title", descriptionField: "seo.description", contentFields: ["title", "seo.description", "faq.question", "faq.answer"], @@ -125,8 +122,7 @@ export const advancedArticleContentType = defineContentType({ }, delivery: { - enabled: true, - redirects: { enabled: true }, + redirects: true, hreflang: { xDefault: "defaultLocale" }, seo: { titleField: "seo.title", @@ -138,7 +134,7 @@ export const advancedArticleContentType = defineContentType({ descriptionField: "seo.description", }, }, - sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, + sitemap: { changeFrequency: "weekly", priority: 0.7 }, }, // Leaf paths, materialised against the generated columns: this compiles to an diff --git a/plugins/example/src/content/article.ts b/plugins/example/src/content/article.ts index 939877c914..bd73421b05 100644 --- a/plugins/example/src/content/article.ts +++ b/plugins/example/src/content/article.ts @@ -49,10 +49,9 @@ export const articleContentType = defineContentType({ }), }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, path: "articles", fields: [ "title", @@ -82,7 +81,6 @@ export const articleContentType = defineContentType({ }, search: { - enabled: true, titleField: "title", descriptionField: "excerpt", contentFields: ["title", "excerpt"], @@ -91,8 +89,7 @@ export const articleContentType = defineContentType({ }, delivery: { - enabled: true, - redirects: { enabled: true }, + redirects: true, seo: { titleField: "title", descriptionField: "excerpt", @@ -106,15 +103,15 @@ export const articleContentType = defineContentType({ // so in two lines rather than four. openGraph: { titleField: "title", descriptionField: "excerpt" }, }, - sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, + sitemap: { changeFrequency: "weekly", priority: 0.7 }, }, editorial: { - enabled: true, revisions: { retention: 20 }, - preview: { enabled: true, expiresInMinutes: 30 }, - scheduling: { enabled: true }, + preview: { expiresInMinutes: 30 }, + scheduling: true, }, + liveEditing: true, indexes: [{ on: ["status", "createdAt"] }], diff --git a/plugins/example/src/content/localized-article.ts b/plugins/example/src/content/localized-article.ts index 5ed1d0f5a9..c14402fe66 100644 --- a/plugins/example/src/content/localized-article.ts +++ b/plugins/example/src/content/localized-article.ts @@ -5,7 +5,6 @@ export const localizedArticleContentType = defineContentType({ tableName: "example_localized_articles", localization: { - enabled: true, // `en` has to exist in `core_languages`, which the boot guard checks once per // process. The Postgres suite inserts it (and `pl`) itself - nothing seeds // languages, they are created by the installer. @@ -19,10 +18,9 @@ export const localizedArticleContentType = defineContentType({ // The global lifecycle every translation's own status is subordinate to: // publishing the English copy of a draft article puts nothing on the internet. - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, path: "localized-articles", fields: ["title", "slug", "body", "featured", "publishedAt"], searchableFields: ["title", "body"], @@ -33,7 +31,6 @@ export const localizedArticleContentType = defineContentType({ }, search: { - enabled: true, titleField: "title", contentFields: ["title", "body"], pathTemplate: "/localized-articles/{slug}", @@ -44,10 +41,10 @@ export const localizedArticleContentType = defineContentType({ // `preview` mints a link per language, freezing the shared revision and that // locale's translation revision together. editorial: { - enabled: true, - preview: { enabled: true, expiresInMinutes: 30 }, + preview: { expiresInMinutes: 30 }, revisions: { retention: 20 }, }, + liveEditing: true, fields: { title: field.text({ diff --git a/plugins/example/src/content/page.ts b/plugins/example/src/content/page.ts index cc6e18ce3b..a7346a557c 100644 --- a/plugins/example/src/content/page.ts +++ b/plugins/example/src/content/page.ts @@ -16,10 +16,9 @@ export const pageContentType = defineContentType({ }), }, - publication: { enabled: true }, + publication: true, publicApi: { - enabled: true, path: "pages", fields: ["title", "slug", "content", "publishedAt"], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f4d035b57..ae617b8348 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -561,12 +561,18 @@ importers: '@tiptap/extension-audio': specifier: ^3.31.4 version: 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4)) + '@tiptap/extension-collaboration': + specifier: ^3.31.4 + version: 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33) + '@tiptap/extension-collaboration-caret': + specifier: ^3.31.4 + version: 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)) '@tiptap/extension-drag-handle': specifier: ^3.31.4 - version: 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)) + version: 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)) '@tiptap/extension-drag-handle-react': specifier: ^3.31.4 - version: 3.31.4(@tiptap/extension-drag-handle@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)))(@tiptap/pm@3.31.4)(@tiptap/react@3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 3.31.4(@tiptap/extension-drag-handle@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)))(@tiptap/pm@3.31.4)(@tiptap/react@3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tiptap/extension-emoji': specifier: ^3.31.4 version: 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/suggestion@3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(emojibase@17.0.0) @@ -600,6 +606,9 @@ importers: '@tiptap/suggestion': specifier: ^3.31.4 version: 3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4) + '@tiptap/y-tiptap': + specifier: ^3.0.9 + version: 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) ai: specifier: ^7.0.134 version: 7.0.134(zod@4.6.5) @@ -678,6 +687,12 @@ importers: use-intl: specifier: ^4.14.9 version: 4.14.9(@types/react@19.3.0)(react@19.3.0) + y-protocols: + specifier: ^1.0.7 + version: 1.0.7(yjs@13.6.33) + yjs: + specifier: ^13.6.33 + version: 13.6.33 devDependencies: '@hono/zod-openapi': specifier: ^1.6.3 @@ -795,7 +810,7 @@ importers: version: 1.6.3(hono@4.13.13)(zod@4.6.5) '@tanstack/react-form': specifier: ^1.33.5 - version: 1.33.5(@tanstack/react-start@1.168.60(crossws@0.4.12(srvx@1.0.5))(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.33.5(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tanstack/react-router': specifier: ^1.170.41 version: 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -838,7 +853,10 @@ importers: devDependencies: '@react-email/ui': specifier: ^6.11.1 - version: 6.11.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 6.11.1(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@types/react': specifier: ^19.3.0 version: 19.3.0 @@ -4505,6 +4523,13 @@ packages: peerDependencies: '@tiptap/core': 3.31.4 + '@tiptap/extension-collaboration-caret@3.31.4': + resolution: {integrity: sha512-1dHvh2VDxRcNehsfPGpahP9RmFcoJIecoSuhOUjDfCP0QPxuGql14mYbflYYm1arj7Z6SZLNt/2tL5Zh0fpyqQ==} + peerDependencies: + '@tiptap/core': 3.31.4 + '@tiptap/pm': 3.31.4 + '@tiptap/y-tiptap': ^3.0.7 + '@tiptap/extension-collaboration@3.31.4': resolution: {integrity: sha512-vIGuBU2oV1jIr0zGPdWc1qFo4NrzObktC06s8Z0zYCb59hm3lfnYzw0w4R2i/BYjjoKRgpiDVYweGAQHuLT7Vw==} peerDependencies: @@ -9151,8 +9176,8 @@ packages: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} - yjs@13.6.32: - resolution: {integrity: sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==} + yjs@13.6.33: + resolution: {integrity: sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} yocto-queue@0.1.0: @@ -11359,6 +11384,22 @@ snapshots: - react-dom - sass + '@react-email/ui@6.11.1(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + esbuild: 0.28.2 + jiti: 2.6.1 + next: 16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + transitivePeerDependencies: + - '@babel/core' + - '@opentelemetry/api' + - '@playwright/test' + - '@types/node' + - babel-plugin-macros + - babel-plugin-react-compiler + - react + - react-dom + - sass + '@react-grab/cli@0.2.0': dependencies: agent-install: 0.0.6 @@ -12053,6 +12094,14 @@ snapshots: transitivePeerDependencies: - react-dom + '@tanstack/react-form@1.33.5(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tanstack/form-core': 1.33.5 + '@tanstack/react-store': 0.11.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + transitivePeerDependencies: + - react-dom + '@tanstack/react-query-devtools@5.104.1(@tanstack/react-query@5.104.1(react@19.3.0))(@types/react@19.3.0)(csstype@3.2.3)(react@19.3.0)': dependencies: '@tanstack/query-devtools': 5.104.1(csstype@3.2.3) @@ -12390,33 +12439,39 @@ snapshots: dependencies: '@tiptap/core': 3.31.4(@tiptap/pm@3.31.4) - '@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32)': + '@tiptap/extension-collaboration-caret@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))': dependencies: '@tiptap/core': 3.31.4(@tiptap/pm@3.31.4) '@tiptap/pm': 3.31.4 - '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32) - yjs: 13.6.32 + '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) + + '@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33)': + dependencies: + '@tiptap/core': 3.31.4(@tiptap/pm@3.31.4) + '@tiptap/pm': 3.31.4 + '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) + yjs: 13.6.33 '@tiptap/extension-document@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))': dependencies: '@tiptap/core': 3.31.4(@tiptap/pm@3.31.4) - '@tiptap/extension-drag-handle-react@3.31.4(@tiptap/extension-drag-handle@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)))(@tiptap/pm@3.31.4)(@tiptap/react@3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@tiptap/extension-drag-handle-react@3.31.4(@tiptap/extension-drag-handle@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)))(@tiptap/pm@3.31.4)(@tiptap/react@3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@tiptap/extension-drag-handle': 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)) + '@tiptap/extension-drag-handle': 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)) '@tiptap/pm': 3.31.4 '@tiptap/react': 3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - '@tiptap/extension-drag-handle@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))': + '@tiptap/extension-drag-handle@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))': dependencies: '@floating-ui/dom': 1.8.0 '@tiptap/core': 3.31.4(@tiptap/pm@3.31.4) - '@tiptap/extension-collaboration': 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32) + '@tiptap/extension-collaboration': 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33) '@tiptap/extension-node-range': 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4) '@tiptap/pm': 3.31.4 - '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32) + '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) '@tiptap/extension-dropcursor@3.31.4(@tiptap/extensions@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))': dependencies: @@ -12593,15 +12648,15 @@ snapshots: '@tiptap/core': 3.31.4(@tiptap/pm@3.31.4) '@tiptap/pm': 3.31.4 - '@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)': + '@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)': dependencies: lib0: 0.2.119 prosemirror-model: 1.25.12 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.1 prosemirror-view: 1.42.6 - y-protocols: 1.0.7(yjs@13.6.32) - yjs: 13.6.32 + y-protocols: 1.0.7(yjs@13.6.33) + yjs: 13.6.33 '@ts-morph/common@0.27.0': dependencies: @@ -15494,6 +15549,34 @@ snapshots: - '@types/node' - babel-plugin-macros + next@16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + '@next/env': 16.3.8 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.28 + caniuse-lite: 1.0.30001815 + postcss: 8.5.23 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + styled-jsx: 5.1.6(react@19.3.0) + optionalDependencies: + '@next/swc-darwin-arm64': 16.3.8 + '@next/swc-darwin-x64': 16.3.8 + '@next/swc-linux-arm64-gnu': 16.3.8 + '@next/swc-linux-arm64-musl': 16.3.8 + '@next/swc-linux-x64-gnu': 16.3.8 + '@next/swc-linux-x64-musl': 16.3.8 + '@next/swc-win32-arm64-msvc': 16.3.8 + '@next/swc-win32-x64-msvc': 16.3.8 + '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.61.1 + babel-plugin-react-compiler: 1.0.0 + sharp: 0.35.5(@types/node@26.6.4) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + nf3@0.3.24: {} nitro@3.0.260903-beta: @@ -16836,6 +16919,11 @@ snapshots: optionalDependencies: '@babel/core': 7.29.7 + styled-jsx@5.1.6(react@19.3.0): + dependencies: + client-only: 0.0.1 + react: 19.3.0 + superjson@2.2.6: dependencies: copy-anything: 4.1.5 @@ -17258,10 +17346,10 @@ snapshots: xmlchars@2.2.0: {} - y-protocols@1.0.7(yjs@13.6.32): + y-protocols@1.0.7(yjs@13.6.33): dependencies: lib0: 0.2.119 - yjs: 13.6.32 + yjs: 13.6.33 y18n@5.0.8: {} @@ -17281,7 +17369,7 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yjs@13.6.32: + yjs@13.6.33: dependencies: lib0: 0.2.119