@riducms/plugin-seo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Haniel Ubogu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # `@riducms/plugin-seo`
2
+
3
+ The Svelte admin UI for Ridu's Go SEO plugin. It provides a metadata overview, title and description
4
+ guidance, upload selection, generator controls, and a live search-result preview.
5
+
6
+ For an existing project, install it in the root and admin workspaces, configure `seo.New(...)`, then
7
+ start development:
8
+
9
+ ```sh
10
+ npm install @riducms/plugin-seo
11
+ npm install --workspace admin @riducms/plugin-seo
12
+ npm run dev
13
+ ```
14
+
15
+ `ridu dev` regenerates contracts and synchronizes safe additive development changes. Before
16
+ deployment, create and review the immutable adapter migration with
17
+ `npm run ridu -- migrate create --name add-seo`.
18
+
19
+ The generated registry imports `seoAdminPlugin`; do not register it again. Generated title,
20
+ description, image, and URL values remain editable and pass through normal validation and access
21
+ rules when saved.
22
+
23
+ See the [SEO guide](../../website/src/content/docs/seo.md) for setup, callbacks, migrations, and
24
+ verification.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "bugs": {
3
+ "url": "https://github.com/riducms/ridu/issues"
4
+ },
5
+ "dependencies": {
6
+ "@riducms/plugin": "0.1.0",
7
+ "@riducms/protocol": "0.1.0",
8
+ "@riducms/ui": "0.1.0",
9
+ "svelte": "^5.56.8"
10
+ },
11
+ "description": "Search metadata authoring for Ridu's official SEO plugin.",
12
+ "devDependencies": {
13
+ "@riducms/build": "0.1.0"
14
+ },
15
+ "exports": {
16
+ ".": "./src/index.ts"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "LICENSE"
21
+ ],
22
+ "homepage": "https://riducms.com",
23
+ "license": "MIT",
24
+ "name": "@riducms/plugin-seo",
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "provenance": true
28
+ },
29
+ "repository": {
30
+ "directory": "packages/plugin-seo",
31
+ "type": "git",
32
+ "url": "git+https://github.com/riducms/ridu.git"
33
+ },
34
+ "scripts": {
35
+ "build": "vite build",
36
+ "check": "svelte-check --tsconfig ./tsconfig.json",
37
+ "test": "bun test"
38
+ },
39
+ "type": "module",
40
+ "version": "0.1.0"
41
+ }
@@ -0,0 +1,84 @@
1
+ import type { FieldAuthoringHost, FieldForm } from "@riducms/plugin";
2
+
3
+ import { generationScopeToken, generationSnapshotToken } from "@plugin-seo/generation-snapshot";
4
+
5
+ interface GenerationResponse {
6
+ result: string;
7
+ }
8
+
9
+ export class GenerationController {
10
+ status = $state<"idle" | "pending" | "error">("idle");
11
+ #generation = 0;
12
+ #request: AbortController | undefined;
13
+
14
+ async run(
15
+ authoring: FieldAuthoringHost | undefined,
16
+ form: FieldForm,
17
+ path: string
18
+ ): Promise<string | undefined> {
19
+ const requestPlugin = authoring?.requestPlugin;
20
+ const snapshot = form.snapshot;
21
+ const resource = form.resource;
22
+ if (requestPlugin === undefined || snapshot === undefined || resource === undefined) {
23
+ this.status = "error";
24
+ return undefined;
25
+ }
26
+ this.#request?.abort();
27
+ const request = new AbortController();
28
+ this.#request = request;
29
+ const generation = ++this.#generation;
30
+ this.status = "pending";
31
+ const document = snapshot.call(form);
32
+ const documentRevision = generationSnapshotToken(document);
33
+ const scope = generationScopeToken(resource, form.contentLocale);
34
+ try {
35
+ const response = await requestPlugin<unknown>(
36
+ path,
37
+ {
38
+ ...(resource.global
39
+ ? { global: resource.collection }
40
+ : { collection: resource.collection }),
41
+ id: resource.id,
42
+ locale: form.contentLocale,
43
+ document,
44
+ },
45
+ request.signal
46
+ );
47
+ if (
48
+ generation !== this.#generation ||
49
+ request.signal.aborted ||
50
+ generationSnapshotToken(snapshot.call(form)) !== documentRevision ||
51
+ generationScopeToken(form.resource, form.contentLocale) !== scope
52
+ ) {
53
+ if (generation === this.#generation) this.status = "idle";
54
+ return undefined;
55
+ }
56
+ if (!isGenerationResponse(response))
57
+ throw new Error("SEO generation returned an invalid response");
58
+ this.status = "idle";
59
+ return response.result;
60
+ } catch {
61
+ if (generation !== this.#generation || request.signal.aborted) return undefined;
62
+ this.status = "error";
63
+ return undefined;
64
+ } finally {
65
+ if (this.#request === request) this.#request = undefined;
66
+ }
67
+ }
68
+
69
+ cancel() {
70
+ this.#generation += 1;
71
+ this.#request?.abort();
72
+ this.#request = undefined;
73
+ if (this.status === "pending") this.status = "idle";
74
+ }
75
+ }
76
+
77
+ function isGenerationResponse(value: unknown): value is GenerationResponse {
78
+ return (
79
+ typeof value === "object" &&
80
+ value !== null &&
81
+ "result" in value &&
82
+ typeof value.result === "string"
83
+ );
84
+ }
@@ -0,0 +1,17 @@
1
+ import type { FieldFormResource } from "@riducms/plugin";
2
+
3
+ export function generationSnapshotToken(document: Readonly<Record<string, unknown>>): string {
4
+ return JSON.stringify(document);
5
+ }
6
+
7
+ export function generationScopeToken(
8
+ resource: FieldFormResource | undefined,
9
+ locale: string | undefined
10
+ ): string {
11
+ return JSON.stringify({
12
+ collection: resource?.collection ?? "",
13
+ global: resource?.global === true,
14
+ id: resource?.id ?? "",
15
+ locale: locale ?? "",
16
+ });
17
+ }
package/src/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { ADMIN_PLUGIN_API_VERSION, defineAdminPlugin, defineFieldPlugin } from "@riducms/plugin";
2
+
3
+ import { seoMessages } from "@plugin-seo/messages";
4
+
5
+ import MetaDescriptionField from "@plugin-seo/meta-description-field.svelte";
6
+ import MetaImageField from "@plugin-seo/meta-image-field.svelte";
7
+ import MetaTitleField from "@plugin-seo/meta-title-field.svelte";
8
+ import OverviewField from "@plugin-seo/overview-field.svelte";
9
+ import PreviewField from "@plugin-seo/preview-field.svelte";
10
+
11
+ export const seoFieldPlugins = [
12
+ defineFieldPlugin({
13
+ type: "ui",
14
+ key: "seo",
15
+ componentKey: "overview",
16
+ component: OverviewField,
17
+ canRender: (field) => field.admin.component?.component === "overview",
18
+ }),
19
+ defineFieldPlugin({
20
+ type: "text",
21
+ key: "seo",
22
+ componentKey: "title",
23
+ component: MetaTitleField,
24
+ canRender: (field) => field.admin.component?.component === "title",
25
+ }),
26
+ defineFieldPlugin({
27
+ type: "textarea",
28
+ key: "seo",
29
+ componentKey: "description",
30
+ component: MetaDescriptionField,
31
+ canRender: (field) => field.admin.component?.component === "description",
32
+ }),
33
+ defineFieldPlugin({
34
+ type: "upload",
35
+ key: "seo",
36
+ componentKey: "image",
37
+ component: MetaImageField,
38
+ canRender: (field) => field.admin.component?.component === "image",
39
+ }),
40
+ defineFieldPlugin({
41
+ type: "ui",
42
+ key: "seo",
43
+ componentKey: "preview",
44
+ component: PreviewField,
45
+ canRender: (field) => field.admin.component?.component === "preview",
46
+ }),
47
+ ] as const;
48
+
49
+ export const seoAdminPlugin = defineAdminPlugin({
50
+ apiVersion: ADMIN_PLUGIN_API_VERSION,
51
+ key: "seo",
52
+ pairingVersion: 1,
53
+ fields: seoFieldPlugins,
54
+ messages: seoMessages,
55
+ });
56
+
57
+ export { lengthState, type LengthState, type LengthStatus } from "@plugin-seo/length-indicator";
58
+ export { generationScopeToken, generationSnapshotToken } from "@plugin-seo/generation-snapshot";
59
+ export { seoMessages } from "@plugin-seo/messages";
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ import type { AdminI18n } from "@riducms/plugin";
3
+
4
+ import { lengthState } from "@plugin-seo/length-indicator";
5
+
6
+ let {
7
+ text,
8
+ minLength,
9
+ maxLength,
10
+ i18n,
11
+ }: { text: string; minLength: number; maxLength: number; i18n: AdminI18n } = $props();
12
+ const state = $derived(lengthState(text, minLength, maxLength));
13
+ const tone = $derived(
14
+ state.status === "good" ? "success" : state.status === "almostThere" ? "warning" : "danger"
15
+ );
16
+ const suffix = $derived(
17
+ state.status === "missing" || state.status === "tooShort" || state.status === "almostThere"
18
+ ? i18n.t("plugin.seo:charactersToGo", { characters: state.remaining })
19
+ : state.status === "tooLong"
20
+ ? i18n.t("plugin.seo:charactersTooMany", { characters: state.remaining })
21
+ : i18n.t("plugin.seo:charactersLeft", { characters: state.remaining })
22
+ );
23
+ </script>
24
+
25
+ <div class="flex min-w-0 items-center gap-2.5" data-length-status={state.status}>
26
+ <span
27
+ class={[
28
+ "shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide",
29
+ tone === "success" && "bg-success/12 text-success",
30
+ tone === "warning" && "bg-warning/12 text-warning",
31
+ tone === "danger" && "bg-destructive/10 text-destructive",
32
+ ]}
33
+ >
34
+ {i18n.t(`plugin.seo:${state.status}`)}
35
+ </span>
36
+ <small class="shrink-0 text-[11px] text-foreground-muted">
37
+ {i18n.t("plugin.seo:characterCount", {
38
+ current: state.length,
39
+ minLength,
40
+ maxLength,
41
+ })}{suffix}
42
+ </small>
43
+ <span
44
+ class="h-0.5 min-w-8 flex-1 overflow-hidden rounded-full bg-control-border"
45
+ aria-hidden="true"
46
+ >
47
+ <span
48
+ class={[
49
+ "block h-full origin-left transition-transform duration-150 motion-reduce:transition-none",
50
+ tone === "success" && "bg-success",
51
+ tone === "warning" && "bg-warning",
52
+ tone === "danger" && "bg-destructive",
53
+ ]}
54
+ style:transform="scaleX({state.progress})"
55
+ ></span>
56
+ </span>
57
+ </div>
@@ -0,0 +1,32 @@
1
+ export type LengthStatus = "missing" | "tooShort" | "almostThere" | "good" | "tooLong";
2
+
3
+ export interface LengthState {
4
+ status: LengthStatus;
5
+ length: number;
6
+ progress: number;
7
+ remaining: number;
8
+ }
9
+
10
+ export function lengthState(text: string, minLength: number, maxLength: number): LengthState {
11
+ const length = text.length;
12
+ if (length === 0) return { status: "missing", length, progress: 0, remaining: minLength };
13
+ if (length < minLength) {
14
+ const progress = Math.min(1, length / minLength);
15
+ return {
16
+ status: progress > 0.9 ? "almostThere" : "tooShort",
17
+ length,
18
+ progress,
19
+ remaining: minLength - length,
20
+ };
21
+ }
22
+ if (length <= maxLength) {
23
+ const range = Math.max(1, maxLength - minLength);
24
+ return {
25
+ status: "good",
26
+ length,
27
+ progress: Math.min(1, (length - minLength) / range),
28
+ remaining: maxLength - length,
29
+ };
30
+ }
31
+ return { status: "tooLong", length, progress: 1, remaining: length - maxLength };
32
+ }
@@ -0,0 +1,96 @@
1
+ import { defineAdminMessages } from "@riducms/plugin";
2
+
3
+ export const seoMessages = defineAdminMessages({
4
+ fallback: {
5
+ almostThere: "Almost there",
6
+ autoGenerate: "Auto-generate",
7
+ bestPractices: "Best practices",
8
+ cancelled: "Generation was cancelled.",
9
+ characterCount: "{current}/{minLength}-{maxLength} chars",
10
+ charactersLeft: ", {characters} left over",
11
+ charactersToGo: ", {characters} to go",
12
+ charactersTooMany: ", {characters} too many",
13
+ checksPassing: "{current}/{max} checks are passing",
14
+ generationFailed: "Could not generate this value. Try again.",
15
+ good: "Good",
16
+ image: "Image",
17
+ imageAutoGenerationTip: "Generate an image from the current document, or choose one manually.",
18
+ imageUnavailable: "Referenced image is unavailable.",
19
+ inspectImage: "Inspect image",
20
+ lengthTipDescription: "Aim for {minLength}–{maxLength} characters. ",
21
+ lengthTipTitle: "Aim for {minLength}–{maxLength} characters. ",
22
+ missing: "Missing",
23
+ noImage: "No image",
24
+ preview: "Preview",
25
+ previewDescription: "A representative search result using the current unsaved values.",
26
+ removeImage: "Remove image",
27
+ replaceImage: "Replace image",
28
+ selectImage: "Select image",
29
+ tooLong: "Too long",
30
+ tooShort: "Too short",
31
+ title: "Title",
32
+ description: "Description",
33
+ },
34
+ translations: {
35
+ fr: {
36
+ almostThere: "Presque terminé",
37
+ autoGenerate: "Générer automatiquement",
38
+ bestPractices: "Bonnes pratiques",
39
+ cancelled: "La génération a été annulée.",
40
+ characterCount: "{current}/{minLength}-{maxLength} caractères",
41
+ charactersLeft: ", {characters} restants",
42
+ charactersToGo: ", encore {characters}",
43
+ charactersTooMany: ", {characters} en trop",
44
+ checksPassing: "{current}/{max} vérifications réussies",
45
+ generationFailed: "Impossible de générer cette valeur. Réessayez.",
46
+ good: "Bon",
47
+ image: "Image",
48
+ imageAutoGenerationTip: "Générez une image depuis le document ou choisissez-en une.",
49
+ imageUnavailable: "L’image référencée n’est pas disponible.",
50
+ inspectImage: "Inspecter l’image",
51
+ lengthTipDescription: "Visez {minLength} à {maxLength} caractères. ",
52
+ lengthTipTitle: "Visez {minLength} à {maxLength} caractères. ",
53
+ missing: "Manquant",
54
+ noImage: "Aucune image",
55
+ preview: "Aperçu",
56
+ previewDescription: "Résultat de recherche représentatif avec les valeurs non enregistrées.",
57
+ removeImage: "Supprimer l’image",
58
+ replaceImage: "Remplacer l’image",
59
+ selectImage: "Sélectionner une image",
60
+ tooLong: "Trop long",
61
+ tooShort: "Trop court",
62
+ title: "Titre",
63
+ description: "Description",
64
+ },
65
+ ar: {
66
+ almostThere: "أوشكت على الانتهاء",
67
+ autoGenerate: "إنشاء تلقائي",
68
+ bestPractices: "أفضل الممارسات",
69
+ cancelled: "تم إلغاء الإنشاء.",
70
+ characterCount: "{current}/{minLength}-{maxLength} حرفًا",
71
+ charactersLeft: "، متبقٍ {characters}",
72
+ charactersToGo: "، يلزم {characters}",
73
+ charactersTooMany: "، زائد {characters}",
74
+ checksPassing: "تم اجتياز {current}/{max} من الفحوصات",
75
+ description: "الوصف",
76
+ generationFailed: "تعذر إنشاء هذه القيمة. حاول مرة أخرى.",
77
+ good: "جيد",
78
+ image: "الصورة",
79
+ imageAutoGenerationTip: "أنشئ صورة من المستند الحالي أو اختر واحدة يدويًا.",
80
+ imageUnavailable: "الصورة المشار إليها غير متاحة.",
81
+ inspectImage: "فحص الصورة",
82
+ lengthTipDescription: "استهدف {minLength}–{maxLength} حرفًا. ",
83
+ lengthTipTitle: "استهدف {minLength}–{maxLength} حرفًا. ",
84
+ missing: "مفقود",
85
+ noImage: "لا توجد صورة",
86
+ preview: "معاينة",
87
+ previewDescription: "نتيجة بحث تمثيلية باستخدام القيم الحالية غير المحفوظة.",
88
+ removeImage: "إزالة الصورة",
89
+ replaceImage: "استبدال الصورة",
90
+ selectImage: "اختيار صورة",
91
+ title: "العنوان",
92
+ tooLong: "طويل جدًا",
93
+ tooShort: "قصير جدًا",
94
+ },
95
+ },
96
+ });
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ import type { FieldComponentProps } from "@riducms/plugin";
3
+ import { Button, FieldFrame, Textarea } from "@riducms/ui";
4
+
5
+ import { GenerationController } from "@plugin-seo/generation-controller.svelte";
6
+ import LengthIndicator from "@plugin-seo/length-indicator.svelte";
7
+ import { lengthConfig } from "@plugin-seo/seo-config";
8
+
9
+ let { field, form, i18n, authoring }: FieldComponentProps = $props();
10
+ const config = $derived(lengthConfig(field));
11
+ const minLength = $derived(field.textarea?.minLength ?? config.minLength);
12
+ const maxLength = $derived(field.textarea?.maxLength ?? config.maxLength);
13
+ const value = $derived(String(form.get(field.path) ?? ""));
14
+ const issues = $derived(form.issuesFor(field.path));
15
+ const hasMessage = $derived(issues.length > 0 || field.admin.description !== undefined);
16
+ const generation = new GenerationController();
17
+
18
+ $effect(() => form.register(field.path));
19
+ $effect(() => () => generation.cancel());
20
+
21
+ async function generate() {
22
+ const result = await generation.run(authoring, form, "generate-description");
23
+ if (result !== undefined) form.set(field.path, result);
24
+ }
25
+ </script>
26
+
27
+ <div data-field-path={field.path}>
28
+ <FieldFrame
29
+ controlID={field.id}
30
+ label={field.admin.label}
31
+ required={field.required}
32
+ readOnly={field.admin.readOnly}
33
+ description={field.admin.description}
34
+ errors={issues.map((issue) => issue.message)}
35
+ >
36
+ <div class="-mt-1 flex flex-wrap items-center gap-x-1 text-[11.5px] text-foreground-muted">
37
+ <span>{i18n.t("plugin.seo:lengthTipDescription", { minLength, maxLength })}</span>
38
+ <a
39
+ class="text-primary underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-ring/70"
40
+ href="https://developers.google.com/search/docs/appearance/snippet#meta-descriptions"
41
+ target="_blank"
42
+ rel="noopener noreferrer"
43
+ >
44
+ {i18n.t("plugin.seo:bestPractices")}
45
+ </a>
46
+ {#if config.generate}
47
+ <span aria-hidden="true">·</span>
48
+ <Button
49
+ variant="link"
50
+ size="xs"
51
+ class="h-auto px-0"
52
+ disabled={field.admin.readOnly || generation.status === "pending"}
53
+ aria-busy={generation.status === "pending"}
54
+ onclick={generate}
55
+ >
56
+ {i18n.t("plugin.seo:autoGenerate")}
57
+ </Button>
58
+ {/if}
59
+ </div>
60
+ <Textarea
61
+ id={field.id}
62
+ name={field.path}
63
+ required={field.required}
64
+ readonly={field.admin.readOnly}
65
+ minlength={field.textarea?.minLength}
66
+ maxlength={field.textarea?.maxLength}
67
+ aria-invalid={issues.length > 0}
68
+ aria-describedby={hasMessage ? `${field.id}-message` : undefined}
69
+ aria-errormessage={issues.length > 0 ? `${field.id}-message` : undefined}
70
+ {value}
71
+ oninput={(event) => form.set(field.path, event.currentTarget.value)}
72
+ />
73
+ <LengthIndicator text={value} {minLength} {maxLength} {i18n} />
74
+ {#if generation.status === "error"}<p class="text-[12px] text-destructive" role="alert">
75
+ {i18n.t("plugin.seo:generationFailed")}
76
+ </p>{/if}
77
+ </FieldFrame>
78
+ </div>
@@ -0,0 +1,190 @@
1
+ <script lang="ts">
2
+ import type { FieldComponentProps, FieldDocument } from "@riducms/plugin";
3
+ import { Button, FieldFrame } from "@riducms/ui";
4
+ import ImageIcon from "~icons/lucide/image";
5
+ import SearchIcon from "~icons/lucide/search";
6
+ import XIcon from "~icons/lucide/x";
7
+
8
+ import { GenerationController } from "@plugin-seo/generation-controller.svelte";
9
+ import { imageGenerationEnabled } from "@plugin-seo/seo-config";
10
+
11
+ let { field, form, i18n, authoring }: FieldComponentProps = $props();
12
+ const generationEnabled = $derived(imageGenerationEnabled(field));
13
+ const value = $derived(String(form.get(field.path) ?? ""));
14
+ const issues = $derived(form.issuesFor(field.path));
15
+ const target = $derived(
16
+ authoring?.collections.find(
17
+ (collection) =>
18
+ collection.slug === field.upload?.collectionSlug ||
19
+ collection.id === field.upload?.collectionId
20
+ )
21
+ );
22
+ const ReferenceBrowser = $derived(authoring?.referenceBrowser);
23
+ let browserOpen = $state(false);
24
+ let browserMode = $state<"inspect" | "select">("select");
25
+ let document = $state.raw<FieldDocument>();
26
+ let loadFailed = $state(false);
27
+ const generation = new GenerationController();
28
+
29
+ $effect(() => form.register(field.path));
30
+ $effect(() => () => generation.cancel());
31
+ $effect(() => {
32
+ const id = value;
33
+ if (id === "" || target === undefined || authoring === undefined) {
34
+ document = undefined;
35
+ loadFailed = false;
36
+ return;
37
+ }
38
+ const request = new AbortController();
39
+ loadFailed = false;
40
+ authoring
41
+ .findDocument(target.slug, id, request.signal)
42
+ .then((result) => {
43
+ if (!request.signal.aborted) document = result;
44
+ })
45
+ .catch(() => {
46
+ if (!request.signal.aborted) {
47
+ document = undefined;
48
+ loadFailed = true;
49
+ }
50
+ });
51
+ return () => request.abort();
52
+ });
53
+
54
+ async function generate() {
55
+ const result = await generation.run(authoring, form, "generate-image");
56
+ if (result !== undefined) form.set(field.path, result);
57
+ }
58
+
59
+ function commit(ids: string[]) {
60
+ form.set(field.path, ids[0] ?? null);
61
+ browserOpen = false;
62
+ }
63
+
64
+ function openBrowser(mode: "inspect" | "select") {
65
+ browserMode = mode;
66
+ browserOpen = true;
67
+ }
68
+
69
+ function documentLabel() {
70
+ for (const key of ["filename", "title", "name", "alt"] as const) {
71
+ const candidate = document?.[key];
72
+ if (typeof candidate === "string" && candidate.length > 0) return candidate;
73
+ }
74
+ return value;
75
+ }
76
+ </script>
77
+
78
+ <div data-field-path={field.path}>
79
+ <FieldFrame
80
+ controlID={field.id}
81
+ label={field.admin.label}
82
+ required={field.required}
83
+ readOnly={field.admin.readOnly}
84
+ description={field.admin.description}
85
+ errors={issues.map((issue) => issue.message)}
86
+ >
87
+ {#if generationEnabled}<div
88
+ class="-mt-1 flex flex-wrap items-center gap-1 text-[11.5px] text-foreground-muted"
89
+ >
90
+ <span>{i18n.t("plugin.seo:imageAutoGenerationTip")}</span>
91
+ <Button
92
+ variant="link"
93
+ size="xs"
94
+ class="h-auto px-0"
95
+ disabled={field.admin.readOnly || generation.status === "pending"}
96
+ aria-busy={generation.status === "pending"}
97
+ onclick={generate}
98
+ >
99
+ {i18n.t("plugin.seo:autoGenerate")}
100
+ </Button>
101
+ </div>{/if}
102
+ {#if value === ""}
103
+ <Button
104
+ id={field.id}
105
+ aria-label={i18n.t("plugin.seo:selectImage")}
106
+ variant="outline"
107
+ disabled={field.admin.readOnly || target === undefined || ReferenceBrowser === undefined}
108
+ onclick={() => openBrowser("select")}
109
+ >
110
+ <SearchIcon />
111
+ {i18n.t("plugin.seo:selectImage")}
112
+ </Button>
113
+ {:else}
114
+ <div
115
+ class="flex min-w-0 items-center gap-2.5 rounded-[4px] border border-control-border bg-control p-2"
116
+ >
117
+ <button
118
+ id={field.id}
119
+ aria-label={i18n.t("plugin.seo:inspectImage")}
120
+ type="button"
121
+ class="flex min-w-0 flex-1 items-center gap-2.5 rounded-[3px] text-start outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed"
122
+ disabled={target === undefined || ReferenceBrowser === undefined}
123
+ onclick={() => openBrowser("inspect")}
124
+ >
125
+ <span
126
+ class="grid size-11 shrink-0 place-items-center overflow-hidden rounded-[3px] bg-background text-foreground-faint"
127
+ >
128
+ {#if typeof document?.url === "string" && String(document?.mimeType ?? "").startsWith("image/")}
129
+ <img class="size-full object-cover" src={document.url} alt="" />
130
+ {:else}<ImageIcon aria-hidden="true" />{/if}
131
+ </span>
132
+ <span class="min-w-0 flex-1">
133
+ <span class="block truncate text-[13px] text-foreground-strong">{documentLabel()}</span>
134
+ <span class="block truncate font-mono text-[10px] text-foreground-faint">{value}</span>
135
+ {#if loadFailed}<span class="block text-[11px] text-destructive">
136
+ {i18n.t("plugin.seo:imageUnavailable")}
137
+ </span>{/if}
138
+ </span>
139
+ <span class="inline-flex shrink-0 items-center gap-1 text-[11px] font-medium">
140
+ <SearchIcon aria-hidden="true" />
141
+ {i18n.t("plugin.seo:inspectImage")}
142
+ </span>
143
+ </button>
144
+ {#if !field.admin.readOnly}<div class="flex shrink-0 gap-1">
145
+ <Button variant="outline" size="xs" onclick={() => openBrowser("select")}>
146
+ <SearchIcon aria-hidden="true" />
147
+ {i18n.t("plugin.seo:replaceImage")}
148
+ </Button>
149
+ <Button
150
+ variant="ghost"
151
+ size="icon-xs"
152
+ onclick={() => form.set(field.path, null)}
153
+ aria-label={i18n.t("plugin.seo:removeImage")}
154
+ >
155
+ <XIcon />
156
+ </Button>
157
+ </div>{/if}
158
+ </div>
159
+ {/if}
160
+ <div class="flex items-center gap-2">
161
+ <span
162
+ class={[
163
+ "rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide",
164
+ value === "" ? "bg-destructive/10 text-destructive" : "bg-success/12 text-success",
165
+ ]}
166
+ >
167
+ {value === "" ? i18n.t("plugin.seo:noImage") : i18n.t("plugin.seo:good")}
168
+ </span>
169
+ </div>
170
+ {#if generation.status === "error"}<p class="text-[12px] text-destructive" role="alert">
171
+ {i18n.t("plugin.seo:generationFailed")}
172
+ </p>{/if}
173
+ </FieldFrame>
174
+ </div>
175
+
176
+ {#if browserOpen && ReferenceBrowser !== undefined && target !== undefined}
177
+ <ReferenceBrowser
178
+ open
179
+ {field}
180
+ collection={target}
181
+ hasMany={false}
182
+ selectedIDs={value === "" ? [] : [value]}
183
+ readOnly={browserMode === "inspect" ? (field.admin.readOnly ?? false) : false}
184
+ {...browserMode === "inspect" && document !== undefined ? { initialDocument: document } : {}}
185
+ {...browserMode === "inspect" && value !== "" ? { initialDocumentID: value } : {}}
186
+ locale={form.contentLocale ?? ""}
187
+ onCommit={commit}
188
+ onClose={() => (browserOpen = false)}
189
+ />
190
+ {/if}
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ import type { FieldComponentProps } from "@riducms/plugin";
3
+ import { Button, FieldFrame, Input } from "@riducms/ui";
4
+
5
+ import { GenerationController } from "@plugin-seo/generation-controller.svelte";
6
+ import LengthIndicator from "@plugin-seo/length-indicator.svelte";
7
+ import { lengthConfig } from "@plugin-seo/seo-config";
8
+
9
+ let { field, form, i18n, authoring }: FieldComponentProps = $props();
10
+ const config = $derived(lengthConfig(field));
11
+ const minLength = $derived(field.text?.minLength ?? config.minLength);
12
+ const maxLength = $derived(field.text?.maxLength ?? config.maxLength);
13
+ const value = $derived(String(form.get(field.path) ?? ""));
14
+ const issues = $derived(form.issuesFor(field.path));
15
+ const hasMessage = $derived(issues.length > 0 || field.admin.description !== undefined);
16
+ const generation = new GenerationController();
17
+
18
+ $effect(() => form.register(field.path));
19
+ $effect(() => () => generation.cancel());
20
+
21
+ async function generate() {
22
+ const result = await generation.run(authoring, form, "generate-title");
23
+ if (result !== undefined) form.set(field.path, result);
24
+ }
25
+ </script>
26
+
27
+ <div data-field-path={field.path}>
28
+ <FieldFrame
29
+ controlID={field.id}
30
+ label={field.admin.label}
31
+ required={field.required}
32
+ readOnly={field.admin.readOnly}
33
+ description={field.admin.description}
34
+ errors={issues.map((issue) => issue.message)}
35
+ >
36
+ <div class="-mt-1 flex flex-wrap items-center gap-x-1 text-[11.5px] text-foreground-muted">
37
+ <span>{i18n.t("plugin.seo:lengthTipTitle", { minLength, maxLength })}</span>
38
+ <a
39
+ class="text-primary underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-ring/70"
40
+ href="https://developers.google.com/search/docs/appearance/title-link#page-titles"
41
+ target="_blank"
42
+ rel="noopener noreferrer"
43
+ >
44
+ {i18n.t("plugin.seo:bestPractices")}
45
+ </a>
46
+ {#if config.generate}
47
+ <span aria-hidden="true">·</span>
48
+ <Button
49
+ variant="link"
50
+ size="xs"
51
+ class="h-auto px-0"
52
+ disabled={field.admin.readOnly || generation.status === "pending"}
53
+ aria-busy={generation.status === "pending"}
54
+ onclick={generate}
55
+ >
56
+ {i18n.t("plugin.seo:autoGenerate")}
57
+ </Button>
58
+ {/if}
59
+ </div>
60
+ <Input
61
+ id={field.id}
62
+ name={field.path}
63
+ required={field.required}
64
+ readonly={field.admin.readOnly}
65
+ minlength={field.text?.minLength}
66
+ maxlength={field.text?.maxLength}
67
+ aria-invalid={issues.length > 0}
68
+ aria-describedby={hasMessage ? `${field.id}-message` : undefined}
69
+ aria-errormessage={issues.length > 0 ? `${field.id}-message` : undefined}
70
+ {value}
71
+ oninput={(event) => form.set(field.path, event.currentTarget.value)}
72
+ />
73
+ <LengthIndicator text={value} {minLength} {maxLength} {i18n} />
74
+ {#if generation.status === "error"}<p class="text-[12px] text-destructive" role="alert">
75
+ {i18n.t("plugin.seo:generationFailed")}
76
+ </p>{/if}
77
+ </FieldFrame>
78
+ </div>
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ import type { FieldComponentProps } from "@riducms/plugin";
3
+ import CheckIcon from "~icons/lucide/check";
4
+ import XIcon from "~icons/lucide/x";
5
+
6
+ import { overviewConfig } from "@plugin-seo/seo-config";
7
+
8
+ let { field, form, i18n }: FieldComponentProps = $props();
9
+ const config = $derived(overviewConfig(field));
10
+ const title = $derived(form.get(config.titlePath));
11
+ const description = $derived(form.get(config.descriptionPath));
12
+ const image = $derived(form.get(config.imagePath));
13
+ const checks = $derived([
14
+ {
15
+ key: "title",
16
+ label: i18n.t("plugin.seo:title"),
17
+ passing:
18
+ typeof title === "string" &&
19
+ title.length >= config.titleMin &&
20
+ title.length <= config.titleMax,
21
+ },
22
+ {
23
+ key: "description",
24
+ label: i18n.t("plugin.seo:description"),
25
+ passing:
26
+ typeof description === "string" &&
27
+ description.length >= config.descriptionMin &&
28
+ description.length <= config.descriptionMax,
29
+ },
30
+ { key: "image", label: i18n.t("plugin.seo:image"), passing: Boolean(image) },
31
+ ]);
32
+ const passing = $derived(checks.filter((check) => check.passing).length);
33
+ </script>
34
+
35
+ <section
36
+ class="rounded-[4px] border border-control-border bg-control/35 p-3.5"
37
+ aria-labelledby={`${field.id}-heading`}
38
+ data-seo-overview
39
+ data-field-path={field.path}
40
+ >
41
+ <div class="flex flex-wrap items-center justify-between gap-2">
42
+ <h3 id={`${field.id}-heading`} class="text-[13px] font-semibold text-foreground-strong">
43
+ {field.admin.label}
44
+ </h3>
45
+ <p class="font-mono text-[11px] text-foreground-muted" aria-live="polite">
46
+ {i18n.t("plugin.seo:checksPassing", { current: passing, max: checks.length })}
47
+ </p>
48
+ </div>
49
+ <ul class="mt-2 flex flex-wrap gap-1.5">
50
+ {#each checks as check (check.key)}
51
+ <li
52
+ class={[
53
+ "inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px]",
54
+ check.passing ? "bg-success/12 text-success" : "bg-destructive/10 text-destructive",
55
+ ]}
56
+ data-seo-check={check.key}
57
+ data-passing={check.passing}
58
+ >
59
+ {#if check.passing}<CheckIcon class="size-3" aria-hidden="true" />{:else}<XIcon
60
+ class="size-3"
61
+ aria-hidden="true"
62
+ />{/if}
63
+ <span>{check.label}</span>
64
+ </li>
65
+ {/each}
66
+ </ul>
67
+ </section>
@@ -0,0 +1,56 @@
1
+ <script lang="ts">
2
+ import type { FieldComponentProps } from "@riducms/plugin";
3
+
4
+ import { GenerationController } from "@plugin-seo/generation-controller.svelte";
5
+ import { previewConfig } from "@plugin-seo/seo-config";
6
+
7
+ let { field, form, i18n, authoring }: FieldComponentProps = $props();
8
+ const config = $derived(previewConfig(field));
9
+ const title = $derived(String(form.get(config.titlePath) ?? ""));
10
+ const description = $derived(String(form.get(config.descriptionPath) ?? ""));
11
+ let href = $state("");
12
+ const generation = new GenerationController();
13
+
14
+ $effect(() => {
15
+ href = "";
16
+ if (!config.generate) return;
17
+ form.snapshot?.();
18
+ form.contentLocale;
19
+ form.resource;
20
+ const timeout = setTimeout(async () => {
21
+ const result = await generation.run(authoring, form, "generate-url");
22
+ if (result !== undefined) href = result;
23
+ }, 250);
24
+ return () => {
25
+ clearTimeout(timeout);
26
+ generation.cancel();
27
+ };
28
+ });
29
+ </script>
30
+
31
+ <section
32
+ class="grid gap-2"
33
+ aria-labelledby={`${field.id}-heading`}
34
+ data-seo-preview
35
+ data-field-path={field.path}
36
+ >
37
+ <div>
38
+ <h3 id={`${field.id}-heading`} class="text-[13px] font-semibold text-foreground-strong">
39
+ {i18n.t("plugin.seo:preview")}
40
+ </h3>
41
+ <p class="mt-0.5 text-[12px] text-foreground-muted">
42
+ {i18n.t("plugin.seo:previewDescription")}
43
+ </p>
44
+ </div>
45
+ <div
46
+ class="w-full max-w-[600px] overflow-hidden rounded-[5px] border border-control-border bg-background p-4 shadow-sm"
47
+ aria-busy={generation.status === "pending"}
48
+ >
49
+ <p class="truncate text-[12px] text-success">{href || "https://..."}</p>
50
+ <p class="mt-1 truncate text-[18px] leading-6 text-primary">{title}</p>
51
+ <p class="mt-0.5 line-clamp-2 text-[13px] leading-5 text-foreground-muted">{description}</p>
52
+ </div>
53
+ {#if generation.status === "error"}<p class="text-[12px] text-destructive" role="alert">
54
+ {i18n.t("plugin.seo:generationFailed")}
55
+ </p>{/if}
56
+ </section>
@@ -0,0 +1,73 @@
1
+ import type { SchemaField } from "@riducms/protocol";
2
+
3
+ export interface LengthConfig {
4
+ generate: boolean;
5
+ minLength: number;
6
+ maxLength: number;
7
+ }
8
+
9
+ export interface OverviewConfig {
10
+ titlePath: string;
11
+ descriptionPath: string;
12
+ imagePath: string;
13
+ titleMin: number;
14
+ titleMax: number;
15
+ descriptionMin: number;
16
+ descriptionMax: number;
17
+ }
18
+
19
+ export interface PreviewConfig {
20
+ generate: boolean;
21
+ titlePath: string;
22
+ descriptionPath: string;
23
+ }
24
+
25
+ export function lengthConfig(field: SchemaField): LengthConfig {
26
+ const value = componentConfig(field);
27
+ return {
28
+ generate: value.generate === true,
29
+ minLength: positiveInteger(value.minLength, field.type === "textarea" ? 100 : 50),
30
+ maxLength: positiveInteger(value.maxLength, field.type === "textarea" ? 150 : 60),
31
+ };
32
+ }
33
+
34
+ export function overviewConfig(field: SchemaField): OverviewConfig {
35
+ const value = componentConfig(field);
36
+ return {
37
+ titlePath: stringValue(value.titlePath, "meta.title"),
38
+ descriptionPath: stringValue(value.descriptionPath, "meta.description"),
39
+ imagePath: stringValue(value.imagePath, "meta.image"),
40
+ titleMin: positiveInteger(value.titleMin, 50),
41
+ titleMax: positiveInteger(value.titleMax, 60),
42
+ descriptionMin: positiveInteger(value.descriptionMin, 100),
43
+ descriptionMax: positiveInteger(value.descriptionMax, 150),
44
+ };
45
+ }
46
+
47
+ export function previewConfig(field: SchemaField): PreviewConfig {
48
+ const value = componentConfig(field);
49
+ return {
50
+ generate: value.generate === true,
51
+ titlePath: stringValue(value.titlePath, "meta.title"),
52
+ descriptionPath: stringValue(value.descriptionPath, "meta.description"),
53
+ };
54
+ }
55
+
56
+ export function imageGenerationEnabled(field: SchemaField) {
57
+ return componentConfig(field).generate === true;
58
+ }
59
+
60
+ function componentConfig(field: SchemaField): Record<string, unknown> {
61
+ const value = field.admin.component?.config;
62
+ return typeof value === "object" && value !== null && !Array.isArray(value)
63
+ ? (value as Record<string, unknown>)
64
+ : {};
65
+ }
66
+
67
+ function stringValue(value: unknown, fallback: string) {
68
+ return typeof value === "string" && value.length > 0 ? value : fallback;
69
+ }
70
+
71
+ function positiveInteger(value: unknown, fallback: number) {
72
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
73
+ }
@@ -0,0 +1,7 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module "~icons/*" {
4
+ import type { Component } from "svelte";
5
+ const icon: Component<Record<string, unknown>>;
6
+ export default icon;
7
+ }