emdash-header-footer-code 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 +217 -0
- package/dist/index.d.mts +14 -0
- package/dist/index.mjs +22 -0
- package/dist/plugin.d.mts +17 -0
- package/dist/plugin.mjs +608 -0
- package/dist/types-CNDv5vQX.d.mts +46 -0
- package/dist/version-iEzwHCKh.mjs +6 -0
- package/package.json +46 -0
- package/src/admin/ChangeLog.tsx +84 -0
- package/src/admin/ChipInput.tsx +154 -0
- package/src/admin/CodeEditor.tsx +135 -0
- package/src/admin/SnippetForm.tsx +458 -0
- package/src/admin/SnippetList.tsx +282 -0
- package/src/admin/SnippetsPage.tsx +285 -0
- package/src/admin/api.ts +47 -0
- package/src/admin/chips.ts +51 -0
- package/src/admin/format.ts +96 -0
- package/src/admin/icons.tsx +91 -0
- package/src/admin/indent.ts +56 -0
- package/src/admin/index.tsx +6 -0
- package/src/admin/styles.tsx +93 -0
- package/src/core/fields.ts +117 -0
- package/src/core/match.ts +54 -0
- package/src/core/paths.ts +48 -0
- package/src/core/pipeline.ts +53 -0
- package/src/core/types.ts +79 -0
- package/src/core/validate.ts +139 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ChangeLogAction, PageKind, Placement, UserRef } from "../core/types.js";
|
|
2
|
+
|
|
3
|
+
/** Pure display helpers for the admin page (no React, no DOM), unit-tested in node. */
|
|
4
|
+
|
|
5
|
+
export const PLACEMENT_LABEL: Record<Placement, string> = {
|
|
6
|
+
head: "Head",
|
|
7
|
+
"body:start": "Body start",
|
|
8
|
+
"body:end": "Body end",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const PAGE_KIND_SUMMARY: Record<Exclude<PageKind, "all">, string> = {
|
|
12
|
+
content: "content pages",
|
|
13
|
+
custom: "custom pages",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface WhereFields {
|
|
17
|
+
placement: Placement;
|
|
18
|
+
includePaths: readonly string[];
|
|
19
|
+
excludePaths: readonly string[];
|
|
20
|
+
pageKind: PageKind;
|
|
21
|
+
locales: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One-line "where does this render" summary, e.g. `Head · /blog/* · excl. /blog/draft-* · content pages · en`. */
|
|
25
|
+
export function whereSummary(s: WhereFields): string {
|
|
26
|
+
const parts = [PLACEMENT_LABEL[s.placement] ?? s.placement];
|
|
27
|
+
parts.push(s.includePaths.length > 0 ? s.includePaths.join(", ") : "All pages");
|
|
28
|
+
if (s.excludePaths.length > 0) parts.push(`excl. ${s.excludePaths.join(", ")}`);
|
|
29
|
+
if (s.pageKind !== "all") parts.push(PAGE_KIND_SUMMARY[s.pageKind] ?? s.pageKind);
|
|
30
|
+
if (s.locales.length > 0) parts.push(s.locales.join(", "));
|
|
31
|
+
return parts.join(" · ");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function startOfDay(d: Date): number {
|
|
35
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* "just now", "3 min ago", "2 hours ago" (same day), "yesterday", then a short date.
|
|
40
|
+
* `now` is injectable for tests; `locale` defaults to the browser's.
|
|
41
|
+
*/
|
|
42
|
+
export function relativeTime(iso: string, now: Date | number = Date.now(), locale?: string): string {
|
|
43
|
+
const then = new Date(iso);
|
|
44
|
+
if (Number.isNaN(then.getTime())) return iso;
|
|
45
|
+
const nowDate = typeof now === "number" ? new Date(now) : now;
|
|
46
|
+
const seconds = Math.floor((nowDate.getTime() - then.getTime()) / 1000);
|
|
47
|
+
if (seconds < 60) return "just now";
|
|
48
|
+
const minutes = Math.floor(seconds / 60);
|
|
49
|
+
if (minutes < 60) return `${minutes} min ago`;
|
|
50
|
+
const dayDiff = Math.round((startOfDay(nowDate) - startOfDay(then)) / 86_400_000);
|
|
51
|
+
if (dayDiff === 0) {
|
|
52
|
+
const hours = Math.floor(minutes / 60);
|
|
53
|
+
return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
|
|
54
|
+
}
|
|
55
|
+
if (dayDiff === 1) return "yesterday";
|
|
56
|
+
const sameYear = then.getFullYear() === nowDate.getFullYear();
|
|
57
|
+
return then.toLocaleDateString(locale, sameYear ? { month: "short", day: "numeric" } : { dateStyle: "medium" });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Full local timestamp for tooltips, e.g. "Oct 5, 2026, 3:04 PM". */
|
|
61
|
+
export function formatTimestamp(iso: string, locale?: string): string {
|
|
62
|
+
const d = new Date(iso);
|
|
63
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
64
|
+
return d.toLocaleString(locale, { dateStyle: "medium", timeStyle: "short" });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function userLabel(u: UserRef): string {
|
|
68
|
+
return u.name ?? u.email ?? u.id;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type ActionBadgeVariant = "success" | "info" | "secondary" | "error" | "warning";
|
|
72
|
+
|
|
73
|
+
/** Change-log action label and Kumo Badge variant. */
|
|
74
|
+
export const ACTION_META: Record<ChangeLogAction, { label: string; variant: ActionBadgeVariant }> = {
|
|
75
|
+
create: { label: "Created", variant: "success" },
|
|
76
|
+
update: { label: "Edited", variant: "info" },
|
|
77
|
+
enable: { label: "Enabled", variant: "success" },
|
|
78
|
+
disable: { label: "Disabled", variant: "secondary" },
|
|
79
|
+
duplicate: { label: "Duplicated", variant: "info" },
|
|
80
|
+
delete: { label: "Deleted", variant: "error" },
|
|
81
|
+
killswitch_on: { label: "Output off", variant: "warning" },
|
|
82
|
+
killswitch_off: { label: "Output on", variant: "success" },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type PlacementFilter = Placement | "all";
|
|
86
|
+
|
|
87
|
+
/** Client-side list filter: case-insensitive name search plus a placement filter. Keeps order. */
|
|
88
|
+
export function filterSnippets<T extends { name: string; placement: Placement }>(
|
|
89
|
+
list: readonly T[],
|
|
90
|
+
f: { query: string; placement: PlacementFilter },
|
|
91
|
+
): T[] {
|
|
92
|
+
const q = f.query.trim().toLowerCase();
|
|
93
|
+
return list.filter(
|
|
94
|
+
(s) => (f.placement === "all" || s.placement === f.placement) && (q === "" || s.name.toLowerCase().includes(q)),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Small inline icons (Phosphor-style, 256 viewBox) so the admin page needs no icon dependency:
|
|
5
|
+
* the host site's Vite compiles this source and may not be able to resolve @phosphor-icons/react.
|
|
6
|
+
*/
|
|
7
|
+
function Svg({ children, size = 16, ...rest }: React.SVGProps<SVGSVGElement> & { size?: number }) {
|
|
8
|
+
return (
|
|
9
|
+
<svg
|
|
10
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
11
|
+
viewBox="0 0 256 256"
|
|
12
|
+
width={size}
|
|
13
|
+
height={size}
|
|
14
|
+
fill="none"
|
|
15
|
+
stroke="currentColor"
|
|
16
|
+
strokeWidth={16}
|
|
17
|
+
strokeLinecap="round"
|
|
18
|
+
strokeLinejoin="round"
|
|
19
|
+
aria-hidden="true"
|
|
20
|
+
focusable="false"
|
|
21
|
+
{...rest}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
</svg>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type IconProps = React.SVGProps<SVGSVGElement> & { size?: number };
|
|
29
|
+
|
|
30
|
+
export const PlusIcon = (p: IconProps) => (
|
|
31
|
+
<Svg {...p}>
|
|
32
|
+
<path d="M40 128h176M128 40v176" />
|
|
33
|
+
</Svg>
|
|
34
|
+
);
|
|
35
|
+
export const ArrowLeftIcon = (p: IconProps) => (
|
|
36
|
+
<Svg {...p}>
|
|
37
|
+
<path d="M216 128H40M112 56l-72 72 72 72" />
|
|
38
|
+
</Svg>
|
|
39
|
+
);
|
|
40
|
+
export const DotsIcon = (p: IconProps) => (
|
|
41
|
+
<Svg {...p} fill="currentColor" stroke="none">
|
|
42
|
+
<circle cx="60" cy="128" r="16" />
|
|
43
|
+
<circle cx="128" cy="128" r="16" />
|
|
44
|
+
<circle cx="196" cy="128" r="16" />
|
|
45
|
+
</Svg>
|
|
46
|
+
);
|
|
47
|
+
export const PencilIcon = (p: IconProps) => (
|
|
48
|
+
<Svg {...p}>
|
|
49
|
+
<path d="M92.7 216H48a8 8 0 0 1-8-8v-44.7a8 8 0 0 1 2.3-5.6l120-120a8 8 0 0 1 11.4 0l44.6 44.6a8 8 0 0 1 0 11.4l-120 120a8 8 0 0 1-5.6 2.3ZM136 64l56 56" />
|
|
50
|
+
</Svg>
|
|
51
|
+
);
|
|
52
|
+
export const CopyIcon = (p: IconProps) => (
|
|
53
|
+
<Svg {...p}>
|
|
54
|
+
<path d="M168 168h48V40H88v48" />
|
|
55
|
+
<rect x="40" y="88" width="128" height="128" rx="8" />
|
|
56
|
+
</Svg>
|
|
57
|
+
);
|
|
58
|
+
export const TrashIcon = (p: IconProps) => (
|
|
59
|
+
<Svg {...p}>
|
|
60
|
+
<path d="M216 56H40M104 104v64M152 104v64M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56M168 56V40a16 16 0 0 0-16-16h-48a16 16 0 0 0-16 16v16" />
|
|
61
|
+
</Svg>
|
|
62
|
+
);
|
|
63
|
+
export const InfoIcon = (p: IconProps) => (
|
|
64
|
+
<Svg {...p}>
|
|
65
|
+
<circle cx="128" cy="128" r="96" />
|
|
66
|
+
<path d="M120 120h8v56h8" />
|
|
67
|
+
<circle cx="126" cy="84" r="6" fill="currentColor" stroke="none" />
|
|
68
|
+
</Svg>
|
|
69
|
+
);
|
|
70
|
+
export const WarningIcon = (p: IconProps) => (
|
|
71
|
+
<Svg {...p}>
|
|
72
|
+
<path d="M128 104v40M114.2 40l-88 152A16 16 0 0 0 40 216h176a16 16 0 0 0 13.8-24l-88-152a16 16 0 0 0-27.6 0Z" />
|
|
73
|
+
<circle cx="128" cy="180" r="6" fill="currentColor" stroke="none" />
|
|
74
|
+
</Svg>
|
|
75
|
+
);
|
|
76
|
+
export const SearchIcon = (p: IconProps) => (
|
|
77
|
+
<Svg {...p}>
|
|
78
|
+
<circle cx="112" cy="112" r="72" />
|
|
79
|
+
<path d="M163 163l53 53" />
|
|
80
|
+
</Svg>
|
|
81
|
+
);
|
|
82
|
+
export const XIcon = (p: IconProps) => (
|
|
83
|
+
<Svg {...p}>
|
|
84
|
+
<path d="M200 56 56 200M200 200 56 56" />
|
|
85
|
+
</Svg>
|
|
86
|
+
);
|
|
87
|
+
export const CodeIcon = (p: IconProps) => (
|
|
88
|
+
<Svg {...p}>
|
|
89
|
+
<path d="m64 88-48 40 48 40M192 88l48 40-48 40M160 40 96 216" />
|
|
90
|
+
</Svg>
|
|
91
|
+
);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Pure Tab / Shift+Tab editing logic behind CodeEditor (unit-tested in node). */
|
|
2
|
+
|
|
3
|
+
export const INDENT = " ";
|
|
4
|
+
|
|
5
|
+
export interface TextEdit {
|
|
6
|
+
value: string;
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function lineStartAt(value: string, pos: number): number {
|
|
12
|
+
return value.lastIndexOf("\n", pos - 1) + 1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tab: with a caret, insert two spaces; with a selection, indent every line it touches.
|
|
17
|
+
* Shift+Tab (`outdent`): remove up to two leading spaces from every touched line.
|
|
18
|
+
* A multi-line selection ending at column 0 does not touch that last line.
|
|
19
|
+
*/
|
|
20
|
+
export function indentText(value: string, start: number, end: number, outdent = false): TextEdit {
|
|
21
|
+
if (!outdent && start === end) {
|
|
22
|
+
return { value: value.slice(0, start) + INDENT + value.slice(end), start: start + INDENT.length, end: start + INDENT.length };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const firstLine = lineStartAt(value, start);
|
|
26
|
+
let lastPos = end;
|
|
27
|
+
if (end > start && end > firstLine && lineStartAt(value, end) === end) lastPos = end - 1;
|
|
28
|
+
|
|
29
|
+
// Start offsets of every touched line.
|
|
30
|
+
const lineStarts: number[] = [firstLine];
|
|
31
|
+
for (let i = value.indexOf("\n", firstLine); i !== -1 && i < lastPos; i = value.indexOf("\n", i + 1)) {
|
|
32
|
+
lineStarts.push(i + 1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let out = "";
|
|
36
|
+
let cursor = 0;
|
|
37
|
+
let newStart = start;
|
|
38
|
+
let newEnd = end;
|
|
39
|
+
for (const ls of lineStarts) {
|
|
40
|
+
out += value.slice(cursor, ls);
|
|
41
|
+
cursor = ls;
|
|
42
|
+
if (outdent) {
|
|
43
|
+
let n = 0;
|
|
44
|
+
while (n < INDENT.length && value[ls + n] === " ") n++;
|
|
45
|
+
cursor = ls + n;
|
|
46
|
+
if (start > ls) newStart -= Math.min(n, start - ls);
|
|
47
|
+
if (end > ls) newEnd -= Math.min(n, end - ls);
|
|
48
|
+
} else {
|
|
49
|
+
out += INDENT;
|
|
50
|
+
if (start > ls) newStart += INDENT.length;
|
|
51
|
+
if (end > ls) newEnd += INDENT.length;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
out += value.slice(cursor);
|
|
55
|
+
return { value: out, start: newStart, end: newEnd };
|
|
56
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The EmDash admin ships precompiled Tailwind CSS, so utilities it never uses itself (arbitrary
|
|
3
|
+
* grid templates, exact code metrics) do not exist for plugin pages. The few such rules live
|
|
4
|
+
* here, scoped under `hfc-` class names and built on Kumo's CSS variables so they follow the
|
|
5
|
+
* admin's light and dark themes.
|
|
6
|
+
*/
|
|
7
|
+
const CSS = `
|
|
8
|
+
.hfc-edit-grid {
|
|
9
|
+
display: grid; gap: 1.5rem; align-items: start;
|
|
10
|
+
grid-template-areas: "code" "settings" "targeting";
|
|
11
|
+
}
|
|
12
|
+
.hfc-area-code { grid-area: code; }
|
|
13
|
+
.hfc-area-settings { grid-area: settings; }
|
|
14
|
+
.hfc-area-targeting { grid-area: targeting; }
|
|
15
|
+
@media (min-width: 1024px) {
|
|
16
|
+
.hfc-edit-grid {
|
|
17
|
+
grid-template-columns: minmax(0, 1fr) 22rem;
|
|
18
|
+
/* The 1fr row absorbs the Settings card's span so Code and Targeting stay together. */
|
|
19
|
+
grid-template-rows: auto 1fr;
|
|
20
|
+
grid-template-areas: "code settings" "targeting settings";
|
|
21
|
+
}
|
|
22
|
+
/* Sticky inside <main>'s scroll area; capped above the sticky footer so it never hides behind it. */
|
|
23
|
+
.hfc-area-settings {
|
|
24
|
+
position: sticky; top: 0;
|
|
25
|
+
max-height: calc(100dvh - 11rem); overflow-y: auto;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/* Targeting: 2x2 (Include | Exclude, Page kind | Locales) when the card is wide enough. */
|
|
29
|
+
.hfc-targeting { container-type: inline-size; }
|
|
30
|
+
.hfc-targeting-grid { display: grid; gap: 1.25rem 1.5rem; align-items: start; }
|
|
31
|
+
@container (min-width: 34rem) {
|
|
32
|
+
.hfc-targeting-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
33
|
+
}
|
|
34
|
+
/*
|
|
35
|
+
* Snippets / Change log: brand-tinted active segment (Kumo's default is a white pill on grey).
|
|
36
|
+
* Contrast (WCAG): every label >= 4.5:1, and the active pill's solid blue ring >= 3:1 against both
|
|
37
|
+
* the track and its own tint (1.4.11), in light and dark. --text-color-kumo-link is blue-800 in
|
|
38
|
+
* light and blue-400 in dark, so it works as a ring/text colour on either surface.
|
|
39
|
+
*/
|
|
40
|
+
.hfc-tabs .hfc-tab-indicator {
|
|
41
|
+
background: color-mix(in oklab, var(--color-kumo-brand) 14%, var(--color-kumo-base));
|
|
42
|
+
box-shadow: 0 0 0 1.5px var(--text-color-kumo-link);
|
|
43
|
+
}
|
|
44
|
+
.hfc-tabs .hfc-tab { cursor: pointer; transition: background-color 120ms, color 120ms; }
|
|
45
|
+
.hfc-tabs .hfc-tab[aria-selected="true"] { color: var(--text-color-kumo-link); font-weight: 500; }
|
|
46
|
+
/* Inactive: Kumo's "subtle" is only ~4:1 on the light track, "strong" passes there; in dark
|
|
47
|
+
"strong" is brighter than the active label, so dark keeps "subtle" (~7.6:1). */
|
|
48
|
+
.hfc-tabs .hfc-tab[aria-selected="false"] {
|
|
49
|
+
color: light-dark(var(--text-color-kumo-strong), var(--text-color-kumo-subtle));
|
|
50
|
+
}
|
|
51
|
+
.hfc-tabs .hfc-tab[aria-selected="false"]:hover {
|
|
52
|
+
color: var(--text-color-kumo-default);
|
|
53
|
+
background: color-mix(in oklab, var(--color-kumo-base) 60%, transparent);
|
|
54
|
+
}
|
|
55
|
+
/* Focus: a 2px inset ring in the same high-contrast blue (Kumo's brand ring is ~3:1 at best). */
|
|
56
|
+
.hfc-tabs .hfc-tab:focus-visible { box-shadow: inset 0 0 0 2px var(--text-color-kumo-link); }
|
|
57
|
+
.hfc-tabs .hfc-tab .hfc-tab-count { transition: background-color 120ms, color 120ms; }
|
|
58
|
+
.hfc-tabs .hfc-tab[aria-selected="true"] .hfc-tab-count {
|
|
59
|
+
background: color-mix(in oklab, var(--color-kumo-brand) 22%, var(--color-kumo-base));
|
|
60
|
+
color: var(--text-color-kumo-link);
|
|
61
|
+
}
|
|
62
|
+
/*
|
|
63
|
+
* Info banner (theme note): Kumo's info Banner sets blue text on a blue tint. Keep the tint and a
|
|
64
|
+
* blue icon, add a blue left accent, and use neutral text so the copy reads at body contrast.
|
|
65
|
+
*/
|
|
66
|
+
.hfc-banner-info {
|
|
67
|
+
background: color-mix(in oklab, var(--color-kumo-info-tint) 55%, var(--color-kumo-base));
|
|
68
|
+
box-shadow: inset 3px 0 0 var(--text-color-kumo-link);
|
|
69
|
+
color: var(--text-color-kumo-default);
|
|
70
|
+
}
|
|
71
|
+
.hfc-banner-info code { color: var(--text-color-kumo-default); font-weight: 500; }
|
|
72
|
+
.hfc-banner-info svg { color: var(--text-color-kumo-link); }
|
|
73
|
+
.hfc-code-text { font-size: 13px; line-height: 20px; tab-size: 2; }
|
|
74
|
+
.hfc-code-area { height: 24rem; min-height: 21.5rem; white-space: pre; overflow: auto; }
|
|
75
|
+
.hfc-footer {
|
|
76
|
+
position: sticky; bottom: -1.5rem; z-index: 10;
|
|
77
|
+
margin: 0 -1.5rem -1.5rem; padding: 0.75rem 1.5rem;
|
|
78
|
+
border-top: 1px solid var(--color-kumo-line);
|
|
79
|
+
background: var(--color-kumo-base);
|
|
80
|
+
}
|
|
81
|
+
/* Keep Kumo's toast viewport (fixed bottom-right) clear of the sticky Save footer while the
|
|
82
|
+
editor is open. Kumo gives the viewport no hook of its own, so match its utility classes. */
|
|
83
|
+
body:has(.hfc-footer) .fixed.top-auto.bottom-4 { bottom: 5.5rem !important; }
|
|
84
|
+
.hfc-where { max-width: 36rem; }
|
|
85
|
+
@media (max-width: 1023px) { .hfc-where { max-width: 14rem; } }
|
|
86
|
+
.hfc-row-clickable { cursor: pointer; }
|
|
87
|
+
.hfc-row-clickable:hover > td { background: color-mix(in oklab, var(--color-kumo-tint) 45%, transparent); }
|
|
88
|
+
.hfc-row-off > td.hfc-dim { opacity: 0.55; }
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
export function HfcStyles() {
|
|
92
|
+
return <style>{CSS}</style>;
|
|
93
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { EditableSnippet } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type FieldType = "text" | "code" | "select" | "toggle" | "number" | "pathList" | "localeList";
|
|
4
|
+
|
|
5
|
+
/** Where a field renders in the edit form: the main column, or one of the side cards. */
|
|
6
|
+
export const FIELD_SECTIONS = ["main", "settings", "targeting"] as const;
|
|
7
|
+
export type FieldSection = (typeof FIELD_SECTIONS)[number];
|
|
8
|
+
|
|
9
|
+
export interface FieldDef {
|
|
10
|
+
/** Top-level snippet key, or a dotted path into meta such as "meta.consentCategory". */
|
|
11
|
+
name: string;
|
|
12
|
+
label: string;
|
|
13
|
+
type: FieldType;
|
|
14
|
+
section: FieldSection;
|
|
15
|
+
options?: { value: string; label: string }[];
|
|
16
|
+
help?: string;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
/** Hidden from viewers with only plugins:read. */
|
|
19
|
+
manageOnly?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Add a field here (for example meta.consentCategory) to add it to the edit form. */
|
|
23
|
+
export const FIELDS: readonly FieldDef[] = [
|
|
24
|
+
{ name: "name", label: "Name", type: "text", section: "settings", required: true, help: "Shown in the admin list only." },
|
|
25
|
+
{
|
|
26
|
+
name: "code",
|
|
27
|
+
label: "Code",
|
|
28
|
+
type: "code",
|
|
29
|
+
section: "main",
|
|
30
|
+
required: true,
|
|
31
|
+
manageOnly: true,
|
|
32
|
+
help: "Raw HTML: <script>, <style>, <noscript>, <meta>, … Output exactly as written. Max 64 KB.",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "placement",
|
|
36
|
+
label: "Placement",
|
|
37
|
+
type: "select",
|
|
38
|
+
section: "settings",
|
|
39
|
+
options: [
|
|
40
|
+
{ value: "head", label: "Head" },
|
|
41
|
+
{ value: "body:start", label: "Body start" },
|
|
42
|
+
{ value: "body:end", label: "Body end" },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{ name: "enabled", label: "Enabled", type: "toggle", section: "settings" },
|
|
46
|
+
{
|
|
47
|
+
name: "priority",
|
|
48
|
+
label: "Priority",
|
|
49
|
+
type: "number",
|
|
50
|
+
section: "settings",
|
|
51
|
+
help: "Lower runs first within the same placement.",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "includePaths",
|
|
55
|
+
label: "Include paths",
|
|
56
|
+
type: "pathList",
|
|
57
|
+
section: "targeting",
|
|
58
|
+
help: "Press Enter to add, e.g. / or /blog/*. Leave empty for every page.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "excludePaths",
|
|
62
|
+
label: "Exclude paths",
|
|
63
|
+
type: "pathList",
|
|
64
|
+
section: "targeting",
|
|
65
|
+
help: "Press Enter to add. Wins over include.",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "pageKind",
|
|
69
|
+
label: "Page kind",
|
|
70
|
+
type: "select",
|
|
71
|
+
section: "targeting",
|
|
72
|
+
options: [
|
|
73
|
+
{ value: "all", label: "All pages" },
|
|
74
|
+
{ value: "content", label: "Content pages" },
|
|
75
|
+
{ value: "custom", label: "Custom pages" },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "locales",
|
|
80
|
+
label: "Locales",
|
|
81
|
+
type: "localeList",
|
|
82
|
+
section: "targeting",
|
|
83
|
+
help: "Press Enter to add, e.g. en. Leave empty for all.",
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
export function getFieldValue(draft: Record<string, any>, name: string): unknown {
|
|
88
|
+
let cur: unknown = draft;
|
|
89
|
+
for (const part of name.split(".")) {
|
|
90
|
+
if (typeof cur !== "object" || cur === null) return undefined;
|
|
91
|
+
cur = (cur as Record<string, any>)[part];
|
|
92
|
+
}
|
|
93
|
+
return cur;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function setFieldValue<T extends Record<string, any>>(draft: T, name: string, value: unknown): T {
|
|
97
|
+
const [head, ...rest] = name.split(".");
|
|
98
|
+
if (rest.length === 0) return { ...draft, [head!]: value };
|
|
99
|
+
const child = draft[head!];
|
|
100
|
+
const base = typeof child === "object" && child !== null ? (child as Record<string, any>) : {};
|
|
101
|
+
return { ...draft, [head!]: setFieldValue(base, rest.join("."), value) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function emptyDraft(): EditableSnippet {
|
|
105
|
+
return {
|
|
106
|
+
name: "",
|
|
107
|
+
code: "",
|
|
108
|
+
placement: "head",
|
|
109
|
+
enabled: true,
|
|
110
|
+
priority: 10,
|
|
111
|
+
includePaths: [],
|
|
112
|
+
excludePaths: [],
|
|
113
|
+
pageKind: "all",
|
|
114
|
+
locales: [],
|
|
115
|
+
meta: {},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { compilePattern, type PathMatcher } from "./paths.js";
|
|
2
|
+
import type { Logger, PageInfo, Snippet } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export interface CompiledSnippet {
|
|
5
|
+
snippet: Snippet;
|
|
6
|
+
include: PathMatcher[];
|
|
7
|
+
exclude: PathMatcher[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isAdminPath(path: string): boolean {
|
|
11
|
+
return path.startsWith("/_emdash/");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function compileSnippet(snippet: Snippet): CompiledSnippet {
|
|
15
|
+
return {
|
|
16
|
+
snippet,
|
|
17
|
+
include: snippet.includePaths.map(compilePattern),
|
|
18
|
+
exclude: snippet.excludePaths.map(compilePattern),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function compareSnippets(a: Snippet, b: Snippet): number {
|
|
23
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
24
|
+
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
|
|
25
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Enabled snippets, sorted, with compiled matchers. Invalid ones are skipped and logged. */
|
|
29
|
+
export function prepareSnippets(list: Snippet[], log: Logger): CompiledSnippet[] {
|
|
30
|
+
const out: CompiledSnippet[] = [];
|
|
31
|
+
for (const snippet of [...list].sort(compareSnippets)) {
|
|
32
|
+
if (!snippet.enabled) continue;
|
|
33
|
+
try {
|
|
34
|
+
out.push(compileSnippet(snippet));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
log.warn("Skipping snippet with an invalid path pattern", {
|
|
37
|
+
snippetId: snippet.id,
|
|
38
|
+
error: error instanceof Error ? error.message : String(error),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function matchesPage(c: CompiledSnippet, page: PageInfo): boolean {
|
|
46
|
+
const { snippet } = c;
|
|
47
|
+
if (snippet.pageKind !== "all" && snippet.pageKind !== page.kind) return false;
|
|
48
|
+
if (snippet.locales.length > 0 && (page.locale === null || !snippet.locales.includes(page.locale))) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (c.include.length > 0 && !c.include.some((m) => m(page.path))) return false;
|
|
52
|
+
if (c.exclude.some((m) => m(page.path))) return false;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type PathMatcher = (path: string) => boolean;
|
|
2
|
+
|
|
3
|
+
const WHITESPACE = /\s/;
|
|
4
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
5
|
+
|
|
6
|
+
/** Returns an error message, or null when the pattern is valid. */
|
|
7
|
+
export function validatePattern(pattern: string): string | null {
|
|
8
|
+
if (!pattern.startsWith("/")) return "Path must start with /";
|
|
9
|
+
if (WHITESPACE.test(pattern)) return "Path must not contain whitespace";
|
|
10
|
+
const star = pattern.indexOf("*");
|
|
11
|
+
if (star !== -1 && star !== pattern.length - 1) return "* is only allowed at the end of a path";
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Strip trailing slashes, keeping "/" for the root. Case-sensitive. */
|
|
16
|
+
export function normalizePath(path: string): string {
|
|
17
|
+
if (path === "/") return path;
|
|
18
|
+
const stripped = path.replace(TRAILING_SLASHES, "");
|
|
19
|
+
return stripped === "" ? "/" : stripped;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Decode a percent-encoded path (page paths come from url.pathname, so `café` arrives as
|
|
24
|
+
* `caf%C3%A9`). Malformed escapes fall back to the raw path instead of throwing.
|
|
25
|
+
*/
|
|
26
|
+
export function safeDecodePath(path: string): string {
|
|
27
|
+
try {
|
|
28
|
+
return decodeURI(path);
|
|
29
|
+
} catch {
|
|
30
|
+
return path;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compile a validated pattern into a matcher. Throws on invalid patterns.
|
|
36
|
+
* Matching runs on decoded paths: both the page path and the pattern are decoded, so an
|
|
37
|
+
* admin can type `/blog/café/*` (or the encoded form) and it matches `/blog/caf%C3%A9/x`.
|
|
38
|
+
*/
|
|
39
|
+
export function compilePattern(pattern: string): PathMatcher {
|
|
40
|
+
const error = validatePattern(pattern);
|
|
41
|
+
if (error) throw new Error(error);
|
|
42
|
+
if (pattern.endsWith("*")) {
|
|
43
|
+
const prefix = safeDecodePath(pattern.slice(0, -1));
|
|
44
|
+
return (path) => normalizePath(safeDecodePath(path)).startsWith(prefix);
|
|
45
|
+
}
|
|
46
|
+
const exact = normalizePath(safeDecodePath(pattern));
|
|
47
|
+
return (path) => normalizePath(safeDecodePath(path)) === exact;
|
|
48
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { PageFragmentContribution } from "emdash";
|
|
2
|
+
import { type CompiledSnippet, matchesPage } from "./match.js";
|
|
3
|
+
import type { Logger, PageInfo, Transform } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/** match → (already sorted) → transform → emit */
|
|
6
|
+
export function renderFragments(
|
|
7
|
+
compiled: CompiledSnippet[],
|
|
8
|
+
page: PageInfo,
|
|
9
|
+
transforms: readonly Transform[],
|
|
10
|
+
log: Logger,
|
|
11
|
+
): PageFragmentContribution[] {
|
|
12
|
+
const snippetFragments: PageFragmentContribution[] = [];
|
|
13
|
+
const extraFragments: PageFragmentContribution[] = [];
|
|
14
|
+
const seenExtraKeys = new Set<string>();
|
|
15
|
+
|
|
16
|
+
for (const c of compiled) {
|
|
17
|
+
if (!matchesPage(c, page)) continue;
|
|
18
|
+
const { snippet } = c;
|
|
19
|
+
let html: string | null = snippet.code;
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < transforms.length && html !== null; i++) {
|
|
22
|
+
const transform = transforms[i]!;
|
|
23
|
+
try {
|
|
24
|
+
const result = transform({ snippet, html, page });
|
|
25
|
+
if (result === null || typeof result === "string") {
|
|
26
|
+
html = result;
|
|
27
|
+
} else {
|
|
28
|
+
html = result.html;
|
|
29
|
+
for (const fragment of result.fragments ?? []) {
|
|
30
|
+
if (fragment.key !== undefined) {
|
|
31
|
+
if (seenExtraKeys.has(fragment.key)) continue;
|
|
32
|
+
seenExtraKeys.add(fragment.key);
|
|
33
|
+
}
|
|
34
|
+
extraFragments.push(fragment);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
log.error("Snippet transform failed; skipping snippet", {
|
|
39
|
+
snippetId: snippet.id,
|
|
40
|
+
transformIndex: i,
|
|
41
|
+
error: error instanceof Error ? error.message : String(error),
|
|
42
|
+
});
|
|
43
|
+
html = null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (html !== null) {
|
|
48
|
+
snippetFragments.push({ kind: "html", placement: snippet.placement, html, key: `hfc-${snippet.id}` });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return [...snippetFragments, ...extraFragments];
|
|
53
|
+
}
|