- {item.summary} -
- ) : null} -shared editor
+ ) : ( +local editor
+ ); + +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 => ( + + ))} + + > + ); +}; + +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 = , + 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+ {lock ? ( + <> + {lock.user.name} {t("is_editing")}. {t("locked_desc")} + > + ) : null} +
+ ); +}; + +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
+ {saving ? (
+
+
Cześć
\n"), + model: modelAnswering("Cześć
\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: "Hello
", to: "pl", }); - expect(await response.json()).toEqual({ text: "Cześć
" }); + expect(await response.json()).toEqual({ + text: "Cześć
", + }); expect(promptOf(model.doGenerateCalls[0])).toContain("Keep every tag"); + expect(promptOf(model.doGenerateCalls[0])).toContain( + "Hello
", + ); + }); + + 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: "We rebuilt the AdminCP.
", + 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(""); + 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: "
Body
", + 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: 'Hello link
', to: "pl", }); @@ -268,7 +337,7 @@ describe("blog AI admin routes", () => { }); const response = await post(app, "/excerpt", { - content: "Body
", + 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: "Body
", 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: "Body
", 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} -- {review.summary} +
+ {t("before")}: + {fix.quote}
- {review.suggestions.length === 0 ? ( -{t("nothing")}
+ {fix.replacement ? ( ++ {t("after")}: + {fix.replacement} +
) : ( -- {suggestion.message} -
-{t("removes")}
)}+ {suggestion.message} +
+ {state === "open" && fix ? ( + <> ++ {t("fix.stale")} +
+ )} + > + ) : null} +- {t("disclaimer")} -
-
- {error}
+ {pending ?
+ {review.suggestions.length === 0 + ? t("nothing") + : t("verdict", { + high, + low: review.suggestions.length - high, + })}
- ) : null} - {review ?+ {review.summary} +
++ {t("intro")} +
+ )} + {error ? ( ++ {error} +
+ ) : null} + {review && review.suggestions.length > 0 ? ( ++ {getLangValue(values.excerpt, source)} +
+ ) : null} +- {t("fallback", { - language: - languages.find(language => language.code === locale)?.name ?? - locale, - source: sourceName, - })} -
- ) : null} - -Hello world
", pl: "" }), + content: lang+ {children} +
+); + +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 ?+ {t("left", { items: left.map(row => row.label).join(", ") })} +
+ ) : null} ++ {title} +
+