@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 +21 -0
- package/README.md +24 -0
- package/package.json +41 -0
- package/src/generation-controller.svelte.ts +84 -0
- package/src/generation-snapshot.ts +17 -0
- package/src/index.ts +59 -0
- package/src/length-indicator.svelte +57 -0
- package/src/length-indicator.ts +32 -0
- package/src/messages.ts +96 -0
- package/src/meta-description-field.svelte +78 -0
- package/src/meta-image-field.svelte +190 -0
- package/src/meta-title-field.svelte +78 -0
- package/src/overview-field.svelte +67 -0
- package/src/preview-field.svelte +56 -0
- package/src/seo-config.ts +73 -0
- package/src/vite-env.d.ts +7 -0
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
|
+
}
|
package/src/messages.ts
ADDED
|
@@ -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
|
+
}
|