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,79 @@
|
|
|
1
|
+
import type { PageFragmentContribution, PublicPageContext } from "emdash";
|
|
2
|
+
|
|
3
|
+
export const PLACEMENTS = ["head", "body:start", "body:end"] as const;
|
|
4
|
+
export type Placement = (typeof PLACEMENTS)[number];
|
|
5
|
+
|
|
6
|
+
export const PAGE_KINDS = ["all", "content", "custom"] as const;
|
|
7
|
+
export type PageKind = (typeof PAGE_KINDS)[number];
|
|
8
|
+
|
|
9
|
+
export interface UserRef {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string | null;
|
|
12
|
+
email: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Fields an admin edits. */
|
|
16
|
+
export interface EditableSnippet {
|
|
17
|
+
name: string;
|
|
18
|
+
code: string;
|
|
19
|
+
placement: Placement;
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
priority: number;
|
|
22
|
+
includePaths: string[];
|
|
23
|
+
excludePaths: string[];
|
|
24
|
+
pageKind: PageKind;
|
|
25
|
+
locales: string[];
|
|
26
|
+
meta: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Snippet extends EditableSnippet {
|
|
30
|
+
id: string;
|
|
31
|
+
schemaVersion: number; // 1 in v0.1
|
|
32
|
+
createdAt: string; // ISO
|
|
33
|
+
updatedAt: string; // ISO
|
|
34
|
+
createdBy: UserRef;
|
|
35
|
+
updatedBy: UserRef;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PluginState {
|
|
39
|
+
rev: number;
|
|
40
|
+
disabled: boolean;
|
|
41
|
+
}
|
|
42
|
+
export const DEFAULT_STATE: PluginState = { rev: 0, disabled: false };
|
|
43
|
+
|
|
44
|
+
export type ChangeLogAction =
|
|
45
|
+
| "create"
|
|
46
|
+
| "update"
|
|
47
|
+
| "enable"
|
|
48
|
+
| "disable"
|
|
49
|
+
| "duplicate"
|
|
50
|
+
| "delete"
|
|
51
|
+
| "killswitch_on"
|
|
52
|
+
| "killswitch_off";
|
|
53
|
+
|
|
54
|
+
export interface ChangeLogEntry {
|
|
55
|
+
id: string;
|
|
56
|
+
at: string;
|
|
57
|
+
user: UserRef;
|
|
58
|
+
action: ChangeLogAction;
|
|
59
|
+
snippetId: string | null;
|
|
60
|
+
snippetName: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type PageInfo = PublicPageContext;
|
|
64
|
+
|
|
65
|
+
export interface TransformContext {
|
|
66
|
+
snippet: Readonly<Snippet>;
|
|
67
|
+
html: string;
|
|
68
|
+
page: Readonly<PageInfo>;
|
|
69
|
+
}
|
|
70
|
+
export type TransformResult =
|
|
71
|
+
| string
|
|
72
|
+
| null
|
|
73
|
+
| { html: string | null; fragments?: PageFragmentContribution[] };
|
|
74
|
+
export type Transform = (ctx: TransformContext) => TransformResult;
|
|
75
|
+
|
|
76
|
+
export interface Logger {
|
|
77
|
+
warn(message: string, data?: unknown): void;
|
|
78
|
+
error(message: string, data?: unknown): void;
|
|
79
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { validatePattern } from "./paths.js";
|
|
2
|
+
import { type EditableSnippet, PAGE_KINDS, PLACEMENTS, type Snippet } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export const LIMITS = {
|
|
5
|
+
maxSnippetBytes: 64 * 1024,
|
|
6
|
+
maxTotalBytes: 512 * 1024,
|
|
7
|
+
maxSnippets: 100,
|
|
8
|
+
maxNameLength: 200,
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type SnippetInput = Omit<EditableSnippet, "meta"> & { meta?: Record<string, unknown> };
|
|
12
|
+
export type ValidationResult =
|
|
13
|
+
| { ok: true; value: SnippetInput }
|
|
14
|
+
| { ok: false; errors: Record<string, string> };
|
|
15
|
+
|
|
16
|
+
const encoder = new TextEncoder();
|
|
17
|
+
export function byteLength(s: string): number {
|
|
18
|
+
return encoder.encode(s).length;
|
|
19
|
+
}
|
|
20
|
+
export function formatKB(bytes: number): string {
|
|
21
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
25
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stringList(v: unknown): string[] | null {
|
|
29
|
+
if (v === undefined) return [];
|
|
30
|
+
if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) return null;
|
|
31
|
+
return v.map((x) => x.trim()).filter((x) => x !== "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pathListError(list: string[]): string | null {
|
|
35
|
+
for (const p of list) {
|
|
36
|
+
const err = validatePattern(p);
|
|
37
|
+
if (err) return `${p}: ${err}`;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function validateSnippetInput(input: unknown): ValidationResult {
|
|
43
|
+
if (!isPlainObject(input)) return { ok: false, errors: { _form: "Invalid request body" } };
|
|
44
|
+
const errors: Record<string, string> = {};
|
|
45
|
+
|
|
46
|
+
const name = typeof input.name === "string" ? input.name.trim() : "";
|
|
47
|
+
if (!name) errors.name = "Name is required";
|
|
48
|
+
else if (name.length > LIMITS.maxNameLength) errors.name = "Name must be at most 200 characters";
|
|
49
|
+
|
|
50
|
+
const code = typeof input.code === "string" ? input.code : "";
|
|
51
|
+
if (code.trim() === "") errors.code = "Code is required";
|
|
52
|
+
|
|
53
|
+
const placement = input.placement ?? "head";
|
|
54
|
+
if (!PLACEMENTS.includes(placement as never)) {
|
|
55
|
+
errors.placement = `Placement must be one of ${PLACEMENTS.join(", ")}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const pageKind = input.pageKind ?? "all";
|
|
59
|
+
if (!PAGE_KINDS.includes(pageKind as never)) {
|
|
60
|
+
errors.pageKind = `Page kind must be one of ${PAGE_KINDS.join(", ")}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const enabled = input.enabled ?? true;
|
|
64
|
+
if (typeof enabled !== "boolean") errors.enabled = "Enabled must be true or false";
|
|
65
|
+
|
|
66
|
+
const priority = input.priority ?? 10;
|
|
67
|
+
if (typeof priority !== "number" || !Number.isInteger(priority)) {
|
|
68
|
+
errors.priority = "Priority must be a whole number";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const includePaths = stringList(input.includePaths);
|
|
72
|
+
if (includePaths === null) errors.includePaths = "Include paths must be a list of paths";
|
|
73
|
+
else {
|
|
74
|
+
const err = pathListError(includePaths);
|
|
75
|
+
if (err) errors.includePaths = err;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const excludePaths = stringList(input.excludePaths);
|
|
79
|
+
if (excludePaths === null) errors.excludePaths = "Exclude paths must be a list of paths";
|
|
80
|
+
else {
|
|
81
|
+
const err = pathListError(excludePaths);
|
|
82
|
+
if (err) errors.excludePaths = err;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let locales: string[] = [];
|
|
86
|
+
if (input.locales !== undefined) {
|
|
87
|
+
if (!Array.isArray(input.locales) || !input.locales.every((l) => typeof l === "string" && l.trim() !== "")) {
|
|
88
|
+
errors.locales = "Locales must be non-empty strings";
|
|
89
|
+
} else {
|
|
90
|
+
locales = input.locales.map((l: string) => l.trim());
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let meta: Record<string, unknown> | undefined;
|
|
95
|
+
if (input.meta !== undefined) {
|
|
96
|
+
if (!isPlainObject(input.meta)) errors.meta = "Meta must be an object";
|
|
97
|
+
else meta = input.meta;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (Object.keys(errors).length > 0) return { ok: false, errors };
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
value: {
|
|
104
|
+
name,
|
|
105
|
+
code,
|
|
106
|
+
placement: placement as SnippetInput["placement"],
|
|
107
|
+
enabled: enabled as boolean,
|
|
108
|
+
priority: priority as number,
|
|
109
|
+
includePaths: includePaths!,
|
|
110
|
+
excludePaths: excludePaths!,
|
|
111
|
+
pageKind: pageKind as SnippetInput["pageKind"],
|
|
112
|
+
locales,
|
|
113
|
+
meta,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Size and count limits. `candidate.id` set = update of an existing snippet. */
|
|
119
|
+
export function checkLimits(
|
|
120
|
+
candidate: { id?: string; code: string },
|
|
121
|
+
existing: Snippet[],
|
|
122
|
+
): Record<string, string> | null {
|
|
123
|
+
const isUpdate = candidate.id !== undefined && existing.some((s) => s.id === candidate.id);
|
|
124
|
+
if (!isUpdate && existing.length >= LIMITS.maxSnippets) {
|
|
125
|
+
return { _form: `You can have at most ${LIMITS.maxSnippets} snippets` };
|
|
126
|
+
}
|
|
127
|
+
const size = byteLength(candidate.code);
|
|
128
|
+
if (size > LIMITS.maxSnippetBytes) {
|
|
129
|
+
return { code: `Code is ${formatKB(size)}; the limit is 64 KB per snippet` };
|
|
130
|
+
}
|
|
131
|
+
const others = existing
|
|
132
|
+
.filter((s) => s.id !== candidate.id)
|
|
133
|
+
.reduce((sum, s) => sum + byteLength(s.code), 0);
|
|
134
|
+
const total = others + size;
|
|
135
|
+
if (total > LIMITS.maxTotalBytes) {
|
|
136
|
+
return { code: `Total code would be ${formatKB(total)}; the limit is 512 KB across all snippets` };
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|