@pramen/cms-editor 0.0.47 → 0.0.48

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.47",
3
+ "version": "0.0.48",
4
4
  "description": "Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -5,7 +5,7 @@
5
5
  import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Textarea } from "@podoba/react";
6
6
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
7
7
  import { Api, ApiError } from "./api";
8
- import { FieldForm } from "./fields";
8
+ import { FieldForm, slugify } from "./fields";
9
9
  import type { Config } from "./api";
10
10
  import type { Me } from "./app-context";
11
11
  import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, Media, Page, RegionDefinition, RenderedBlock } from "./types";
@@ -1467,9 +1467,6 @@ export function errMsg(e: unknown): string {
1467
1467
  if (e instanceof ApiError) return e.message;
1468
1468
  return e instanceof Error ? e.message : String(e);
1469
1469
  }
1470
- function slugify(s: string): string {
1471
- return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1472
- }
1473
1470
  /** Strip HTML tags + decode the few entities the WYSIWYG emits, for a clean text preview —
1474
1471
  * so a collapsed rich_text block reads "Test Toakdopwad" instead of "<b>Test</b>&nbsp;…". */
1475
1472
  function plainText(html: string): string {
package/src/fields.tsx CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Text, Textarea } from "@podoba/react";
5
5
  import { BlockEditor } from "@podoba/react/editor";
6
- import { useEffect, useState, type DragEvent, type ReactNode } from "react";
6
+ import { useEffect, useRef, useState, type DragEvent, type ReactNode } from "react";
7
7
  import type { Api } from "./api";
8
8
  import type { FieldDefinition, Media } from "./types";
9
9
 
@@ -152,7 +152,8 @@ export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefin
152
152
  return (
153
153
  <div className="flex flex-col gap-4">
154
154
  {schema.map((def) => (
155
- <FieldInput key={def.name} def={def} value={value[def.name]} onChange={(v) => set(def.name, v)} api={api} />
155
+ // `siblings` is only read by field types that derive from another field (slug).
156
+ <FieldInput key={def.name} def={def} value={value[def.name]} onChange={(v) => set(def.name, v)} api={api} siblings={value} />
156
157
  ))}
157
158
  </div>
158
159
  );
@@ -170,7 +171,7 @@ export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefin
170
171
  * accessible-name computation via `aria-labelledby`. Those three go through the bare
171
172
  * `CONTROL` skin instead, the same way `number`/`date` already do.
172
173
  */
173
- function FieldInput({ def, value, onChange, api, hideLabelAs }: { def: FieldDefinition; value: unknown; onChange: (v: unknown) => void; api: Api; hideLabelAs?: string }) {
174
+ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def: FieldDefinition; value: unknown; onChange: (v: unknown) => void; api: Api; hideLabelAs?: string; siblings?: Record<string, unknown> }) {
174
175
  const label: ReactNode = hideLabelAs !== undefined ? undefined : (
175
176
  <>
176
177
  {def.label ?? def.name} {def.required ? <span className="text-danger">*</span> : null}
@@ -214,6 +215,16 @@ function FieldInput({ def, value, onChange, api, hideLabelAs }: { def: FieldDefi
214
215
  <input className={CONTROL} type={def.type === "date" ? "date" : "datetime-local"} aria-label={hideLabelAs} value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value || null)} />
215
216
  </FieldShell>
216
217
  );
218
+ case "slug":
219
+ return (
220
+ <SlugField
221
+ def={def}
222
+ label={label}
223
+ value={typeof value === "string" ? value : ""}
224
+ source={typeof siblings?.[def.from ?? ""] === "string" ? (siblings[def.from ?? ""] as string) : ""}
225
+ onChange={onChange as (v: string) => void}
226
+ />
227
+ );
217
228
  case "publish":
218
229
  // Deliberately NOT FieldShell: it wraps children in a <label>, whose implicit
219
230
  // control would be PublishControl's first <button> ("Publish now") — clicking the
@@ -431,6 +442,106 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
431
442
  );
432
443
  }
433
444
 
445
+ /**
446
+ * Turn a title into a URL segment.
447
+ *
448
+ * Diacritics are decomposed and their marks dropped, so Czech text transliterates the way
449
+ * a reader expects — "Vrátnice výrobního závodu" becomes "vratnice-vyrobniho-zavodu"
450
+ * rather than losing the accented letters entirely.
451
+ */
452
+ export function slugify(input: string): string {
453
+ // Trailing trim AFTER the length cap — slicing a hyphen-terminated prefix out of a long
454
+ // title would otherwise leave the slug ending in "-".
455
+ return slugifyInput(input).replace(/-+$/, "");
456
+ }
457
+
458
+ /**
459
+ * Per-keystroke normalization: everything `slugify` does EXCEPT the trailing-hyphen trim.
460
+ *
461
+ * The input is controlled, so anything this strips can never be typed at all — trimming the
462
+ * trailing "-" here would make a separator unenterable (type "my-", `slugify` hands back
463
+ * "my", the value prop never changes, React restores the DOM). The full `slugify` runs on
464
+ * blur instead, once the word is finished.
465
+ */
466
+ function slugifyInput(input: string): string {
467
+ return input
468
+ .normalize("NFD")
469
+ .replace(/[\u0300-\u036f]/g, "")
470
+ .toLowerCase()
471
+ .replace(/[^a-z0-9-]+/g, "-")
472
+ .replace(/-{2,}/g, "-")
473
+ .replace(/^-+/, "")
474
+ .slice(0, 80);
475
+ }
476
+
477
+ /**
478
+ * A slug that follows another field while it is untouched.
479
+ *
480
+ * It deliberately stops following the moment the value differs from what it last derived
481
+ * — which includes every row that already has a slug. Renaming a published project must
482
+ * not silently change its URL and break every link to it; "Generate from …" is there for
483
+ * when that IS what you want.
484
+ */
485
+ function SlugField({ def, label, value, source, onChange }: { def: FieldDefinition; label: ReactNode; value: string; source: string; onChange: (v: string) => void }) {
486
+ // What this control last wrote. While the field still holds it, the field is "untouched"
487
+ // and free to follow; anything else means a human typed it.
488
+ const derived = useRef<string | null>(null);
489
+ // Whether a human has edited this field. Once true it stays true for the session —
490
+ // emptying the field is an edit, not an invitation to start following again.
491
+ const touched = useRef(false);
492
+ // The source we last saw. Seeded on the first run so merely OPENING a row never derives:
493
+ // a row saved before this field existed has a filled title and an empty slug, and an
494
+ // onChange there marks the block dirty and autosaves something nobody asked for. Only a
495
+ // real change to the source field derives; "Generate from …" covers the rest.
496
+ const lastSource = useRef<string | null>(null);
497
+
498
+ useEffect(() => {
499
+ if (!def.from) return;
500
+ const prev = lastSource.current;
501
+ lastSource.current = source;
502
+ if (prev === null || prev === source || !source) return;
503
+ if (touched.current) return;
504
+ // Follow only an empty field or one still holding our own last derivation — an existing
505
+ // slug is a live URL, and renaming its page must not break every link to it.
506
+ if (value !== "" && value !== derived.current) return;
507
+ const next = slugify(source);
508
+ if (next === value) return;
509
+ derived.current = next;
510
+ onChange(next);
511
+ }, [source, value, def.from, onChange]);
512
+
513
+ const suggestion = source ? slugify(source) : "";
514
+ const canGenerate = Boolean(suggestion) && suggestion !== value;
515
+
516
+ return (
517
+ <FieldShell label={label}>
518
+ <input
519
+ className={CONTROL}
520
+ value={value}
521
+ onChange={(e) => {
522
+ touched.current = true; // typed by hand — stop following from here on
523
+ derived.current = null;
524
+ onChange(slugifyInput(e.target.value));
525
+ }}
526
+ onBlur={() => {
527
+ const clean = slugify(value);
528
+ if (clean !== value) onChange(clean);
529
+ }}
530
+ placeholder={suggestion}
531
+ />
532
+ {canGenerate ? (
533
+ <button
534
+ type="button"
535
+ className="self-start text-caption text-fg-subtle underline hover:text-fg"
536
+ onClick={() => { derived.current = suggestion; touched.current = false; onChange(suggestion); }}
537
+ >
538
+ Generate from {def.from}: {suggestion}
539
+ </button>
540
+ ) : null}
541
+ </FieldShell>
542
+ );
543
+ }
544
+
434
545
  // A `select` field. Static `options` render as-is; when `optionsFrom` is set, the options are
435
546
  // fetched once from that query handler (returns `{ value, label }[]`) — e.g. a live list of
436
547
  // campaigns — so the editor never has to hardcode or copy identifiers by hand.
package/src/types.ts CHANGED
@@ -13,6 +13,8 @@ export type FieldType =
13
13
  | "datetime"
14
14
  /** A publication timestamp — rendered as publish-now / schedule / unpublish. */
15
15
  | "publish"
16
+ /** A URL segment, derived from the field named by `from` while it is untouched. */
17
+ | "slug"
16
18
  | "media"
17
19
  | "select"
18
20
  | "repeater"
@@ -32,6 +34,8 @@ export interface FieldDefinition {
32
34
  * return `{ value, label }[]`. Lets a select offer live data (e.g. existing campaigns)
33
35
  * instead of a static list. Takes precedence over `options`. */
34
36
  optionsFrom?: string;
37
+ /** For `slug`: the sibling field this one is derived from (e.g. `"title"`). */
38
+ from?: string;
35
39
  }
36
40
 
37
41
  export interface RegionDefinition {