@riducms/plugin-seo 0.1.4 → 0.2.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/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.1.4",
7
- "@riducms/protocol": "0.1.4",
8
- "@riducms/ui": "0.1.4",
6
+ "@riducms/plugin": "0.2.0",
7
+ "@riducms/protocol": "0.2.0",
8
+ "@riducms/ui": "0.2.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.1.4"
13
+ "@riducms/build": "0.2.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.1.4"
40
+ "version": "0.2.0"
41
41
  }
@@ -1,4 +1,4 @@
1
- import type { FieldAuthoringHost, FieldForm } from "@riducms/plugin";
1
+ import type { FieldAuthoringHost, FieldDocumentForm } from "@riducms/plugin";
2
2
 
3
3
  import { generationScopeToken, generationSnapshotToken } from "@plugin-seo/generation-snapshot";
4
4
 
@@ -13,7 +13,7 @@ export class GenerationController {
13
13
 
14
14
  async run(
15
15
  authoring: FieldAuthoringHost | undefined,
16
- form: FieldForm,
16
+ form: FieldDocumentForm,
17
17
  path: string
18
18
  ): Promise<string | undefined> {
19
19
  const requestPlugin = authoring?.requestPlugin;
package/src/index.ts CHANGED
@@ -1,4 +1,12 @@
1
- import { ADMIN_PLUGIN_API_VERSION, defineAdminPlugin, defineFieldPlugin } from "@riducms/plugin";
1
+ import { defineAdminPlugin, defineFieldComponent } from "@riducms/plugin/authoring/v1";
2
+ import {
3
+ decodeString,
4
+ decodeUI,
5
+ decodeLengthConfig,
6
+ decodeOverviewConfig,
7
+ decodeImageConfig,
8
+ decodePreviewConfig,
9
+ } from "@plugin-seo/seo-config";
2
10
 
3
11
  import { seoMessages } from "@plugin-seo/messages";
4
12
 
@@ -8,49 +16,41 @@ import MetaTitleField from "@plugin-seo/meta-title-field.svelte";
8
16
  import OverviewField from "@plugin-seo/overview-field.svelte";
9
17
  import PreviewField from "@plugin-seo/preview-field.svelte";
10
18
 
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
19
  export const seoAdminPlugin = defineAdminPlugin({
50
- apiVersion: ADMIN_PLUGIN_API_VERSION,
51
20
  key: "seo",
52
21
  pairingVersion: 1,
53
- fields: seoFieldPlugins,
22
+ components: {
23
+ overview: defineFieldComponent({
24
+ type: "ui",
25
+ component: OverviewField,
26
+ decodeValue: decodeUI,
27
+ decodeConfig: decodeOverviewConfig,
28
+ }),
29
+ title: defineFieldComponent({
30
+ type: "text",
31
+ component: MetaTitleField,
32
+ decodeValue: decodeString,
33
+ decodeConfig: (raw: unknown) => decodeLengthConfig(raw, "text"),
34
+ }),
35
+ description: defineFieldComponent({
36
+ type: "textarea",
37
+ component: MetaDescriptionField,
38
+ decodeValue: decodeString,
39
+ decodeConfig: (raw: unknown) => decodeLengthConfig(raw, "textarea"),
40
+ }),
41
+ image: defineFieldComponent({
42
+ type: "upload",
43
+ component: MetaImageField,
44
+ decodeValue: decodeString,
45
+ decodeConfig: decodeImageConfig,
46
+ }),
47
+ preview: defineFieldComponent({
48
+ type: "ui",
49
+ component: PreviewField,
50
+ decodeValue: decodeUI,
51
+ decodeConfig: decodePreviewConfig,
52
+ }),
53
+ },
54
54
  messages: seoMessages,
55
55
  });
56
56
 
@@ -1,26 +1,35 @@
1
1
  <script lang="ts">
2
- import type { FieldComponentProps } from "@riducms/plugin";
3
- import { Button, FieldFrame, Textarea } from "@riducms/ui";
2
+ import type { PluginFieldProps } from "@riducms/plugin";
3
+ import { Button, FieldFrame, Textarea, fieldControlARIA } from "@riducms/ui";
4
4
 
5
5
  import { GenerationController } from "@plugin-seo/generation-controller.svelte";
6
6
  import LengthIndicator from "@plugin-seo/length-indicator.svelte";
7
- import { lengthConfig } from "@plugin-seo/seo-config";
7
+ import type { LengthConfig } from "@plugin-seo/seo-config";
8
+
9
+ let {
10
+ field: binding,
11
+ form,
12
+ config,
13
+ i18n,
14
+ authoring,
15
+ }: PluginFieldProps<string, LengthConfig, "textarea"> = $props();
16
+ const field = $derived(binding.schema);
17
+ const editingBlocked = $derived(binding.readOnly);
8
18
 
9
- let { field, form, i18n, authoring }: FieldComponentProps = $props();
10
- const config = $derived(lengthConfig(field));
11
19
  const minLength = $derived(field.textarea?.minLength ?? config.minLength);
12
20
  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);
21
+ const value = $derived(String(binding.value ?? ""));
22
+ const issues = $derived(binding.issues);
23
+ const inputARIA = $derived(
24
+ fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
25
+ );
16
26
  const generation = new GenerationController();
17
27
 
18
- $effect(() => form.register(field.path));
19
28
  $effect(() => () => generation.cancel());
20
29
 
21
30
  async function generate() {
22
31
  const result = await generation.run(authoring, form, "generate-description");
23
- if (result !== undefined) form.set(field.path, result);
32
+ if (result !== undefined) binding.set(result);
24
33
  }
25
34
  </script>
26
35
 
@@ -49,7 +58,7 @@
49
58
  variant="link"
50
59
  size="xs"
51
60
  class="h-auto px-0"
52
- disabled={field.admin.readOnly || generation.status === "pending"}
61
+ disabled={editingBlocked || generation.status === "pending"}
53
62
  aria-busy={generation.status === "pending"}
54
63
  onclick={generate}
55
64
  >
@@ -61,14 +70,14 @@
61
70
  id={field.id}
62
71
  name={field.path}
63
72
  required={field.required}
64
- readonly={field.admin.readOnly}
73
+ readonly={editingBlocked}
65
74
  minlength={field.textarea?.minLength}
66
75
  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}
76
+ aria-invalid={inputARIA["aria-invalid"]}
77
+ aria-describedby={inputARIA["aria-describedby"]}
78
+ aria-errormessage={inputARIA["aria-errormessage"]}
70
79
  {value}
71
- oninput={(event) => form.set(field.path, event.currentTarget.value)}
80
+ oninput={(event) => binding.set(event.currentTarget.value)}
72
81
  />
73
82
  <LengthIndicator text={value} {minLength} {maxLength} {i18n} />
74
83
  {#if generation.status === "error"}<p class="text-[12px] text-destructive" role="alert">
@@ -1,17 +1,27 @@
1
1
  <script lang="ts">
2
- import type { FieldComponentProps, FieldDocument } from "@riducms/plugin";
3
- import { Button, FieldFrame } from "@riducms/ui";
2
+ import type { PluginFieldProps, FieldDocument } from "@riducms/plugin";
3
+ import { Button, FieldFrame, fieldControlARIA } from "@riducms/ui";
4
4
  import ImageIcon from "~icons/lucide/image";
5
5
  import SearchIcon from "~icons/lucide/search";
6
6
  import XIcon from "~icons/lucide/x";
7
7
 
8
8
  import { GenerationController } from "@plugin-seo/generation-controller.svelte";
9
- import { imageGenerationEnabled } from "@plugin-seo/seo-config";
10
9
 
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));
10
+ let {
11
+ field: binding,
12
+ form,
13
+ config,
14
+ i18n,
15
+ authoring,
16
+ }: PluginFieldProps<string, { generate: boolean }, "upload"> = $props();
17
+ const field = $derived(binding.schema);
18
+ const editingBlocked = $derived(binding.readOnly);
19
+ const generationEnabled = $derived(config.generate);
20
+ const value = $derived(String(binding.value ?? ""));
21
+ const issues = $derived(binding.issues);
22
+ const controlARIA = $derived(
23
+ fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
24
+ );
15
25
  const target = $derived(
16
26
  authoring?.collections.find(
17
27
  (collection) =>
@@ -26,7 +36,6 @@
26
36
  let loadFailed = $state(false);
27
37
  const generation = new GenerationController();
28
38
 
29
- $effect(() => form.register(field.path));
30
39
  $effect(() => () => generation.cancel());
31
40
  $effect(() => {
32
41
  const id = value;
@@ -53,11 +62,11 @@
53
62
 
54
63
  async function generate() {
55
64
  const result = await generation.run(authoring, form, "generate-image");
56
- if (result !== undefined) form.set(field.path, result);
65
+ if (result !== undefined) binding.set(result);
57
66
  }
58
67
 
59
68
  function commit(ids: string[]) {
60
- form.set(field.path, ids[0] ?? null);
69
+ binding.set(ids[0] ?? null);
61
70
  browserOpen = false;
62
71
  }
63
72
 
@@ -92,7 +101,7 @@
92
101
  variant="link"
93
102
  size="xs"
94
103
  class="h-auto px-0"
95
- disabled={field.admin.readOnly || generation.status === "pending"}
104
+ disabled={editingBlocked || generation.status === "pending"}
96
105
  aria-busy={generation.status === "pending"}
97
106
  onclick={generate}
98
107
  >
@@ -103,8 +112,10 @@
103
112
  <Button
104
113
  id={field.id}
105
114
  aria-label={i18n.t("plugin.seo:selectImage")}
115
+ {...controlARIA}
106
116
  variant="outline"
107
- disabled={field.admin.readOnly || target === undefined || ReferenceBrowser === undefined}
117
+ class="aria-invalid:!border-destructive"
118
+ disabled={editingBlocked || target === undefined || ReferenceBrowser === undefined}
108
119
  onclick={() => openBrowser("select")}
109
120
  >
110
121
  <SearchIcon />
@@ -112,14 +123,16 @@
112
123
  </Button>
113
124
  {:else}
114
125
  <div
115
- class="flex min-w-0 items-center gap-2.5 rounded-[4px] border border-control-border bg-control p-2"
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"]}
116
128
  >
117
129
  <button
118
130
  id={field.id}
119
131
  aria-label={i18n.t("plugin.seo:inspectImage")}
132
+ {...controlARIA}
120
133
  type="button"
121
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"
122
- disabled={target === undefined || ReferenceBrowser === undefined}
135
+ disabled={editingBlocked || target === undefined || ReferenceBrowser === undefined}
123
136
  onclick={() => openBrowser("inspect")}
124
137
  >
125
138
  <span
@@ -142,14 +155,20 @@
142
155
  </span>
143
156
  </button>
144
157
  {#if !field.admin.readOnly}<div class="flex shrink-0 gap-1">
145
- <Button variant="outline" size="xs" onclick={() => openBrowser("select")}>
158
+ <Button
159
+ variant="outline"
160
+ size="xs"
161
+ disabled={editingBlocked}
162
+ onclick={() => openBrowser("select")}
163
+ >
146
164
  <SearchIcon aria-hidden="true" />
147
165
  {i18n.t("plugin.seo:replaceImage")}
148
166
  </Button>
149
167
  <Button
150
168
  variant="ghost"
151
169
  size="icon-xs"
152
- onclick={() => form.set(field.path, null)}
170
+ disabled={editingBlocked}
171
+ onclick={() => binding.set(null)}
153
172
  aria-label={i18n.t("plugin.seo:removeImage")}
154
173
  >
155
174
  <XIcon />
@@ -1,26 +1,35 @@
1
1
  <script lang="ts">
2
- import type { FieldComponentProps } from "@riducms/plugin";
3
- import { Button, FieldFrame, Input } from "@riducms/ui";
2
+ import type { PluginFieldProps } from "@riducms/plugin";
3
+ import { Button, FieldFrame, Input, fieldControlARIA } from "@riducms/ui";
4
4
 
5
5
  import { GenerationController } from "@plugin-seo/generation-controller.svelte";
6
6
  import LengthIndicator from "@plugin-seo/length-indicator.svelte";
7
- import { lengthConfig } from "@plugin-seo/seo-config";
7
+ import type { LengthConfig } from "@plugin-seo/seo-config";
8
+
9
+ let {
10
+ field: binding,
11
+ form,
12
+ config,
13
+ i18n,
14
+ authoring,
15
+ }: PluginFieldProps<string, LengthConfig, "text"> = $props();
16
+ const field = $derived(binding.schema);
17
+ const editingBlocked = $derived(binding.readOnly);
8
18
 
9
- let { field, form, i18n, authoring }: FieldComponentProps = $props();
10
- const config = $derived(lengthConfig(field));
11
19
  const minLength = $derived(field.text?.minLength ?? config.minLength);
12
20
  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);
21
+ const value = $derived(String(binding.value ?? ""));
22
+ const issues = $derived(binding.issues);
23
+ const inputARIA = $derived(
24
+ fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
25
+ );
16
26
  const generation = new GenerationController();
17
27
 
18
- $effect(() => form.register(field.path));
19
28
  $effect(() => () => generation.cancel());
20
29
 
21
30
  async function generate() {
22
31
  const result = await generation.run(authoring, form, "generate-title");
23
- if (result !== undefined) form.set(field.path, result);
32
+ if (result !== undefined) binding.set(result);
24
33
  }
25
34
  </script>
26
35
 
@@ -49,7 +58,7 @@
49
58
  variant="link"
50
59
  size="xs"
51
60
  class="h-auto px-0"
52
- disabled={field.admin.readOnly || generation.status === "pending"}
61
+ disabled={editingBlocked || generation.status === "pending"}
53
62
  aria-busy={generation.status === "pending"}
54
63
  onclick={generate}
55
64
  >
@@ -61,14 +70,14 @@
61
70
  id={field.id}
62
71
  name={field.path}
63
72
  required={field.required}
64
- readonly={field.admin.readOnly}
73
+ readonly={editingBlocked}
65
74
  minlength={field.text?.minLength}
66
75
  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}
76
+ aria-invalid={inputARIA["aria-invalid"]}
77
+ aria-describedby={inputARIA["aria-describedby"]}
78
+ aria-errormessage={inputARIA["aria-errormessage"]}
70
79
  {value}
71
- oninput={(event) => form.set(field.path, event.currentTarget.value)}
80
+ oninput={(event) => binding.set(event.currentTarget.value)}
72
81
  />
73
82
  <LengthIndicator text={value} {minLength} {maxLength} {i18n} />
74
83
  {#if generation.status === "error"}<p class="text-[12px] text-destructive" role="alert">
@@ -1,12 +1,18 @@
1
1
  <script lang="ts">
2
- import type { FieldComponentProps } from "@riducms/plugin";
2
+ import type { PluginFieldProps } from "@riducms/plugin";
3
3
  import CheckIcon from "~icons/lucide/check";
4
4
  import XIcon from "~icons/lucide/x";
5
5
 
6
- import { overviewConfig } from "@plugin-seo/seo-config";
6
+ import type { OverviewConfig } from "@plugin-seo/seo-config";
7
+
8
+ let {
9
+ field: binding,
10
+ form,
11
+ config,
12
+ i18n,
13
+ }: PluginFieldProps<undefined, OverviewConfig, "ui"> = $props();
14
+ const field = $derived(binding.schema);
7
15
 
8
- let { field, form, i18n }: FieldComponentProps = $props();
9
- const config = $derived(overviewConfig(field));
10
16
  const title = $derived(form.get(config.titlePath));
11
17
  const description = $derived(form.get(config.descriptionPath));
12
18
  const image = $derived(form.get(config.imagePath));
@@ -1,11 +1,18 @@
1
1
  <script lang="ts">
2
- import type { FieldComponentProps } from "@riducms/plugin";
2
+ import type { PluginFieldProps } from "@riducms/plugin";
3
3
 
4
4
  import { GenerationController } from "@plugin-seo/generation-controller.svelte";
5
- import { previewConfig } from "@plugin-seo/seo-config";
5
+ import type { PreviewConfig } from "@plugin-seo/seo-config";
6
+
7
+ let {
8
+ field: binding,
9
+ form,
10
+ config,
11
+ i18n,
12
+ authoring,
13
+ }: PluginFieldProps<undefined, PreviewConfig, "ui"> = $props();
14
+ const field = $derived(binding.schema);
6
15
 
7
- let { field, form, i18n, authoring }: FieldComponentProps = $props();
8
- const config = $derived(previewConfig(field));
9
16
  const title = $derived(String(form.get(config.titlePath) ?? ""));
10
17
  const description = $derived(String(form.get(config.descriptionPath) ?? ""));
11
18
  let href = $state("");
package/src/seo-config.ts CHANGED
@@ -1,5 +1,3 @@
1
- import type { SchemaField } from "@riducms/protocol";
2
-
3
1
  export interface LengthConfig {
4
2
  generate: boolean;
5
3
  minLength: number;
@@ -22,17 +20,25 @@ export interface PreviewConfig {
22
20
  descriptionPath: string;
23
21
  }
24
22
 
25
- export function lengthConfig(field: SchemaField): LengthConfig {
26
- const value = componentConfig(field);
23
+ export function decodeLengthConfig(raw: unknown, type: "text" | "textarea"): LengthConfig {
24
+ const value = componentConfig(raw, ["generate", "minLength", "maxLength"]);
27
25
  return {
28
26
  generate: value.generate === true,
29
- minLength: positiveInteger(value.minLength, field.type === "textarea" ? 100 : 50),
30
- maxLength: positiveInteger(value.maxLength, field.type === "textarea" ? 150 : 60),
27
+ minLength: positiveInteger(value.minLength, type === "textarea" ? 100 : 50),
28
+ maxLength: positiveInteger(value.maxLength, type === "textarea" ? 150 : 60),
31
29
  };
32
30
  }
33
31
 
34
- export function overviewConfig(field: SchemaField): OverviewConfig {
35
- const value = componentConfig(field);
32
+ export function decodeOverviewConfig(raw: unknown): OverviewConfig {
33
+ const value = componentConfig(raw, [
34
+ "titlePath",
35
+ "descriptionPath",
36
+ "imagePath",
37
+ "titleMin",
38
+ "titleMax",
39
+ "descriptionMin",
40
+ "descriptionMax",
41
+ ]);
36
42
  return {
37
43
  titlePath: stringValue(value.titlePath, "meta.title"),
38
44
  descriptionPath: stringValue(value.descriptionPath, "meta.description"),
@@ -44,8 +50,8 @@ export function overviewConfig(field: SchemaField): OverviewConfig {
44
50
  };
45
51
  }
46
52
 
47
- export function previewConfig(field: SchemaField): PreviewConfig {
48
- const value = componentConfig(field);
53
+ export function decodePreviewConfig(raw: unknown): PreviewConfig {
54
+ const value = componentConfig(raw, ["generate", "titlePath", "descriptionPath"]);
49
55
  return {
50
56
  generate: value.generate === true,
51
57
  titlePath: stringValue(value.titlePath, "meta.title"),
@@ -53,21 +59,38 @@ export function previewConfig(field: SchemaField): PreviewConfig {
53
59
  };
54
60
  }
55
61
 
56
- export function imageGenerationEnabled(field: SchemaField) {
57
- return componentConfig(field).generate === true;
62
+ export function decodeImageConfig(raw: unknown): { generate: boolean } {
63
+ return { generate: componentConfig(raw, ["generate"]).generate === true };
58
64
  }
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
+ function componentConfig(raw: unknown, allowed: readonly string[]): Record<string, unknown> {
66
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
67
+ throw new Error("SEO config must be an object.");
68
+ const value = raw as Record<string, unknown>;
69
+ for (const key of Object.keys(value))
70
+ if (!allowed.includes(key)) throw new Error(`Unsupported SEO config key ${key}.`);
71
+ if (value.generate !== undefined && typeof value.generate !== "boolean")
72
+ throw new Error("generate must be a boolean.");
73
+ return value;
74
+ }
75
+ export function decodeString(value: unknown): string {
76
+ if (typeof value !== "string") throw new Error("Expected a string value.");
77
+ return value;
78
+ }
79
+ export function decodeUI(value: unknown): undefined {
80
+ if (value !== undefined) throw new Error("UI fields have no stored value.");
81
+ return undefined;
65
82
  }
66
83
 
67
84
  function stringValue(value: unknown, fallback: string) {
68
- return typeof value === "string" && value.length > 0 ? value : fallback;
85
+ if (value === undefined) return fallback;
86
+ if (typeof value !== "string" || !value.length)
87
+ throw new Error("Expected a nonempty string config value.");
88
+ return value;
69
89
  }
70
90
 
71
91
  function positiveInteger(value: unknown, fallback: number) {
72
- return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
92
+ if (value === undefined) return fallback;
93
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0)
94
+ throw new Error("Expected a positive integer config value.");
95
+ return value;
73
96
  }