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.
@@ -0,0 +1,608 @@
1
+ import { n as PLUGIN_VERSION, t as PLUGIN_ID } from "./version-iEzwHCKh.mjs";
2
+ import { PluginRouteError, definePlugin } from "emdash";
3
+
4
+ //#region src/core/paths.ts
5
+ const WHITESPACE = /\s/;
6
+ const TRAILING_SLASHES = /\/+$/;
7
+ /** Returns an error message, or null when the pattern is valid. */
8
+ function validatePattern(pattern) {
9
+ if (!pattern.startsWith("/")) return "Path must start with /";
10
+ if (WHITESPACE.test(pattern)) return "Path must not contain whitespace";
11
+ const star = pattern.indexOf("*");
12
+ if (star !== -1 && star !== pattern.length - 1) return "* is only allowed at the end of a path";
13
+ return null;
14
+ }
15
+ /** Strip trailing slashes, keeping "/" for the root. Case-sensitive. */
16
+ function normalizePath(path) {
17
+ if (path === "/") return path;
18
+ const stripped = path.replace(TRAILING_SLASHES, "");
19
+ return stripped === "" ? "/" : stripped;
20
+ }
21
+ /**
22
+ * Decode a percent-encoded path (page paths come from url.pathname, so `café` arrives as
23
+ * `caf%C3%A9`). Malformed escapes fall back to the raw path instead of throwing.
24
+ */
25
+ function safeDecodePath(path) {
26
+ try {
27
+ return decodeURI(path);
28
+ } catch {
29
+ return path;
30
+ }
31
+ }
32
+ /**
33
+ * Compile a validated pattern into a matcher. Throws on invalid patterns.
34
+ * Matching runs on decoded paths: both the page path and the pattern are decoded, so an
35
+ * admin can type `/blog/café/*` (or the encoded form) and it matches `/blog/caf%C3%A9/x`.
36
+ */
37
+ function compilePattern(pattern) {
38
+ const error = validatePattern(pattern);
39
+ if (error) throw new Error(error);
40
+ if (pattern.endsWith("*")) {
41
+ const prefix = safeDecodePath(pattern.slice(0, -1));
42
+ return (path) => normalizePath(safeDecodePath(path)).startsWith(prefix);
43
+ }
44
+ const exact = normalizePath(safeDecodePath(pattern));
45
+ return (path) => normalizePath(safeDecodePath(path)) === exact;
46
+ }
47
+
48
+ //#endregion
49
+ //#region src/core/match.ts
50
+ function isAdminPath(path) {
51
+ return path.startsWith("/_emdash/");
52
+ }
53
+ function compileSnippet(snippet) {
54
+ return {
55
+ snippet,
56
+ include: snippet.includePaths.map(compilePattern),
57
+ exclude: snippet.excludePaths.map(compilePattern)
58
+ };
59
+ }
60
+ function compareSnippets(a, b) {
61
+ if (a.priority !== b.priority) return a.priority - b.priority;
62
+ if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
63
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
64
+ }
65
+ /** Enabled snippets, sorted, with compiled matchers. Invalid ones are skipped and logged. */
66
+ function prepareSnippets(list, log) {
67
+ const out = [];
68
+ for (const snippet of [...list].sort(compareSnippets)) {
69
+ if (!snippet.enabled) continue;
70
+ try {
71
+ out.push(compileSnippet(snippet));
72
+ } catch (error) {
73
+ log.warn("Skipping snippet with an invalid path pattern", {
74
+ snippetId: snippet.id,
75
+ error: error instanceof Error ? error.message : String(error)
76
+ });
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ function matchesPage(c, page) {
82
+ const { snippet } = c;
83
+ if (snippet.pageKind !== "all" && snippet.pageKind !== page.kind) return false;
84
+ if (snippet.locales.length > 0 && (page.locale === null || !snippet.locales.includes(page.locale))) return false;
85
+ if (c.include.length > 0 && !c.include.some((m) => m(page.path))) return false;
86
+ if (c.exclude.some((m) => m(page.path))) return false;
87
+ return true;
88
+ }
89
+
90
+ //#endregion
91
+ //#region src/core/types.ts
92
+ const PLACEMENTS = [
93
+ "head",
94
+ "body:start",
95
+ "body:end"
96
+ ];
97
+ const PAGE_KINDS = [
98
+ "all",
99
+ "content",
100
+ "custom"
101
+ ];
102
+ const DEFAULT_STATE = {
103
+ rev: 0,
104
+ disabled: false
105
+ };
106
+
107
+ //#endregion
108
+ //#region src/core/validate.ts
109
+ const LIMITS = {
110
+ maxSnippetBytes: 64 * 1024,
111
+ maxTotalBytes: 512 * 1024,
112
+ maxSnippets: 100,
113
+ maxNameLength: 200
114
+ };
115
+ const encoder = new TextEncoder();
116
+ function byteLength(s) {
117
+ return encoder.encode(s).length;
118
+ }
119
+ function formatKB(bytes) {
120
+ return `${(bytes / 1024).toFixed(1)} KB`;
121
+ }
122
+ function isPlainObject(v) {
123
+ return typeof v === "object" && v !== null && !Array.isArray(v);
124
+ }
125
+ function stringList(v) {
126
+ if (v === void 0) return [];
127
+ if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) return null;
128
+ return v.map((x) => x.trim()).filter((x) => x !== "");
129
+ }
130
+ function pathListError(list) {
131
+ for (const p of list) {
132
+ const err = validatePattern(p);
133
+ if (err) return `${p}: ${err}`;
134
+ }
135
+ return null;
136
+ }
137
+ function validateSnippetInput(input) {
138
+ if (!isPlainObject(input)) return {
139
+ ok: false,
140
+ errors: { _form: "Invalid request body" }
141
+ };
142
+ const errors = {};
143
+ const name = typeof input.name === "string" ? input.name.trim() : "";
144
+ if (!name) errors.name = "Name is required";
145
+ else if (name.length > LIMITS.maxNameLength) errors.name = "Name must be at most 200 characters";
146
+ const code = typeof input.code === "string" ? input.code : "";
147
+ if (code.trim() === "") errors.code = "Code is required";
148
+ const placement = input.placement ?? "head";
149
+ if (!PLACEMENTS.includes(placement)) errors.placement = `Placement must be one of ${PLACEMENTS.join(", ")}`;
150
+ const pageKind = input.pageKind ?? "all";
151
+ if (!PAGE_KINDS.includes(pageKind)) errors.pageKind = `Page kind must be one of ${PAGE_KINDS.join(", ")}`;
152
+ const enabled = input.enabled ?? true;
153
+ if (typeof enabled !== "boolean") errors.enabled = "Enabled must be true or false";
154
+ const priority = input.priority ?? 10;
155
+ if (typeof priority !== "number" || !Number.isInteger(priority)) errors.priority = "Priority must be a whole number";
156
+ const includePaths = stringList(input.includePaths);
157
+ if (includePaths === null) errors.includePaths = "Include paths must be a list of paths";
158
+ else {
159
+ const err = pathListError(includePaths);
160
+ if (err) errors.includePaths = err;
161
+ }
162
+ const excludePaths = stringList(input.excludePaths);
163
+ if (excludePaths === null) errors.excludePaths = "Exclude paths must be a list of paths";
164
+ else {
165
+ const err = pathListError(excludePaths);
166
+ if (err) errors.excludePaths = err;
167
+ }
168
+ let locales = [];
169
+ if (input.locales !== void 0) if (!Array.isArray(input.locales) || !input.locales.every((l) => typeof l === "string" && l.trim() !== "")) errors.locales = "Locales must be non-empty strings";
170
+ else locales = input.locales.map((l) => l.trim());
171
+ let meta;
172
+ if (input.meta !== void 0) if (!isPlainObject(input.meta)) errors.meta = "Meta must be an object";
173
+ else meta = input.meta;
174
+ if (Object.keys(errors).length > 0) return {
175
+ ok: false,
176
+ errors
177
+ };
178
+ return {
179
+ ok: true,
180
+ value: {
181
+ name,
182
+ code,
183
+ placement,
184
+ enabled,
185
+ priority,
186
+ includePaths,
187
+ excludePaths,
188
+ pageKind,
189
+ locales,
190
+ meta
191
+ }
192
+ };
193
+ }
194
+ /** Size and count limits. `candidate.id` set = update of an existing snippet. */
195
+ function checkLimits(candidate, existing) {
196
+ if (!(candidate.id !== void 0 && existing.some((s) => s.id === candidate.id)) && existing.length >= LIMITS.maxSnippets) return { _form: `You can have at most ${LIMITS.maxSnippets} snippets` };
197
+ const size = byteLength(candidate.code);
198
+ if (size > LIMITS.maxSnippetBytes) return { code: `Code is ${formatKB(size)}; the limit is 64 KB per snippet` };
199
+ const total = existing.filter((s) => s.id !== candidate.id).reduce((sum, s) => sum + byteLength(s.code), 0) + size;
200
+ if (total > LIMITS.maxTotalBytes) return { code: `Total code would be ${formatKB(total)}; the limit is 512 KB across all snippets` };
201
+ return null;
202
+ }
203
+
204
+ //#endregion
205
+ //#region src/repo.ts
206
+ /**
207
+ * All snippets in one query. The 100-snippet cap keeps this to a single page.
208
+ * The cap check (checkLimits) and the write are not atomic, so concurrent creates can push
209
+ * the stored count past 100; snippets beyond the first 100 (by createdAt) are then not
210
+ * loaded. Acceptable for v0.1.
211
+ */
212
+ async function loadAllSnippets(c) {
213
+ const { items } = await c.query({
214
+ orderBy: { createdAt: "asc" },
215
+ limit: LIMITS.maxSnippets
216
+ });
217
+ return items.map((i) => i.data);
218
+ }
219
+ function userRef(user) {
220
+ return user ? {
221
+ id: user.id,
222
+ name: user.name,
223
+ email: user.email
224
+ } : {
225
+ id: "unknown",
226
+ name: null,
227
+ email: null
228
+ };
229
+ }
230
+ async function appendLog(c, entry, newId) {
231
+ const full = {
232
+ id: newId(),
233
+ ...entry
234
+ };
235
+ await c.put(full.id, full);
236
+ return full;
237
+ }
238
+ async function listLog(c) {
239
+ const { items } = await c.query({
240
+ orderBy: { at: "desc" },
241
+ limit: 100
242
+ });
243
+ return items.map((i) => i.data);
244
+ }
245
+
246
+ //#endregion
247
+ //#region src/state.ts
248
+ const STATE_KEY = "state";
249
+ const MAX_ATTEMPTS = 5;
250
+ async function readState(kv) {
251
+ return await kv.get(STATE_KEY) ?? { ...DEFAULT_STATE };
252
+ }
253
+ /** Atomically bump `rev` (and optionally set `disabled`). Retries on conflict. */
254
+ async function bumpState(kv, change = {}) {
255
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
256
+ const current = await kv.getVersioned(STATE_KEY);
257
+ const base = current?.value ?? DEFAULT_STATE;
258
+ const next = {
259
+ ...base,
260
+ ...change,
261
+ rev: base.rev + 1
262
+ };
263
+ if ((await kv.compareAndSet(STATE_KEY, current?.revision ?? null, next)).applied) return next;
264
+ }
265
+ throw new Error(`Could not update plugin state after ${MAX_ATTEMPTS} attempts`);
266
+ }
267
+
268
+ //#endregion
269
+ //#region src/cache.ts
270
+ /**
271
+ * Per-isolate cache. The kv `state` read is shared for `stateTtlMs`. This covers
272
+ * the up-to-3 page:fragments calls per request and bursts of concurrent requests.
273
+ * The compiled snippet list is rebuilt only when `state.rev` changes.
274
+ */
275
+ function createSnippetCache(opts = {}) {
276
+ const now = opts.now ?? Date.now;
277
+ const ttl = opts.stateTtlMs ?? 1e3;
278
+ let statePromise = null;
279
+ let stateReadAt = 0;
280
+ let cachedRev = null;
281
+ let compiled = [];
282
+ function getState(ctx) {
283
+ const t = now();
284
+ if (!statePromise || t - stateReadAt >= ttl) {
285
+ stateReadAt = t;
286
+ const p = readState(ctx.kv);
287
+ statePromise = p;
288
+ p.catch(() => {
289
+ if (statePromise === p) statePromise = null;
290
+ });
291
+ }
292
+ return statePromise;
293
+ }
294
+ return {
295
+ async load(ctx) {
296
+ const state = await getState(ctx);
297
+ if (state.disabled) return {
298
+ disabled: true,
299
+ snippets: []
300
+ };
301
+ if (cachedRev !== state.rev) {
302
+ compiled = prepareSnippets(await loadAllSnippets(ctx.storage.snippets), ctx.log);
303
+ cachedRev = state.rev;
304
+ }
305
+ return {
306
+ disabled: false,
307
+ snippets: compiled
308
+ };
309
+ },
310
+ invalidate() {
311
+ statePromise = null;
312
+ }
313
+ };
314
+ }
315
+
316
+ //#endregion
317
+ //#region src/core/pipeline.ts
318
+ /** match → (already sorted) → transform → emit */
319
+ function renderFragments(compiled, page, transforms, log) {
320
+ const snippetFragments = [];
321
+ const extraFragments = [];
322
+ const seenExtraKeys = /* @__PURE__ */ new Set();
323
+ for (const c of compiled) {
324
+ if (!matchesPage(c, page)) continue;
325
+ const { snippet } = c;
326
+ let html = snippet.code;
327
+ for (let i = 0; i < transforms.length && html !== null; i++) {
328
+ const transform = transforms[i];
329
+ try {
330
+ const result = transform({
331
+ snippet,
332
+ html,
333
+ page
334
+ });
335
+ if (result === null || typeof result === "string") html = result;
336
+ else {
337
+ html = result.html;
338
+ for (const fragment of result.fragments ?? []) {
339
+ if (fragment.key !== void 0) {
340
+ if (seenExtraKeys.has(fragment.key)) continue;
341
+ seenExtraKeys.add(fragment.key);
342
+ }
343
+ extraFragments.push(fragment);
344
+ }
345
+ }
346
+ } catch (error) {
347
+ log.error("Snippet transform failed; skipping snippet", {
348
+ snippetId: snippet.id,
349
+ transformIndex: i,
350
+ error: error instanceof Error ? error.message : String(error)
351
+ });
352
+ html = null;
353
+ }
354
+ }
355
+ if (html !== null) snippetFragments.push({
356
+ kind: "html",
357
+ placement: snippet.placement,
358
+ html,
359
+ key: `hfc-${snippet.id}`
360
+ });
361
+ }
362
+ return [...snippetFragments, ...extraFragments];
363
+ }
364
+
365
+ //#endregion
366
+ //#region src/hook.ts
367
+ function createFragmentsHandler(cache, transforms) {
368
+ return async function handlePageFragments(event, ctx) {
369
+ const { page } = event;
370
+ if (isAdminPath(page.path)) return null;
371
+ try {
372
+ const { disabled, snippets } = await cache.load(ctx);
373
+ if (disabled || snippets.length === 0) return null;
374
+ const fragments = renderFragments(snippets, page, transforms, ctx.log);
375
+ return fragments.length > 0 ? fragments : null;
376
+ } catch (error) {
377
+ ctx.log.error("Header & Footer Code: failed to render snippets", { error: error instanceof Error ? error.message : String(error) });
378
+ return null;
379
+ }
380
+ };
381
+ }
382
+
383
+ //#endregion
384
+ //#region src/routes.ts
385
+ /** Mirrors Role.ADMIN in @emdash-cms/auth (plugins:manage). */
386
+ const ROLE_ADMIN = 50;
387
+ const SCHEMA_VERSION = 1;
388
+ function cols(ctx) {
389
+ return {
390
+ snippets: ctx.storage.snippets,
391
+ changelog: ctx.storage.changelog
392
+ };
393
+ }
394
+ function body(ctx) {
395
+ const input = ctx.input;
396
+ return typeof input === "object" && input !== null && !Array.isArray(input) ? input : {};
397
+ }
398
+ function requireId(ctx) {
399
+ const id = body(ctx).id;
400
+ if (typeof id !== "string" || id === "") throw PluginRouteError.badRequest("Missing snippet id");
401
+ return id;
402
+ }
403
+ async function requireSnippet(ctx, id) {
404
+ const snippet = await cols(ctx).snippets.get(id);
405
+ if (!snippet) throw new PluginRouteError("NOT_FOUND", "Snippet not found", 404);
406
+ return snippet;
407
+ }
408
+ /**
409
+ * 400 with field errors. EmDash 1.0.1's HTTP dispatch forwards only `code` and `message`
410
+ * (it drops `details`), so the message itself must be the readable error text.
411
+ */
412
+ function validationError(errors) {
413
+ return PluginRouteError.badRequest(Object.values(errors).join("; "), { errors });
414
+ }
415
+ function assertWritable(snippet) {
416
+ if (snippet.schemaVersion > SCHEMA_VERSION) throw new PluginRouteError("UNSUPPORTED_SCHEMA", "This snippet was saved by a newer version of the plugin. Upgrade the plugin to edit it.", 409);
417
+ }
418
+ function createRoutes(deps) {
419
+ /**
420
+ * Bump rev, invalidate, then log. Runs after every successful record write.
421
+ *
422
+ * Rev moves first so a failed change-log write can never leave isolates serving stale
423
+ * output (e.g. a deleted snippet still rendering). A failed log write is reported via
424
+ * ctx.log.error and does not fail the request: the record write already happened, so a
425
+ * 5xx would mislead the admin into retrying (a duplicate create, or a 404 on delete).
426
+ * A lost audit row is the lesser harm.
427
+ */
428
+ async function afterWrite(ctx, action, snippet, change) {
429
+ const state = await bumpState(ctx.kv, change);
430
+ deps.cache.invalidate();
431
+ try {
432
+ await appendLog(cols(ctx).changelog, {
433
+ at: deps.now(),
434
+ user: userRef(ctx.user),
435
+ action,
436
+ snippetId: snippet?.id ?? null,
437
+ snippetName: snippet?.name ?? null
438
+ }, deps.newId);
439
+ } catch (error) {
440
+ ctx.log.error("header-footer-code: could not write change-log entry", {
441
+ action,
442
+ snippetId: snippet?.id ?? null,
443
+ error: error instanceof Error ? error.message : String(error)
444
+ });
445
+ }
446
+ return state;
447
+ }
448
+ return {
449
+ "snippets/list": {
450
+ permission: "plugins:read",
451
+ handler: async (ctx) => {
452
+ const canManage = (ctx.user?.role ?? 0) >= ROLE_ADMIN;
453
+ const [state, all] = await Promise.all([readState(ctx.kv), loadAllSnippets(cols(ctx).snippets)]);
454
+ const snippets = canManage ? all : all.map(({ code: _code, ...rest }) => rest);
455
+ return {
456
+ disabled: state.disabled,
457
+ canManage,
458
+ snippets
459
+ };
460
+ }
461
+ },
462
+ "snippets/get": {
463
+ permission: "plugins:manage",
464
+ handler: async (ctx) => ({ snippet: await requireSnippet(ctx, requireId(ctx)) })
465
+ },
466
+ "snippets/save": {
467
+ permission: "plugins:manage",
468
+ handler: async (ctx) => {
469
+ const input = body(ctx);
470
+ const id = typeof input.id === "string" && input.id !== "" ? input.id : void 0;
471
+ const result = validateSnippetInput(input);
472
+ if (!result.ok) throw validationError(result.errors);
473
+ const all = await loadAllSnippets(cols(ctx).snippets);
474
+ const existing = id ? all.find((s) => s.id === id) : void 0;
475
+ if (id && !existing) throw new PluginRouteError("NOT_FOUND", "Snippet not found", 404);
476
+ if (existing) assertWritable(existing);
477
+ const limitErrors = checkLimits({
478
+ id,
479
+ code: result.value.code
480
+ }, all);
481
+ if (limitErrors) throw validationError(limitErrors);
482
+ const now = deps.now();
483
+ const user = userRef(ctx.user);
484
+ const { meta, ...fields } = result.value;
485
+ const snippet = existing ? {
486
+ ...existing,
487
+ ...fields,
488
+ meta: meta ?? existing.meta,
489
+ updatedAt: now,
490
+ updatedBy: user
491
+ } : {
492
+ id: deps.newId(),
493
+ schemaVersion: SCHEMA_VERSION,
494
+ ...fields,
495
+ meta: meta ?? {},
496
+ createdAt: now,
497
+ updatedAt: now,
498
+ createdBy: user,
499
+ updatedBy: user
500
+ };
501
+ await cols(ctx).snippets.put(snippet.id, snippet);
502
+ await afterWrite(ctx, existing ? "update" : "create", snippet);
503
+ return { snippet };
504
+ }
505
+ },
506
+ "snippets/toggle": {
507
+ permission: "plugins:manage",
508
+ handler: async (ctx) => {
509
+ const id = requireId(ctx);
510
+ const enabled = body(ctx).enabled;
511
+ if (typeof enabled !== "boolean") throw PluginRouteError.badRequest("enabled must be true or false");
512
+ const current = await requireSnippet(ctx, id);
513
+ assertWritable(current);
514
+ const snippet = {
515
+ ...current,
516
+ enabled,
517
+ updatedAt: deps.now(),
518
+ updatedBy: userRef(ctx.user)
519
+ };
520
+ await cols(ctx).snippets.put(id, snippet);
521
+ await afterWrite(ctx, enabled ? "enable" : "disable", snippet);
522
+ return { snippet };
523
+ }
524
+ },
525
+ "snippets/duplicate": {
526
+ permission: "plugins:manage",
527
+ handler: async (ctx) => {
528
+ const source = await requireSnippet(ctx, requireId(ctx));
529
+ const all = await loadAllSnippets(cols(ctx).snippets);
530
+ const limitErrors = checkLimits({ code: source.code }, all);
531
+ if (limitErrors) throw validationError(limitErrors);
532
+ const now = deps.now();
533
+ const user = userRef(ctx.user);
534
+ const snippet = {
535
+ ...structuredClone(source),
536
+ id: deps.newId(),
537
+ name: `${source.name} (copy)`.slice(0, 200),
538
+ enabled: false,
539
+ createdAt: now,
540
+ updatedAt: now,
541
+ createdBy: user,
542
+ updatedBy: user
543
+ };
544
+ await cols(ctx).snippets.put(snippet.id, snippet);
545
+ await afterWrite(ctx, "duplicate", snippet);
546
+ return { snippet };
547
+ }
548
+ },
549
+ "snippets/delete": {
550
+ permission: "plugins:manage",
551
+ handler: async (ctx) => {
552
+ const snippet = await requireSnippet(ctx, requireId(ctx));
553
+ await cols(ctx).snippets.delete(snippet.id);
554
+ await afterWrite(ctx, "delete", snippet);
555
+ return { deleted: true };
556
+ }
557
+ },
558
+ "killswitch/set": {
559
+ permission: "plugins:manage",
560
+ handler: async (ctx) => {
561
+ const disabled = body(ctx).disabled;
562
+ if (typeof disabled !== "boolean") throw PluginRouteError.badRequest("disabled must be true or false");
563
+ return { disabled: (await afterWrite(ctx, disabled ? "killswitch_on" : "killswitch_off", null, { disabled })).disabled };
564
+ }
565
+ },
566
+ "changelog/list": {
567
+ permission: "plugins:manage",
568
+ handler: async (ctx) => ({ entries: await listLog(cols(ctx).changelog) })
569
+ }
570
+ };
571
+ }
572
+
573
+ //#endregion
574
+ //#region src/plugin.ts
575
+ const BUILTIN_TRANSFORMS = [];
576
+ const ADMIN_ENTRY = "emdash-header-footer-code/admin";
577
+ const ADMIN_PAGES = [{
578
+ path: "/snippets",
579
+ label: "Header & Footer Code",
580
+ icon: "code"
581
+ }];
582
+ function createPlugin(options = {}) {
583
+ const transforms = [...BUILTIN_TRANSFORMS, ...options.transforms ?? []];
584
+ const cache = createSnippetCache();
585
+ const fragments = createFragmentsHandler(cache, transforms);
586
+ return definePlugin({
587
+ id: PLUGIN_ID,
588
+ version: PLUGIN_VERSION,
589
+ capabilities: ["hooks.page-fragments:register"],
590
+ storage: {
591
+ snippets: { indexes: ["createdAt"] },
592
+ changelog: { indexes: ["at"] }
593
+ },
594
+ admin: {
595
+ entry: ADMIN_ENTRY,
596
+ pages: ADMIN_PAGES
597
+ },
598
+ hooks: { "page:fragments": { handler: (event, ctx) => fragments(event, ctx) } },
599
+ routes: createRoutes({
600
+ cache,
601
+ now: () => (/* @__PURE__ */ new Date()).toISOString(),
602
+ newId: () => crypto.randomUUID()
603
+ })
604
+ });
605
+ }
606
+
607
+ //#endregion
608
+ export { ADMIN_ENTRY, ADMIN_PAGES, createPlugin };
@@ -0,0 +1,46 @@
1
+ import { PageFragmentContribution, PublicPageContext } from "emdash";
2
+
3
+ //#region src/core/types.d.ts
4
+ declare const PLACEMENTS: readonly ["head", "body:start", "body:end"];
5
+ type Placement = (typeof PLACEMENTS)[number];
6
+ declare const PAGE_KINDS: readonly ["all", "content", "custom"];
7
+ type PageKind = (typeof PAGE_KINDS)[number];
8
+ interface UserRef {
9
+ id: string;
10
+ name: string | null;
11
+ email: string | null;
12
+ }
13
+ /** Fields an admin edits. */
14
+ interface EditableSnippet {
15
+ name: string;
16
+ code: string;
17
+ placement: Placement;
18
+ enabled: boolean;
19
+ priority: number;
20
+ includePaths: string[];
21
+ excludePaths: string[];
22
+ pageKind: PageKind;
23
+ locales: string[];
24
+ meta: Record<string, unknown>;
25
+ }
26
+ interface Snippet extends EditableSnippet {
27
+ id: string;
28
+ schemaVersion: number;
29
+ createdAt: string;
30
+ updatedAt: string;
31
+ createdBy: UserRef;
32
+ updatedBy: UserRef;
33
+ }
34
+ type PageInfo = PublicPageContext;
35
+ interface TransformContext {
36
+ snippet: Readonly<Snippet>;
37
+ html: string;
38
+ page: Readonly<PageInfo>;
39
+ }
40
+ type TransformResult = string | null | {
41
+ html: string | null;
42
+ fragments?: PageFragmentContribution[];
43
+ };
44
+ type Transform = (ctx: TransformContext) => TransformResult;
45
+ //#endregion
46
+ export { TransformResult as i, Transform as n, TransformContext as r, Snippet as t };
@@ -0,0 +1,6 @@
1
+ //#region src/version.ts
2
+ const PLUGIN_ID = "header-footer-code";
3
+ const PLUGIN_VERSION = "0.1.0";
4
+
5
+ //#endregion
6
+ export { PLUGIN_VERSION as n, PLUGIN_ID as t };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "emdash-header-footer-code",
3
+ "version": "0.1.0",
4
+ "description": "Add custom code snippets (analytics, verification tags, chat widgets, CSS/JS) to the head or body of EmDash pages — no theme edits.",
5
+ "repository": { "type": "git", "url": "git+https://github.com/jithinsk/emdash-header-footer-code.git" },
6
+ "homepage": "https://github.com/jithinsk/emdash-header-footer-code#readme",
7
+ "bugs": { "url": "https://github.com/jithinsk/emdash-header-footer-code/issues" },
8
+ "type": "module",
9
+ "license": "MIT",
10
+ "keywords": ["emdash", "emdash-plugin", "cms", "header", "footer", "snippets", "analytics"],
11
+ "exports": {
12
+ ".": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" },
13
+ "./plugin": { "types": "./dist/plugin.d.mts", "default": "./dist/plugin.mjs" },
14
+ "./admin": "./src/admin/index.tsx"
15
+ },
16
+ "files": ["dist", "src/admin", "src/core", "README.md", "LICENSE"],
17
+ "scripts": {
18
+ "build": "tsdown",
19
+ "test": "vitest run",
20
+ "typecheck": "tsc --noEmit",
21
+ "e2e": "pnpm build && playwright test -c e2e/playwright.config.ts",
22
+ "smoke": "node scripts/smoke-pack.mjs"
23
+ },
24
+ "peerDependencies": {
25
+ "emdash": "^1.0.1",
26
+ "react": "^18.0.0 || ^19.0.0",
27
+ "@cloudflare/kumo": "^2.6.0"
28
+ },
29
+ "devDependencies": {
30
+ "@axe-core/playwright": "^4.13.0",
31
+ "@astrojs/react": "^6.0.5",
32
+ "@cloudflare/kumo": "2.6.0",
33
+ "@playwright/test": "^1.56.0",
34
+ "@types/react": "19.2.14",
35
+ "better-sqlite3": "^12.4.1",
36
+ "@types/better-sqlite3": "^7.6.13",
37
+ "emdash": "1.0.1",
38
+ "react": "19.2.4",
39
+ "react-dom": "19.2.4",
40
+ "tsdown": "0.20.3",
41
+ "typescript": "^6.0.3",
42
+ "vitest": "4.1.10"
43
+ },
44
+ "packageManager": "pnpm@10.33.0",
45
+ "pnpm": { "onlyBuiltDependencies": ["better-sqlite3", "esbuild"] }
46
+ }