@riducms/plugin-seo 0.2.3 → 0.4.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 +9 -4
- package/package.json +5 -5
- package/src/index.ts +1 -1
- package/src/length-indicator.svelte +7 -23
- package/src/messages.ts +33 -16
- package/src/meta-description-field.svelte +26 -18
- package/src/meta-image-field.svelte +149 -75
- package/src/meta-title-field.svelte +24 -18
- package/src/overview-field.svelte +12 -48
- package/src/preview-field.svelte +12 -12
- package/src/seo.scss +315 -0
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
7
|
-
"@riducms/protocol": "0.
|
|
8
|
-
"@riducms/ui": "0.
|
|
6
|
+
"@riducms/plugin": "0.4.0",
|
|
7
|
+
"@riducms/protocol": "0.4.0",
|
|
8
|
+
"@riducms/ui": "0.4.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.
|
|
13
|
+
"@riducms/build": "0.4.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.
|
|
40
|
+
"version": "0.4.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
|
-
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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: "
|
|
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:
|
|
21
|
-
|
|
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: "
|
|
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: "
|
|
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:
|
|
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:
|
|
52
|
-
|
|
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:
|
|
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:
|
|
83
|
-
|
|
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
|
-
<
|
|
61
|
+
<p class="ridu-seo-guidance">
|
|
46
62
|
<span>{i18n.t("plugin.seo:lengthTipDescription", { minLength, maxLength })}</span>
|
|
47
63
|
<a
|
|
48
|
-
class="
|
|
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
|
-
|
|
56
|
-
|
|
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"}
|
|
89
|
+
{#if generation.status === "error"}
|
|
90
|
+
<p class="ridu-seo-error" role="alert">
|
|
84
91
|
{i18n.t("plugin.seo:generationFailed")}
|
|
85
|
-
</p>
|
|
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
|
|
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}
|
|
97
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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="
|
|
127
|
-
|
|
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
|
-
|
|
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:
|
|
192
|
+
aria-label={i18n.t("plugin.seo:chooseExistingImage")}
|
|
132
193
|
{...controlARIA}
|
|
133
|
-
|
|
134
|
-
|
|
194
|
+
variant="secondary"
|
|
195
|
+
size="sm"
|
|
135
196
|
disabled={editingBlocked || target === undefined || ReferenceBrowser === undefined}
|
|
136
|
-
onclick={() => openBrowser("
|
|
197
|
+
onclick={() => openBrowser("select")}
|
|
137
198
|
>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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="
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
</
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
250
|
+
{/if}
|
|
251
|
+
</div>
|
|
177
252
|
</div>
|
|
178
253
|
{/if}
|
|
179
|
-
<div class="
|
|
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"}
|
|
259
|
+
{#if generation.status === "error"}
|
|
260
|
+
<p class="ridu-seo-error" role="alert">
|
|
190
261
|
{i18n.t("plugin.seo:generationFailed")}
|
|
191
|
-
</p>
|
|
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={
|
|
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
|
-
<
|
|
61
|
+
<p class="ridu-seo-guidance">
|
|
46
62
|
<span>{i18n.t("plugin.seo:lengthTipTitle", { minLength, maxLength })}</span>
|
|
47
63
|
<a
|
|
48
|
-
class="
|
|
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
|
-
|
|
56
|
-
|
|
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"}
|
|
87
|
+
{#if generation.status === "error"}
|
|
88
|
+
<p class="ridu-seo-error" role="alert">
|
|
84
89
|
{i18n.t("plugin.seo:generationFailed")}
|
|
85
|
-
</p>
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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(
|
|
25
|
+
const passing = $derived(checks.filter(Boolean).length);
|
|
39
26
|
</script>
|
|
40
27
|
|
|
41
28
|
<section
|
|
42
|
-
class="
|
|
43
|
-
aria-
|
|
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
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
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>
|
package/src/preview-field.svelte
CHANGED
|
@@ -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="
|
|
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="
|
|
46
|
+
<h3 id={`${field.id}-heading`} class="ridu-seo-preview-heading">
|
|
46
47
|
{i18n.t("plugin.seo:preview")}
|
|
47
48
|
</h3>
|
|
48
|
-
<p class="
|
|
49
|
+
<p class="ridu-seo-preview-description">
|
|
49
50
|
{i18n.t("plugin.seo:previewDescription")}
|
|
50
51
|
</p>
|
|
51
52
|
</div>
|
|
52
|
-
<div
|
|
53
|
-
class="
|
|
54
|
-
|
|
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"}
|
|
58
|
+
{#if generation.status === "error"}
|
|
59
|
+
<p class="ridu-seo-error" role="alert">
|
|
61
60
|
{i18n.t("plugin.seo:generationFailed")}
|
|
62
|
-
</p>
|
|
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
|
+
}
|