@riducms/plugin-seo 0.2.3 → 0.3.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/README.md CHANGED
@@ -7,9 +7,9 @@ For an existing project, install it in the root and admin workspaces, configure
7
7
  start development:
8
8
 
9
9
  ```sh
10
- npm install @riducms/plugin-seo
11
- npm install --workspace admin @riducms/plugin-seo
12
- npm run dev
10
+ bun add @riducms/plugin-seo
11
+ bun add --cwd admin @riducms/plugin-seo
12
+ bun run dev
13
13
  ```
14
14
 
15
15
  `ridu dev` regenerates contracts and synchronizes safe additive development changes. Before
@@ -20,5 +20,10 @@ The generated registry imports `seoAdminPlugin`; do not register it again. Gener
20
20
  description, image, and URL values remain editable and pass through normal validation and access
21
21
  rules when saved.
22
22
 
23
+ The field stack follows Payload's overview, inline generation, length guidance, upload intake, and
24
+ search-preview anatomy. Its `ridu-seo-*` presentation is semantic SCSS in the shared component
25
+ layer; applications can override those hooks without depending on UnoCSS utility output.
26
+
23
27
  See the [SEO guide](../../website/src/content/docs/seo.md) for setup, callbacks, migrations, and
24
- verification.
28
+ verification. See [customizing admin CSS](https://riducms.com/docs/admin/customizing-css/) for
29
+ application styling overrides.
package/package.json CHANGED
@@ -3,14 +3,14 @@
3
3
  "url": "https://github.com/riducms/ridu/issues"
4
4
  },
5
5
  "dependencies": {
6
- "@riducms/plugin": "0.2.3",
7
- "@riducms/protocol": "0.2.3",
8
- "@riducms/ui": "0.2.3",
6
+ "@riducms/plugin": "0.3.0",
7
+ "@riducms/protocol": "0.3.0",
8
+ "@riducms/ui": "0.3.0",
9
9
  "svelte": "^5.56.8"
10
10
  },
11
11
  "description": "Search metadata authoring for Ridu's official SEO plugin.",
12
12
  "devDependencies": {
13
- "@riducms/build": "0.2.3"
13
+ "@riducms/build": "0.3.0"
14
14
  },
15
15
  "exports": {
16
16
  ".": "./src/index.ts"
@@ -37,5 +37,5 @@
37
37
  "test": "bun test"
38
38
  },
39
39
  "type": "module",
40
- "version": "0.2.3"
40
+ "version": "0.3.0"
41
41
  }
package/src/index.ts CHANGED
@@ -19,7 +19,7 @@ import PreviewField from "@plugin-seo/preview-field.svelte";
19
19
  export const seoAdminPlugin = defineAdminPlugin({
20
20
  key: "seo",
21
21
  pairingVersion: 1,
22
- components: {
22
+ fieldEditors: {
23
23
  overview: defineFieldComponent({
24
24
  type: "ui",
25
25
  component: OverviewField,
@@ -2,6 +2,7 @@
2
2
  import type { AdminI18n } from "@riducms/plugin";
3
3
 
4
4
  import { lengthState } from "@plugin-seo/length-indicator";
5
+ import "@plugin-seo/seo.scss";
5
6
 
6
7
  let {
7
8
  text,
@@ -10,9 +11,6 @@
10
11
  i18n,
11
12
  }: { text: string; minLength: number; maxLength: number; i18n: AdminI18n } = $props();
12
13
  const state = $derived(lengthState(text, minLength, maxLength));
13
- const tone = $derived(
14
- state.status === "good" ? "success" : state.status === "almostThere" ? "warning" : "danger"
15
- );
16
14
  const suffix = $derived(
17
15
  state.status === "missing" || state.status === "tooShort" || state.status === "almostThere"
18
16
  ? i18n.t("plugin.seo:charactersToGo", { characters: state.remaining })
@@ -22,35 +20,21 @@
22
20
  );
23
21
  </script>
24
22
 
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
- >
23
+ <div class="ridu-seo-length" data-length-status={state.status}>
24
+ <span class="ridu-seo-pill" data-status={state.status}>
34
25
  {i18n.t(`plugin.seo:${state.status}`)}
35
26
  </span>
36
- <small class="shrink-0 text-[11px] text-foreground-muted">
27
+ <small class="ridu-seo-length-copy">
37
28
  {i18n.t("plugin.seo:characterCount", {
38
29
  current: state.length,
39
30
  minLength,
40
31
  maxLength,
41
32
  })}{suffix}
42
33
  </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
- >
34
+ <span class="ridu-seo-progress" aria-hidden="true">
47
35
  <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
- ]}
36
+ class="ridu-seo-progress-value"
37
+ data-status={state.status}
54
38
  style:transform="scaleX({state.progress})"
55
39
  ></span>
56
40
  </span>
package/src/messages.ts CHANGED
@@ -4,27 +4,32 @@ export const seoMessages = defineAdminMessages({
4
4
  fallback: {
5
5
  almostThere: "Almost there",
6
6
  autoGenerate: "Auto-generate",
7
- bestPractices: "Best practices",
7
+ bestPractices: "best practices",
8
8
  cancelled: "Generation was cancelled.",
9
9
  characterCount: "{current}/{minLength}-{maxLength} chars",
10
10
  charactersLeft: ", {characters} left over",
11
11
  charactersToGo: ", {characters} to go",
12
12
  charactersTooMany: ", {characters} too many",
13
13
  checksPassing: "{current}/{max} checks are passing",
14
+ chooseExistingImage: "Choose existing",
15
+ createNewImage: "Create new",
14
16
  generationFailed: "Could not generate this value. Try again.",
15
17
  good: "Good",
16
18
  image: "Image",
17
19
  imageAutoGenerationTip: "Generate an image from the current document, or choose one manually.",
20
+ imageSourceTip: "or drag and drop a file.",
18
21
  imageUnavailable: "Referenced image is unavailable.",
19
22
  inspectImage: "Inspect image",
20
- lengthTipDescription: "Aim for {minLength}–{maxLength} characters. ",
21
- lengthTipTitle: "Aim for {minLength}–{maxLength} characters. ",
23
+ lengthTipDescription:
24
+ "This should be between {minLength} and {maxLength} characters. For help in writing quality meta descriptions, see ",
25
+ lengthTipTitle:
26
+ "This should be between {minLength} and {maxLength} characters. For help in writing quality meta titles, see ",
22
27
  missing: "Missing",
23
28
  noImage: "No image",
29
+ or: "or",
24
30
  preview: "Preview",
25
- previewDescription: "A representative search result using the current unsaved values.",
31
+ previewDescription: "Exact result listings may vary based on content and search relevancy.",
26
32
  removeImage: "Remove image",
27
- replaceImage: "Replace image",
28
33
  selectImage: "Select image",
29
34
  tooLong: "Too long",
30
35
  tooShort: "Too short",
@@ -35,27 +40,34 @@ export const seoMessages = defineAdminMessages({
35
40
  fr: {
36
41
  almostThere: "Presque terminé",
37
42
  autoGenerate: "Générer automatiquement",
38
- bestPractices: "Bonnes pratiques",
43
+ bestPractices: "bonnes pratiques",
39
44
  cancelled: "La génération a été annulée.",
40
45
  characterCount: "{current}/{minLength}-{maxLength} caractères",
41
46
  charactersLeft: ", {characters} restants",
42
47
  charactersToGo: ", encore {characters}",
43
48
  charactersTooMany: ", {characters} en trop",
44
49
  checksPassing: "{current}/{max} vérifications réussies",
50
+ chooseExistingImage: "Choisir un élément existant",
51
+ createNewImage: "Créer un élément",
45
52
  generationFailed: "Impossible de générer cette valeur. Réessayez.",
46
53
  good: "Bon",
47
54
  image: "Image",
48
- imageAutoGenerationTip: "Générez une image depuis le document ou choisissez-en une.",
55
+ imageAutoGenerationTip:
56
+ "Générez une image à partir du document actuel ou choisissez-en une manuellement.",
57
+ imageSourceTip: "ou glissez-déposez un fichier.",
49
58
  imageUnavailable: "L’image référencée n’est pas disponible.",
50
59
  inspectImage: "Inspecter l’image",
51
- lengthTipDescription: "Visez {minLength} à {maxLength} caractères. ",
52
- lengthTipTitle: "Visez {minLength} à {maxLength} caractères. ",
60
+ lengthTipDescription:
61
+ "Ceci devrait contenir entre {minLength} et {maxLength} caractères. Pour obtenir de l’aide pour rédiger des descriptions meta de qualité, consultez les ",
62
+ lengthTipTitle:
63
+ "Ceci devrait contenir entre {minLength} et {maxLength} caractères. Pour obtenir de l’aide pour rédiger des titres meta de qualité, consultez les ",
53
64
  missing: "Manquant",
54
65
  noImage: "Aucune image",
66
+ or: "ou",
55
67
  preview: "Aperçu",
56
- previewDescription: "Résultat de recherche représentatif avec les valeurs non enregistrées.",
68
+ previewDescription:
69
+ "Les résultats exacts peuvent varier en fonction du contenu et de la pertinence de la recherche.",
57
70
  removeImage: "Supprimer l’image",
58
- replaceImage: "Remplacer l’image",
59
71
  selectImage: "Sélectionner une image",
60
72
  tooLong: "Trop long",
61
73
  tooShort: "Trop court",
@@ -72,21 +84,26 @@ export const seoMessages = defineAdminMessages({
72
84
  charactersToGo: "، يلزم {characters}",
73
85
  charactersTooMany: "، زائد {characters}",
74
86
  checksPassing: "تم اجتياز {current}/{max} من الفحوصات",
87
+ chooseExistingImage: "اختيار صورة موجودة",
88
+ createNewImage: "إنشاء صورة جديدة",
75
89
  description: "الوصف",
76
90
  generationFailed: "تعذر إنشاء هذه القيمة. حاول مرة أخرى.",
77
91
  good: "جيد",
78
92
  image: "الصورة",
79
- imageAutoGenerationTip: "أنشئ صورة من المستند الحالي أو اختر واحدة يدويًا.",
93
+ imageAutoGenerationTip: "أنشئ صورة من المستند الحالي أو اختر صورة يدويًا.",
94
+ imageSourceTip: "أو اسحب ملفًا وأفلته.",
80
95
  imageUnavailable: "الصورة المشار إليها غير متاحة.",
81
96
  inspectImage: "فحص الصورة",
82
- lengthTipDescription: "استهدف {minLength}–{maxLength} حرفًا. ",
83
- lengthTipTitle: "استهدف {minLength}–{maxLength} حرفًا. ",
97
+ lengthTipDescription:
98
+ "يجب أن يتراوح هذا بين {minLength} و{maxLength} حرفًا. للحصول على مساعدة في كتابة أوصاف ميتا ذات جودة، راجع ",
99
+ lengthTipTitle:
100
+ "يجب أن يتراوح هذا بين {minLength} و{maxLength} حرفًا. للحصول على مساعدة في كتابة عناوين ميتا ذات جودة، راجع ",
84
101
  missing: "مفقود",
85
102
  noImage: "لا توجد صورة",
103
+ or: "أو",
86
104
  preview: "معاينة",
87
- previewDescription: "نتيجة بحث تمثيلية باستخدام القيم الحالية غير المحفوظة.",
105
+ previewDescription: "قد تختلف النتائج الدقيقة بناءً على المحتوى وملاءمة البحث.",
88
106
  removeImage: "إزالة الصورة",
89
- replaceImage: "استبدال الصورة",
90
107
  selectImage: "اختيار صورة",
91
108
  title: "العنوان",
92
109
  tooLong: "طويل جدًا",
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { GenerationController } from "@plugin-seo/generation-controller.svelte";
6
6
  import LengthIndicator from "@plugin-seo/length-indicator.svelte";
7
+ import "@plugin-seo/seo.scss";
7
8
  import type { LengthConfig } from "@plugin-seo/seo-config";
8
9
 
9
10
  let {
@@ -34,6 +35,19 @@
34
35
  </script>
35
36
 
36
37
  <div data-field-path={field.path}>
38
+ {#snippet headingAction()}
39
+ <span class="ridu-seo-heading-separator" aria-hidden="true">—</span>
40
+ <Button
41
+ variant="link"
42
+ size="xs"
43
+ class="ridu-seo-generate"
44
+ disabled={editingBlocked || generation.status === "pending"}
45
+ aria-busy={generation.status === "pending"}
46
+ onclick={generate}
47
+ >
48
+ {i18n.t("plugin.seo:autoGenerate")}
49
+ </Button>
50
+ {/snippet}
37
51
  <FieldFrame
38
52
  controlID={field.id}
39
53
  label={field.admin.label}
@@ -41,32 +55,24 @@
41
55
  readOnly={field.admin.readOnly}
42
56
  description={field.admin.description}
43
57
  errors={issues.map((issue) => issue.message)}
58
+ class="ridu-seo-field"
59
+ headingAction={config.generate ? headingAction : undefined}
44
60
  >
45
- <div class="-mt-1 flex flex-wrap items-center gap-x-1 text-[11.5px] text-foreground-muted">
61
+ <p class="ridu-seo-guidance">
46
62
  <span>{i18n.t("plugin.seo:lengthTipDescription", { minLength, maxLength })}</span>
47
63
  <a
48
- class="text-primary underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-ring/70"
64
+ class="ridu-seo-guidance-link"
49
65
  href="https://developers.google.com/search/docs/appearance/snippet#meta-descriptions"
50
66
  target="_blank"
51
67
  rel="noopener noreferrer"
52
68
  >
53
69
  {i18n.t("plugin.seo:bestPractices")}
54
70
  </a>
55
- {#if config.generate}
56
- <span aria-hidden="true">·</span>
57
- <Button
58
- variant="link"
59
- size="xs"
60
- class="h-auto px-0"
61
- disabled={editingBlocked || generation.status === "pending"}
62
- aria-busy={generation.status === "pending"}
63
- onclick={generate}
64
- >
65
- {i18n.t("plugin.seo:autoGenerate")}
66
- </Button>
67
- {/if}
68
- </div>
71
+ <span>.</span>
72
+ </p>
69
73
  <Textarea
74
+ class="ridu-seo-description"
75
+ data-empty={value === ""}
70
76
  id={field.id}
71
77
  name={field.path}
72
78
  required={field.required}
@@ -80,8 +86,10 @@
80
86
  oninput={(event) => binding.set(event.currentTarget.value)}
81
87
  />
82
88
  <LengthIndicator text={value} {minLength} {maxLength} {i18n} />
83
- {#if generation.status === "error"}<p class="text-[12px] text-destructive" role="alert">
89
+ {#if generation.status === "error"}
90
+ <p class="ridu-seo-error" role="alert">
84
91
  {i18n.t("plugin.seo:generationFailed")}
85
- </p>{/if}
92
+ </p>
93
+ {/if}
86
94
  </FieldFrame>
87
95
  </div>
@@ -2,10 +2,11 @@
2
2
  import type { PluginFieldProps, FieldDocument } from "@riducms/plugin";
3
3
  import { Button, FieldFrame, fieldControlARIA } from "@riducms/ui";
4
4
  import ImageIcon from "~icons/lucide/image";
5
- import SearchIcon from "~icons/lucide/search";
5
+ import PencilIcon from "~icons/lucide/pencil";
6
6
  import XIcon from "~icons/lucide/x";
7
7
 
8
8
  import { GenerationController } from "@plugin-seo/generation-controller.svelte";
9
+ import "@plugin-seo/seo.scss";
9
10
 
10
11
  let {
11
12
  field: binding,
@@ -31,20 +32,31 @@
31
32
  );
32
33
  const ReferenceBrowser = $derived(authoring?.referenceBrowser);
33
34
  let browserOpen = $state(false);
34
- let browserMode = $state<"inspect" | "select">("select");
35
+ let browserMode = $state<"create" | "inspect" | "select">("select");
36
+ let initialFile = $state.raw<File>();
37
+ let dragging = $state(false);
35
38
  let document = $state.raw<FieldDocument>();
36
39
  let loadFailed = $state(false);
37
40
  const generation = new GenerationController();
41
+ const intakeEnabled = $derived(
42
+ !editingBlocked && target !== undefined && ReferenceBrowser !== undefined
43
+ );
44
+ const canCreateImage = $derived(
45
+ intakeEnabled && target !== undefined && authoring.canCreateDocument(target.slug)
46
+ );
38
47
 
39
48
  $effect(() => () => generation.cancel());
40
49
  $effect(() => {
41
50
  const id = value;
51
+ void authoring.documentRevision;
52
+ void authoring.locale;
42
53
  if (id === "" || target === undefined || authoring === undefined) {
43
54
  document = undefined;
44
55
  loadFailed = false;
45
56
  return;
46
57
  }
47
58
  const request = new AbortController();
59
+ document = undefined;
48
60
  loadFailed = false;
49
61
  authoring
50
62
  .findDocument(target.slug, id, request.signal)
@@ -67,14 +79,28 @@
67
79
 
68
80
  function commit(ids: string[]) {
69
81
  binding.set(ids[0] ?? null);
82
+ closeBrowser();
83
+ }
84
+
85
+ function closeBrowser() {
70
86
  browserOpen = false;
87
+ initialFile = undefined;
88
+ dragging = false;
71
89
  }
72
90
 
73
- function openBrowser(mode: "inspect" | "select") {
91
+ function openBrowser(mode: "create" | "inspect" | "select", file?: File) {
74
92
  browserMode = mode;
93
+ initialFile = file;
75
94
  browserOpen = true;
76
95
  }
77
96
 
97
+ function drop(event: DragEvent) {
98
+ event.preventDefault();
99
+ dragging = false;
100
+ const file = event.dataTransfer?.files[0];
101
+ if (canCreateImage && file) openBrowser("create", file);
102
+ }
103
+
78
104
  function documentLabel() {
79
105
  for (const key of ["filename", "title", "name", "alt"] as const) {
80
106
  const candidate = document?.[key];
@@ -82,9 +108,45 @@
82
108
  }
83
109
  return value;
84
110
  }
111
+
112
+ function fileSize(bytes: unknown): string | undefined {
113
+ if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) return undefined;
114
+ if (bytes < 1024)
115
+ return i18n.formatNumber(bytes, { style: "unit", unit: "byte", unitDisplay: "long" });
116
+
117
+ const units = ["KB", "MB", "GB", "TB"];
118
+ const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length);
119
+ return `${i18n.formatNumber(bytes / 1024 ** exponent, { maximumFractionDigits: 0 })}${units[exponent - 1]}`;
120
+ }
121
+
122
+ function documentMetadata() {
123
+ if (loadFailed) return i18n.t("plugin.seo:imageUnavailable");
124
+ return [
125
+ fileSize(document?.filesize),
126
+ typeof document?.width === "number" && typeof document?.height === "number"
127
+ ? `${document.width}x${document.height}`
128
+ : undefined,
129
+ typeof document?.mimeType === "string" ? document.mimeType : undefined,
130
+ ]
131
+ .filter((part): part is string => part !== undefined)
132
+ .join(" — ");
133
+ }
85
134
  </script>
86
135
 
87
136
  <div data-field-path={field.path}>
137
+ {#snippet headingAction()}
138
+ <span class="ridu-seo-heading-separator" aria-hidden="true">—</span>
139
+ <Button
140
+ variant="link"
141
+ size="xs"
142
+ class="ridu-seo-generate"
143
+ disabled={editingBlocked || generation.status === "pending"}
144
+ aria-busy={generation.status === "pending"}
145
+ onclick={generate}
146
+ >
147
+ {i18n.t("plugin.seo:autoGenerate")}
148
+ </Button>
149
+ {/snippet}
88
150
  <FieldFrame
89
151
  controlID={field.id}
90
152
  label={field.admin.label}
@@ -92,78 +154,90 @@
92
154
  readOnly={field.admin.readOnly}
93
155
  description={field.admin.description}
94
156
  errors={issues.map((issue) => issue.message)}
157
+ class="ridu-seo-field"
158
+ headingAction={generationEnabled ? headingAction : undefined}
95
159
  >
96
- {#if generationEnabled}<div
97
- class="-mt-1 flex flex-wrap items-center gap-1 text-[11.5px] text-foreground-muted"
98
- >
99
- <span>{i18n.t("plugin.seo:imageAutoGenerationTip")}</span>
100
- <Button
101
- variant="link"
102
- size="xs"
103
- class="h-auto px-0"
104
- disabled={editingBlocked || generation.status === "pending"}
105
- aria-busy={generation.status === "pending"}
106
- onclick={generate}
107
- >
108
- {i18n.t("plugin.seo:autoGenerate")}
109
- </Button>
110
- </div>{/if}
160
+ {#if generationEnabled}
161
+ <p class="ridu-seo-guidance">{i18n.t("plugin.seo:imageAutoGenerationTip")}</p>
162
+ {/if}
111
163
  {#if value === ""}
112
- <Button
113
- id={field.id}
114
- aria-label={i18n.t("plugin.seo:selectImage")}
115
- {...controlARIA}
116
- variant="outline"
117
- class="aria-invalid:!border-destructive"
118
- disabled={editingBlocked || target === undefined || ReferenceBrowser === undefined}
119
- onclick={() => openBrowser("select")}
120
- >
121
- <SearchIcon />
122
- {i18n.t("plugin.seo:selectImage")}
123
- </Button>
124
- {:else}
164
+ <!-- File drops supplement the keyboard-accessible create and browse actions. -->
165
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
125
166
  <div
126
- class="flex min-w-0 items-center gap-2.5 rounded-[4px] border border-control-border bg-control p-2 aria-invalid:!border-destructive/65"
127
- aria-invalid={controlARIA["aria-invalid"]}
167
+ class="ridu-seo-image-intake"
168
+ role="region"
169
+ aria-label={field.admin.label}
170
+ data-dragging={dragging}
171
+ data-invalid={controlARIA["aria-invalid"]}
172
+ ondragover={(event) => {
173
+ event.preventDefault();
174
+ if (canCreateImage) {
175
+ dragging = true;
176
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
177
+ }
178
+ }}
179
+ ondragleave={(event) => {
180
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) dragging = false;
181
+ }}
182
+ ondrop={drop}
128
183
  >
129
- <button
184
+ {#if canCreateImage}
185
+ <Button variant="secondary" size="sm" onclick={() => openBrowser("create")}>
186
+ {i18n.t("plugin.seo:createNewImage")}
187
+ </Button>
188
+ <span>{i18n.t("plugin.seo:or")}</span>
189
+ {/if}
190
+ <Button
130
191
  id={field.id}
131
- aria-label={i18n.t("plugin.seo:inspectImage")}
192
+ aria-label={i18n.t("plugin.seo:chooseExistingImage")}
132
193
  {...controlARIA}
133
- type="button"
134
- 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"
194
+ variant="secondary"
195
+ size="sm"
135
196
  disabled={editingBlocked || target === undefined || ReferenceBrowser === undefined}
136
- onclick={() => openBrowser("inspect")}
197
+ onclick={() => openBrowser("select")}
137
198
  >
138
- <span
139
- class="grid size-11 shrink-0 place-items-center overflow-hidden rounded-[3px] bg-background text-foreground-faint"
140
- >
141
- {#if typeof document?.url === "string" && String(document?.mimeType ?? "").startsWith("image/")}
142
- <img class="size-full object-cover" src={document.url} alt="" />
143
- {:else}<ImageIcon aria-hidden="true" />{/if}
199
+ {i18n.t("plugin.seo:chooseExistingImage")}
200
+ </Button>
201
+ {#if canCreateImage}
202
+ <span class="ridu-seo-image-intake-copy">
203
+ {i18n.t("plugin.seo:imageSourceTip")}
144
204
  </span>
145
- <span class="min-w-0 flex-1">
146
- <span class="block truncate text-[13px] text-foreground-strong">{documentLabel()}</span>
147
- <span class="block truncate font-mono text-[10px] text-foreground-faint">{value}</span>
148
- {#if loadFailed}<span class="block text-[11px] text-destructive">
149
- {i18n.t("plugin.seo:imageUnavailable")}
150
- </span>{/if}
205
+ {/if}
206
+ </div>
207
+ {:else}
208
+ <div class="ridu-seo-image-selection" aria-invalid={controlARIA["aria-invalid"]}>
209
+ <div class="ridu-seo-image-content">
210
+ <span class="ridu-seo-image-thumbnail">
211
+ {#if typeof document?.url === "string" && String(document?.mimeType ?? "").startsWith("image/")}
212
+ <img src={document.url} alt="" />
213
+ {:else}
214
+ <ImageIcon aria-hidden="true" />
215
+ {/if}
151
216
  </span>
152
- <span class="inline-flex shrink-0 items-center gap-1 text-[11px] font-medium">
153
- <SearchIcon aria-hidden="true" />
154
- {i18n.t("plugin.seo:inspectImage")}
217
+ <span class="ridu-seo-image-details">
218
+ {#if typeof document?.url === "string"}
219
+ <a class="ridu-seo-image-name" href={document.url} target="_blank" rel="noreferrer">
220
+ {documentLabel()}
221
+ </a>
222
+ {:else}
223
+ <span class="ridu-seo-image-name">{documentLabel()}</span>
224
+ {/if}
225
+ <span class="ridu-seo-image-metadata">{documentMetadata()}</span>
155
226
  </span>
156
- </button>
157
- {#if !field.admin.readOnly}<div class="flex shrink-0 gap-1">
158
- <Button
159
- variant="outline"
160
- size="xs"
161
- disabled={editingBlocked}
162
- onclick={() => openBrowser("select")}
163
- >
164
- <SearchIcon aria-hidden="true" />
165
- {i18n.t("plugin.seo:replaceImage")}
166
- </Button>
227
+ </div>
228
+ <div class="ridu-seo-image-actions">
229
+ <Button
230
+ id={field.id}
231
+ {...controlARIA}
232
+ variant="ghost"
233
+ size="icon-xs"
234
+ disabled={editingBlocked || target === undefined || ReferenceBrowser === undefined}
235
+ onclick={() => openBrowser("inspect")}
236
+ aria-label={i18n.t("plugin.seo:inspectImage")}
237
+ >
238
+ <PencilIcon />
239
+ </Button>
240
+ {#if !field.admin.readOnly}
167
241
  <Button
168
242
  variant="ghost"
169
243
  size="icon-xs"
@@ -173,22 +247,20 @@
173
247
  >
174
248
  <XIcon />
175
249
  </Button>
176
- </div>{/if}
250
+ {/if}
251
+ </div>
177
252
  </div>
178
253
  {/if}
179
- <div class="flex items-center gap-2">
180
- <span
181
- class={[
182
- "rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide",
183
- value === "" ? "bg-destructive/10 text-destructive" : "bg-success/12 text-success",
184
- ]}
185
- >
254
+ <div class="ridu-seo-image-status">
255
+ <span class="ridu-seo-pill" data-tone={value === "" ? "danger" : "success"}>
186
256
  {value === "" ? i18n.t("plugin.seo:noImage") : i18n.t("plugin.seo:good")}
187
257
  </span>
188
258
  </div>
189
- {#if generation.status === "error"}<p class="text-[12px] text-destructive" role="alert">
259
+ {#if generation.status === "error"}
260
+ <p class="ridu-seo-error" role="alert">
190
261
  {i18n.t("plugin.seo:generationFailed")}
191
- </p>{/if}
262
+ </p>
263
+ {/if}
192
264
  </FieldFrame>
193
265
  </div>
194
266
 
@@ -200,10 +272,12 @@
200
272
  hasMany={false}
201
273
  selectedIDs={value === "" ? [] : [value]}
202
274
  readOnly={browserMode === "inspect" ? (field.admin.readOnly ?? false) : false}
275
+ initialCreate={browserMode === "create"}
276
+ {...initialFile === undefined ? {} : { initialFile }}
203
277
  {...browserMode === "inspect" && document !== undefined ? { initialDocument: document } : {}}
204
278
  {...browserMode === "inspect" && value !== "" ? { initialDocumentID: value } : {}}
205
279
  locale={form.contentLocale ?? ""}
206
280
  onCommit={commit}
207
- onClose={() => (browserOpen = false)}
281
+ onClose={closeBrowser}
208
282
  />
209
283
  {/if}
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { GenerationController } from "@plugin-seo/generation-controller.svelte";
6
6
  import LengthIndicator from "@plugin-seo/length-indicator.svelte";
7
+ import "@plugin-seo/seo.scss";
7
8
  import type { LengthConfig } from "@plugin-seo/seo-config";
8
9
 
9
10
  let {
@@ -34,6 +35,19 @@
34
35
  </script>
35
36
 
36
37
  <div data-field-path={field.path}>
38
+ {#snippet headingAction()}
39
+ <span class="ridu-seo-heading-separator" aria-hidden="true">—</span>
40
+ <Button
41
+ variant="link"
42
+ size="xs"
43
+ class="ridu-seo-generate"
44
+ disabled={editingBlocked || generation.status === "pending"}
45
+ aria-busy={generation.status === "pending"}
46
+ onclick={generate}
47
+ >
48
+ {i18n.t("plugin.seo:autoGenerate")}
49
+ </Button>
50
+ {/snippet}
37
51
  <FieldFrame
38
52
  controlID={field.id}
39
53
  label={field.admin.label}
@@ -41,31 +55,21 @@
41
55
  readOnly={field.admin.readOnly}
42
56
  description={field.admin.description}
43
57
  errors={issues.map((issue) => issue.message)}
58
+ class="ridu-seo-field"
59
+ headingAction={config.generate ? headingAction : undefined}
44
60
  >
45
- <div class="-mt-1 flex flex-wrap items-center gap-x-1 text-[11.5px] text-foreground-muted">
61
+ <p class="ridu-seo-guidance">
46
62
  <span>{i18n.t("plugin.seo:lengthTipTitle", { minLength, maxLength })}</span>
47
63
  <a
48
- class="text-primary underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-ring/70"
64
+ class="ridu-seo-guidance-link"
49
65
  href="https://developers.google.com/search/docs/appearance/title-link#page-titles"
50
66
  target="_blank"
51
67
  rel="noopener noreferrer"
52
68
  >
53
69
  {i18n.t("plugin.seo:bestPractices")}
54
70
  </a>
55
- {#if config.generate}
56
- <span aria-hidden="true">·</span>
57
- <Button
58
- variant="link"
59
- size="xs"
60
- class="h-auto px-0"
61
- disabled={editingBlocked || generation.status === "pending"}
62
- aria-busy={generation.status === "pending"}
63
- onclick={generate}
64
- >
65
- {i18n.t("plugin.seo:autoGenerate")}
66
- </Button>
67
- {/if}
68
- </div>
71
+ <span>.</span>
72
+ </p>
69
73
  <Input
70
74
  id={field.id}
71
75
  name={field.path}
@@ -80,8 +84,10 @@
80
84
  oninput={(event) => binding.set(event.currentTarget.value)}
81
85
  />
82
86
  <LengthIndicator text={value} {minLength} {maxLength} {i18n} />
83
- {#if generation.status === "error"}<p class="text-[12px] text-destructive" role="alert">
87
+ {#if generation.status === "error"}
88
+ <p class="ridu-seo-error" role="alert">
84
89
  {i18n.t("plugin.seo:generationFailed")}
85
- </p>{/if}
90
+ </p>
91
+ {/if}
86
92
  </FieldFrame>
87
93
  </div>
@@ -1,7 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { PluginFieldProps } from "@riducms/plugin";
3
- import CheckIcon from "~icons/lucide/check";
4
- import XIcon from "~icons/lucide/x";
3
+ import "@plugin-seo/seo.scss";
5
4
 
6
5
  import type { OverviewConfig } from "@plugin-seo/seo-config";
7
6
 
@@ -17,57 +16,22 @@
17
16
  const description = $derived(form.get(config.descriptionPath));
18
17
  const image = $derived(form.get(config.imagePath));
19
18
  const checks = $derived([
20
- {
21
- key: "title",
22
- label: i18n.t("plugin.seo:title"),
23
- passing:
24
- typeof title === "string" &&
25
- title.length >= config.titleMin &&
26
- title.length <= config.titleMax,
27
- },
28
- {
29
- key: "description",
30
- label: i18n.t("plugin.seo:description"),
31
- passing:
32
- typeof description === "string" &&
33
- description.length >= config.descriptionMin &&
34
- description.length <= config.descriptionMax,
35
- },
36
- { key: "image", label: i18n.t("plugin.seo:image"), passing: Boolean(image) },
19
+ typeof title === "string" && title.length >= config.titleMin && title.length <= config.titleMax,
20
+ typeof description === "string" &&
21
+ description.length >= config.descriptionMin &&
22
+ description.length <= config.descriptionMax,
23
+ Boolean(image),
37
24
  ]);
38
- const passing = $derived(checks.filter((check) => check.passing).length);
25
+ const passing = $derived(checks.filter(Boolean).length);
39
26
  </script>
40
27
 
41
28
  <section
42
- class="rounded-[4px] border border-control-border bg-control/35 p-3.5"
43
- aria-labelledby={`${field.id}-heading`}
29
+ class="ridu-seo-overview"
30
+ aria-label={field.admin.label}
44
31
  data-seo-overview
45
32
  data-field-path={field.path}
46
33
  >
47
- <div class="flex flex-wrap items-center justify-between gap-2">
48
- <h3 id={`${field.id}-heading`} class="text-[13px] font-semibold text-foreground-strong">
49
- {field.admin.label}
50
- </h3>
51
- <p class="font-mono text-[11px] text-foreground-muted" aria-live="polite">
52
- {i18n.t("plugin.seo:checksPassing", { current: passing, max: checks.length })}
53
- </p>
54
- </div>
55
- <ul class="mt-2 flex flex-wrap gap-1.5">
56
- {#each checks as check (check.key)}
57
- <li
58
- class={[
59
- "inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px]",
60
- check.passing ? "bg-success/12 text-success" : "bg-destructive/10 text-destructive",
61
- ]}
62
- data-seo-check={check.key}
63
- data-passing={check.passing}
64
- >
65
- {#if check.passing}<CheckIcon class="size-3" aria-hidden="true" />{:else}<XIcon
66
- class="size-3"
67
- aria-hidden="true"
68
- />{/if}
69
- <span>{check.label}</span>
70
- </li>
71
- {/each}
72
- </ul>
34
+ <p aria-live="polite">
35
+ {i18n.t("plugin.seo:checksPassing", { current: passing, max: checks.length })}
36
+ </p>
73
37
  </section>
@@ -2,6 +2,7 @@
2
2
  import type { PluginFieldProps } from "@riducms/plugin";
3
3
 
4
4
  import { GenerationController } from "@plugin-seo/generation-controller.svelte";
5
+ import "@plugin-seo/seo.scss";
5
6
  import type { PreviewConfig } from "@plugin-seo/seo-config";
6
7
 
7
8
  let {
@@ -36,28 +37,27 @@
36
37
  </script>
37
38
 
38
39
  <section
39
- class="grid gap-2"
40
+ class="ridu-seo-preview"
40
41
  aria-labelledby={`${field.id}-heading`}
41
42
  data-seo-preview
42
43
  data-field-path={field.path}
43
44
  >
44
45
  <div>
45
- <h3 id={`${field.id}-heading`} class="text-[13px] font-semibold text-foreground-strong">
46
+ <h3 id={`${field.id}-heading`} class="ridu-seo-preview-heading">
46
47
  {i18n.t("plugin.seo:preview")}
47
48
  </h3>
48
- <p class="mt-0.5 text-[12px] text-foreground-muted">
49
+ <p class="ridu-seo-preview-description">
49
50
  {i18n.t("plugin.seo:previewDescription")}
50
51
  </p>
51
52
  </div>
52
- <div
53
- class="w-full max-w-[600px] overflow-hidden rounded-[5px] border border-control-border bg-background p-4 shadow-sm"
54
- aria-busy={generation.status === "pending"}
55
- >
56
- <p class="truncate text-[12px] text-success">{href || "https://..."}</p>
57
- <p class="mt-1 truncate text-[18px] leading-6 text-primary">{title}</p>
58
- <p class="mt-0.5 line-clamp-2 text-[13px] leading-5 text-foreground-muted">{description}</p>
53
+ <div class="ridu-seo-preview-card" aria-busy={generation.status === "pending"}>
54
+ <p class="ridu-seo-preview-url">{href || "https://..."}</p>
55
+ <p class="ridu-seo-preview-title">{title}</p>
56
+ <p class="ridu-seo-preview-copy">{description}</p>
59
57
  </div>
60
- {#if generation.status === "error"}<p class="text-[12px] text-destructive" role="alert">
58
+ {#if generation.status === "error"}
59
+ <p class="ridu-seo-error" role="alert">
61
60
  {i18n.t("plugin.seo:generationFailed")}
62
- </p>{/if}
61
+ </p>
62
+ {/if}
63
63
  </section>
package/src/seo.scss ADDED
@@ -0,0 +1,315 @@
1
+ @use "@riducms/ui/styles.scss" as ui;
2
+
3
+ @layer ridu.components {
4
+ .ridu-seo-overview,
5
+ .ridu-seo-field,
6
+ .ridu-seo-preview {
7
+ min-width: 0;
8
+ }
9
+
10
+ .ridu-seo-field,
11
+ .ridu-seo-field .ridu-field-control {
12
+ gap: 0;
13
+ }
14
+
15
+ .ridu-seo-overview {
16
+ color: var(--foreground);
17
+ font-size: var(--font-size-body);
18
+ line-height: var(--line-height-body);
19
+ }
20
+ .ridu-seo-overview p {
21
+ margin: 0;
22
+ }
23
+
24
+ .ridu-seo-heading-separator {
25
+ color: var(--ink-muted);
26
+ }
27
+ .ridu-seo-generate.ridu-button {
28
+ block-size: auto;
29
+ border: 0;
30
+ padding: 0;
31
+ color: currentColor;
32
+ font-size: inherit;
33
+ line-height: inherit;
34
+ }
35
+ .ridu-seo-guidance {
36
+ margin: 0 0 5px;
37
+ color: var(--ink-muted);
38
+ font-size: var(--font-size-body);
39
+ line-height: var(--line-height-body);
40
+ }
41
+ .ridu-seo-guidance-link {
42
+ color: inherit;
43
+ text-decoration: underline;
44
+ text-underline-offset: 2px;
45
+ }
46
+ .ridu-seo-guidance-link:focus-visible {
47
+ @include ui.focus-outline;
48
+ }
49
+ .ridu-seo-description[data-empty="false"] {
50
+ min-height: 0;
51
+ }
52
+ .ridu-seo-error {
53
+ margin: 0;
54
+ color: var(--destructive);
55
+ font-size: var(--font-size-body);
56
+ line-height: var(--line-height-body);
57
+ }
58
+
59
+ .ridu-seo-length {
60
+ margin-block-start: 10px;
61
+ display: grid;
62
+ grid-template-columns: auto auto minmax(32px, 1fr);
63
+ align-items: center;
64
+ gap: 10px;
65
+ min-width: 0;
66
+ }
67
+ .ridu-seo-pill {
68
+ display: inline-flex;
69
+ align-items: center;
70
+ flex-shrink: 0;
71
+ inline-size: max-content;
72
+ border-radius: 2px;
73
+ padding: 4px 6px;
74
+ font-size: 11px;
75
+ font-weight: 400;
76
+ line-height: 1;
77
+ white-space: nowrap;
78
+ }
79
+ .ridu-seo-pill[data-tone="danger"],
80
+ .ridu-seo-pill[data-status="missing"],
81
+ .ridu-seo-pill[data-status="tooLong"] {
82
+ background: var(--seo-status-danger);
83
+ color: var(--seo-status-danger-foreground);
84
+ }
85
+ .ridu-seo-pill[data-status="tooShort"] {
86
+ background: var(--seo-status-short);
87
+ color: var(--seo-status-short-foreground);
88
+ }
89
+ .ridu-seo-pill[data-tone="warning"],
90
+ .ridu-seo-pill[data-status="almostThere"] {
91
+ background: var(--seo-status-warning);
92
+ color: var(--seo-status-warning-foreground);
93
+ }
94
+ .ridu-seo-pill[data-tone="success"],
95
+ .ridu-seo-pill[data-status="good"] {
96
+ background: var(--seo-status-success);
97
+ color: var(--seo-status-success-foreground);
98
+ }
99
+ .ridu-seo-length-copy {
100
+ color: var(--foreground);
101
+ font-size: 11px;
102
+ line-height: 1;
103
+ white-space: nowrap;
104
+ }
105
+ .ridu-seo-progress {
106
+ position: relative;
107
+ height: 2px;
108
+ min-width: 32px;
109
+ overflow: hidden;
110
+ background: var(--seo-status-track);
111
+ }
112
+ .ridu-seo-progress-value {
113
+ display: block;
114
+ width: 100%;
115
+ height: 100%;
116
+ transform-origin: left center;
117
+ transition: transform 150ms ease;
118
+ }
119
+ .ridu-seo-length:dir(rtl) .ridu-seo-progress-value {
120
+ transform-origin: right center;
121
+ }
122
+ .ridu-seo-progress-value[data-status="missing"],
123
+ .ridu-seo-progress-value[data-status="tooLong"] {
124
+ background: var(--seo-status-danger);
125
+ }
126
+ .ridu-seo-progress-value[data-status="tooShort"] {
127
+ background: var(--seo-status-short);
128
+ }
129
+ .ridu-seo-progress-value[data-status="almostThere"] {
130
+ background: var(--seo-status-warning);
131
+ }
132
+ .ridu-seo-progress-value[data-status="good"] {
133
+ background: var(--seo-status-success);
134
+ }
135
+
136
+ .ridu-seo-image-intake {
137
+ display: flex;
138
+ align-items: center;
139
+ flex-wrap: wrap;
140
+ gap: 10px;
141
+ min-height: 60px;
142
+ border: 1px dashed var(--control-border);
143
+ border-radius: var(--control-radius);
144
+ padding: 15px;
145
+ color: var(--foreground-muted);
146
+ }
147
+ .ridu-seo-image-intake[data-invalid="true"] {
148
+ border-color: var(--control-invalid-border);
149
+ background: var(--control-invalid-surface);
150
+ }
151
+ .ridu-seo-image-intake[data-dragging="true"] {
152
+ border-color: var(--foreground);
153
+ background: var(--control-surface);
154
+ }
155
+ .ridu-seo-image-intake-copy {
156
+ margin-inline-start: auto;
157
+ font-size: var(--font-size-body);
158
+ line-height: var(--line-height-body);
159
+ }
160
+ .ridu-seo-image-selection {
161
+ display: flex;
162
+ align-items: center;
163
+ gap: 10px;
164
+ box-sizing: border-box;
165
+ height: 60px;
166
+ min-width: 0;
167
+ border: 1px solid var(--control-border);
168
+ border-radius: var(--control-radius);
169
+ padding: 10px;
170
+ background: var(--control-surface);
171
+ }
172
+ .ridu-seo-image-selection[aria-invalid="true"] {
173
+ border-color: var(--control-invalid-border);
174
+ background: var(--control-invalid-surface);
175
+ }
176
+ .ridu-seo-image-content {
177
+ display: flex;
178
+ align-items: center;
179
+ flex: 1;
180
+ gap: 10px;
181
+ min-width: 0;
182
+ }
183
+ .ridu-seo-image-thumbnail {
184
+ display: grid;
185
+ place-items: center;
186
+ flex-shrink: 0;
187
+ width: 40px;
188
+ height: 40px;
189
+ overflow: hidden;
190
+ border-radius: var(--control-radius);
191
+ background: var(--background);
192
+ color: var(--ink-faint);
193
+ }
194
+ .ridu-seo-image-thumbnail img {
195
+ width: 100%;
196
+ height: 100%;
197
+ object-fit: cover;
198
+ }
199
+ .ridu-seo-image-thumbnail svg {
200
+ width: 14px;
201
+ height: 14px;
202
+ }
203
+ .ridu-seo-image-details {
204
+ display: flex;
205
+ flex-direction: column;
206
+ min-width: 0;
207
+ flex: 1;
208
+ }
209
+ .ridu-seo-image-name,
210
+ .ridu-seo-image-metadata {
211
+ display: block;
212
+ overflow: hidden;
213
+ text-overflow: ellipsis;
214
+ white-space: nowrap;
215
+ }
216
+ .ridu-seo-image-name {
217
+ color: var(--ink-strong);
218
+ font-size: var(--font-size-body);
219
+ line-height: var(--line-height-body);
220
+ text-decoration: none;
221
+ }
222
+ .ridu-seo-image-name:focus-visible {
223
+ @include ui.focus-outline;
224
+ }
225
+ .ridu-seo-image-metadata {
226
+ color: var(--ink-muted);
227
+ font-size: var(--font-size-body);
228
+ line-height: var(--line-height-body);
229
+ }
230
+ .ridu-seo-image-actions {
231
+ display: flex;
232
+ flex-shrink: 0;
233
+
234
+ .ridu-button {
235
+ border: 0;
236
+ }
237
+ }
238
+ .ridu-seo-image-status {
239
+ display: flex;
240
+ align-items: center;
241
+ margin-block-start: 10px;
242
+ }
243
+
244
+ .ridu-seo-preview {
245
+ display: grid;
246
+ gap: 0;
247
+ }
248
+ .ridu-seo-preview-heading,
249
+ .ridu-seo-preview-description,
250
+ .ridu-seo-preview-url,
251
+ .ridu-seo-preview-title,
252
+ .ridu-seo-preview-copy {
253
+ margin: 0;
254
+ }
255
+ .ridu-seo-preview-heading {
256
+ color: var(--ink-label);
257
+ font-size: var(--font-size-body);
258
+ font-weight: 400;
259
+ line-height: var(--line-height-body);
260
+ }
261
+ .ridu-seo-preview-description {
262
+ margin-block-end: 5px;
263
+ color: var(--ink-muted);
264
+ font-size: var(--font-size-body);
265
+ line-height: var(--line-height-body);
266
+ }
267
+ .ridu-seo-preview-card {
268
+ box-sizing: border-box;
269
+ width: 100%;
270
+ max-width: 600px;
271
+ overflow: hidden;
272
+ border-radius: 5px;
273
+ padding: 20px;
274
+ background: var(--control-surface);
275
+ box-shadow: var(--seo-preview-shadow);
276
+ pointer-events: none;
277
+ }
278
+ .ridu-seo-preview-url,
279
+ .ridu-seo-preview-title,
280
+ .ridu-seo-preview-copy {
281
+ overflow-wrap: anywhere;
282
+ color: var(--foreground);
283
+ }
284
+ .ridu-seo-preview-url {
285
+ font-size: var(--font-size-body);
286
+ line-height: var(--line-height-body);
287
+ }
288
+ .ridu-seo-preview-title {
289
+ font-size: 16px;
290
+ font-weight: 500;
291
+ line-height: 20px;
292
+ }
293
+ .ridu-seo-preview-copy {
294
+ font-size: var(--font-size-body);
295
+ line-height: var(--line-height-body);
296
+ }
297
+
298
+ @media (max-width: 30rem) {
299
+ .ridu-seo-length {
300
+ grid-template-columns: auto minmax(0, 1fr);
301
+ }
302
+ .ridu-seo-progress {
303
+ grid-column: 1 / -1;
304
+ }
305
+ .ridu-seo-image-intake-copy {
306
+ display: none;
307
+ }
308
+ }
309
+
310
+ @media (prefers-reduced-motion: reduce) {
311
+ .ridu-seo-progress-value {
312
+ transition: none;
313
+ }
314
+ }
315
+ }