sveltekit-admin 0.5.3 → 0.8.1

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.
Files changed (71) hide show
  1. package/README.md +89 -1
  2. package/dist/index.d.ts +6 -1
  3. package/dist/index.js +2 -1
  4. package/dist/server/adapters/drizzle/dataAdapter.d.ts +11 -0
  5. package/dist/server/adapters/drizzle/dataAdapter.js +328 -0
  6. package/dist/server/adapters/drizzle/filterCompiler.d.ts +8 -0
  7. package/dist/server/adapters/drizzle/filterCompiler.js +61 -0
  8. package/dist/server/adapters/drizzle/index.d.ts +22 -0
  9. package/dist/server/adapters/drizzle/index.js +27 -0
  10. package/dist/server/adapters/drizzle/inspect.d.ts +23 -0
  11. package/dist/server/adapters/drizzle/inspect.js +220 -0
  12. package/dist/server/adapters/drizzle/introspector.d.ts +3 -0
  13. package/dist/server/adapters/drizzle/introspector.js +3 -0
  14. package/dist/server/adapters/filter.d.ts +12 -0
  15. package/dist/server/adapters/filter.js +72 -0
  16. package/dist/server/adapters/prisma/dataAdapter.d.ts +13 -0
  17. package/dist/server/adapters/prisma/dataAdapter.js +138 -0
  18. package/dist/server/adapters/prisma/filterCompiler.d.ts +9 -0
  19. package/dist/server/adapters/prisma/filterCompiler.js +42 -0
  20. package/dist/server/adapters/prisma/handler.d.ts +14 -0
  21. package/dist/server/adapters/prisma/handler.js +37 -0
  22. package/dist/server/adapters/prisma/index.d.ts +28 -0
  23. package/dist/server/adapters/prisma/index.js +32 -0
  24. package/dist/server/adapters/prisma/introspector.d.ts +4 -0
  25. package/dist/server/adapters/prisma/introspector.js +9 -0
  26. package/dist/server/adapters/retry.d.ts +27 -0
  27. package/dist/server/adapters/retry.js +53 -0
  28. package/dist/server/adapters/types.d.ts +86 -0
  29. package/dist/server/adapters/types.js +1 -0
  30. package/dist/server/audit.d.ts +65 -0
  31. package/dist/server/audit.js +106 -0
  32. package/dist/server/csrf.d.ts +33 -0
  33. package/dist/server/csrf.js +55 -0
  34. package/dist/server/data.d.ts +0 -8
  35. package/dist/server/data.js +0 -41
  36. package/dist/server/errors.d.ts +47 -0
  37. package/dist/server/errors.js +90 -0
  38. package/dist/server/handler.d.ts +63 -16
  39. package/dist/server/handler.js +212 -535
  40. package/dist/server/introspection/parser.d.ts +4 -40
  41. package/dist/server/introspection/relations.d.ts +1 -1
  42. package/dist/server/introspection/relations.js +1 -1
  43. package/dist/server/mutations.d.ts +9 -0
  44. package/dist/server/mutations.js +295 -0
  45. package/dist/server/plugin.d.ts +47 -0
  46. package/dist/server/plugin.js +1 -0
  47. package/dist/server/pluginAccess.d.ts +7 -0
  48. package/dist/server/pluginAccess.js +79 -0
  49. package/dist/server/pluginRegistry.d.ts +12 -0
  50. package/dist/server/pluginRegistry.js +72 -0
  51. package/dist/server/query/listQuery.d.ts +6 -12
  52. package/dist/server/query/listQuery.js +31 -53
  53. package/dist/server/relationLoaders.d.ts +42 -0
  54. package/dist/server/relationLoaders.js +188 -0
  55. package/dist/server/router.d.ts +10 -0
  56. package/dist/server/router.js +42 -19
  57. package/dist/server/runtime.d.ts +46 -0
  58. package/dist/server/runtime.js +210 -0
  59. package/dist/server/search.d.ts +14 -0
  60. package/dist/server/search.js +78 -0
  61. package/dist/server/types/schema.d.ts +40 -0
  62. package/dist/server/types/schema.js +8 -0
  63. package/dist/server/views/Form.svelte +27 -3
  64. package/dist/server/views/Form.svelte.d.ts +2 -1
  65. package/dist/server/views/Layout.svelte +27 -2
  66. package/dist/server/views/Layout.svelte.d.ts +2 -0
  67. package/dist/server/views/List.svelte +28 -3
  68. package/dist/server/views/List.svelte.d.ts +2 -2
  69. package/dist/server/views/RelationCheckboxes.svelte +1 -1
  70. package/dist/server/views/types.d.ts +8 -0
  71. package/package.json +40 -17
@@ -0,0 +1,210 @@
1
+ import { isSensitiveFieldName } from './introspection/parser.js';
2
+ import { buildRelationGraph } from './introspection/relations.js';
3
+ import { primaryKeyOf } from './data.js';
4
+ import { validateListFilterConfig } from './query/filterDetection.js';
5
+ import { isCompositeFilter, isLeafFilter, normalizeScope } from './adapters/filter.js';
6
+ import { toLabel } from './views/html.js';
7
+ import { AdminConfigError } from './errors.js';
8
+ export function scopeFrom(relConfig, ctx) {
9
+ return relConfig?.where ? normalizeScope(relConfig.where(ctx)) : undefined;
10
+ }
11
+ export function listScopeFrom(runtime, model, ctx) {
12
+ const listScope = runtime.config.models?.[model.name]?.listWhere?.(ctx);
13
+ // A scope function returning `{}` (falsy-looking but truthy as
14
+ // an object) would otherwise silently fail OPEN — `{}` composed
15
+ // into an AND matches every row, exactly the opposite of what a
16
+ // caller configuring listWhere expects (real gap found in
17
+ // review: `locals.userId` undefined after a session expires is
18
+ // a realistic way to hit this). Fail loud instead: a scope
19
+ // function is either omitted entirely, or must return at least
20
+ // one condition every time it runs.
21
+ if (listScope && Object.keys(listScope).length === 0) {
22
+ throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.listWhere returned an empty object ({}), ` +
23
+ `which would silently disable list scoping (fail-open). Return undefined/omit the ` +
24
+ `scope entirely if there is genuinely nothing to scope by for this request, or a ` +
25
+ `condition that actually restricts rows otherwise.`);
26
+ }
27
+ return listScope;
28
+ }
29
+ export function modelScopeFrom(runtime, model, ctx) {
30
+ const scope = runtime.config.models?.[model.name]?.scope;
31
+ if (!scope)
32
+ return undefined;
33
+ const raw = scope(ctx);
34
+ if (!raw || (typeof raw === 'object' && !Array.isArray(raw) && Object.keys(raw).length === 0)) {
35
+ throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.scope must return a non-empty condition; ` +
36
+ 'refusing to fail open.');
37
+ }
38
+ const normalized = normalizeScope(raw);
39
+ const valid = (node) => {
40
+ if (isLeafFilter(node))
41
+ return node.value !== undefined;
42
+ return isCompositeFilter(node) && node.clauses.length > 0 && node.clauses.every(valid);
43
+ };
44
+ if (!valid(normalized)) {
45
+ throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.scope returned an invalid condition; refusing to fail open.`);
46
+ }
47
+ return normalized;
48
+ }
49
+ /** Extract equality predicates so create can force tenant-owned columns. */
50
+ export function modelScopeValues(runtime, model, ctx) {
51
+ const normalized = modelScopeFrom(runtime, model, ctx);
52
+ if (!normalized)
53
+ return {};
54
+ const values = {};
55
+ const visit = (node) => {
56
+ if (isLeafFilter(node)) {
57
+ if (node.op !== 'eq')
58
+ return false;
59
+ if (node.field in values && values[node.field] !== node.value)
60
+ return false;
61
+ values[node.field] = node.value;
62
+ return true;
63
+ }
64
+ if (!isCompositeFilter(node) || node.op === 'or')
65
+ return false;
66
+ return node.clauses.every(visit);
67
+ };
68
+ if (!visit(normalized) || Object.keys(values).length === 0) {
69
+ throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.scope must contain only equality conditions for creation`);
70
+ }
71
+ return values;
72
+ }
73
+ export function createAdminRuntime(config) {
74
+ const { basePath = '/admin', exclude = [], hidePivotTables = true, models: modelsConfig = {} } = config;
75
+ const adapter = config.adapter;
76
+ const introspector = adapter.introspector;
77
+ // Introspect the schema once at startup — same failure handling as before:
78
+ // a broken/missing schema source degrades to "no models known" rather than
79
+ // throwing out of `createAdminHandler` itself.
80
+ let schema = null;
81
+ let relationGraph = null;
82
+ try {
83
+ const introspected = introspector.introspect();
84
+ if (introspected instanceof Promise) {
85
+ throw new Error('[sveltekit-admin] SchemaIntrospector.introspect() returned a Promise — ' +
86
+ 'createAdminHandler only supports synchronous introspection today.');
87
+ }
88
+ schema = introspected;
89
+ relationGraph = buildRelationGraph(schema);
90
+ for (const d of relationGraph.diagnostics) {
91
+ console.warn(`[sveltekit-admin] ${d}`);
92
+ }
93
+ }
94
+ catch (e) {
95
+ console.warn('[sveltekit-admin] Could not introspect schema:', e);
96
+ }
97
+ const models = schema?.models.filter((m) => {
98
+ // Exclude explicitly excluded models
99
+ if (exclude.includes(m.name))
100
+ return false;
101
+ // Exclude pivot tables if option is enabled
102
+ if (hidePivotTables && m.isPivotTable)
103
+ return false;
104
+ return true;
105
+ }) || [];
106
+ // Valider `listFilter` au démarrage : une config invalide (champ
107
+ // inexistant, sensible, relation, type non supporté) doit échouer fort
108
+ // ici plutôt que produire silencieusement un filtre mort à chaque rendu
109
+ // de liste (docs/design §8, même politique que le groupe ambigu de
110
+ // relations.ts).
111
+ const hiddenFieldsOf = (m) => new Set(modelsConfig[m.name]?.hidden ?? []);
112
+ for (const m of models) {
113
+ const entries = modelsConfig[m.name]?.listFilter;
114
+ // Non-null par construction : `models` n'existe que si le schéma
115
+ // a été parsé, et le graphe est construit dans la même branche de boot.
116
+ if (entries)
117
+ validateListFilterConfig(m.name, entries, m, relationGraph, hiddenFieldsOf(m));
118
+ }
119
+ const labelOf = (m) => {
120
+ const configured = modelsConfig[m.name]?.label;
121
+ if (configured)
122
+ return configured;
123
+ const label = toLabel(m.name);
124
+ return label.charAt(0).toUpperCase() + label.slice(1);
125
+ };
126
+ const modelList = models.map((m) => ({ name: m.name, label: labelOf(m) }));
127
+ const findModel = (name) => models.find((m) => m.name.toLowerCase() === name?.toLowerCase());
128
+ const viewModel = (m) => ({
129
+ name: m.name,
130
+ label: labelOf(m),
131
+ fields: m.fields,
132
+ primaryKey: primaryKeyOf(m),
133
+ // Non-null par construction : `m` vient toujours de `models`,
134
+ // dérivé du schéma qu'on vient de parser avec succès.
135
+ relationGraph: relationGraph
136
+ });
137
+ const selectThreshold = config.relationDefaults?.selectThreshold ?? 200;
138
+ const filterLinkThreshold = config.listFilterDefaults?.linkThreshold ?? 20;
139
+ const labelFieldCandidates = config.relationDefaults?.labelFields ?? [
140
+ 'name', 'title', 'label', 'email', 'username', 'slug'
141
+ ];
142
+ /**
143
+ * Champs qu'un `?f.<field>=` est autorisé à cibler pour ce modèle : tout
144
+ * champ scalaire non-liste, non-relation, de type filtrable
145
+ * (String/Int/Float/Decimal/BigInt/Boolean/DateTime/enum — donc pas
146
+ * Json/Bytes), non sensible, et non listé dans `hidden` pour ce modèle.
147
+ * Sans ce dernier point, `hidden: ['internalNotes']` ne fait que masquer
148
+ * l'affichage : le champ reste un oracle de confirmation de valeur via
149
+ * `?f.internalNotes=...contains...`, exactement la faille §0.a fermée
150
+ * ailleurs pour les champs sensibles par nom — `hidden` et le prédicat
151
+ * de sensibilité sont deux sources distinctes, toutes deux doivent
152
+ * fermer l'oracle (docs/design §10, "deux sources, un seul prédicat
153
+ * partagé, sinon divergence garantie"). Défense en profondeur :
154
+ * `listQuery.ts` revérifie lui-même la sensibilité par nom, ce set est
155
+ * la première passe et la seule à connaître la config `hidden`.
156
+ */
157
+ const resolveFilterableFields = (model) => {
158
+ const hidden = hiddenFieldsOf(model);
159
+ const out = new Set();
160
+ for (const f of model.fields) {
161
+ if (f.relation || f.isList)
162
+ continue;
163
+ if (['Json', 'Bytes'].includes(f.type))
164
+ continue;
165
+ if (isSensitiveFieldName(f.name))
166
+ continue;
167
+ if (hidden.has(f.name))
168
+ continue;
169
+ out.add(f.name);
170
+ }
171
+ return out;
172
+ };
173
+ /**
174
+ * Résout le label BRUT (non échappé) d'une ligne : premier champ String
175
+ * candidat présent, sinon template `{a} {b}` si configuré, sinon la PK.
176
+ * Déterministe. Svelte échappe automatiquement à l'interpolation dans les
177
+ * composants — pas besoin d'échapper ici.
178
+ */
179
+ const resolveLabel = (targetModel, row, labelTemplate) => {
180
+ if (labelTemplate) {
181
+ return labelTemplate.replace(/\{(\w+)\}/g, (_, k) => String(row[k] ?? ''));
182
+ }
183
+ for (const candidate of labelFieldCandidates) {
184
+ const field = targetModel.fields.find((f) => f.name === candidate);
185
+ if (field && field.type === 'String' && row[candidate] != null) {
186
+ return String(row[candidate]);
187
+ }
188
+ }
189
+ return String(row[primaryKeyOf(targetModel)]);
190
+ };
191
+ return {
192
+ adapter,
193
+ schema,
194
+ relationGraph,
195
+ models,
196
+ modelList,
197
+ config,
198
+ basePath,
199
+ perPage: 20,
200
+ selectThreshold,
201
+ filterLinkThreshold,
202
+ labelFieldCandidates,
203
+ findModel,
204
+ labelOf,
205
+ hiddenFieldsOf,
206
+ viewModel,
207
+ resolveLabel,
208
+ resolveFilterableFields
209
+ };
210
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `_search` JSON endpoint — split out of `handler.ts`, pure orchestration
3
+ * over `AdminRuntime`.
4
+ */
5
+ import { type AdminRuntime } from './runtime.js';
6
+ /**
7
+ * Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
8
+ * Sert les options d'une relation to-one-owning ou m2m en JSON
9
+ * paginé — la voie prévue pour un futur widget autocomplete côté client
10
+ * quand le nombre d'options dépasse `selectThreshold`. Respecte le `where`
11
+ * de scoping configuré sur la relation, comme le select et la validation
12
+ * POST : même garantie anti-IDOR sur les trois chemins.
13
+ */
14
+ export declare function handleSearch(runtime: AdminRuntime, event: any): Promise<Response>;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `_search` JSON endpoint — split out of `handler.ts`, pure orchestration
3
+ * over `AdminRuntime`.
4
+ */
5
+ import { primaryKeyOf, paginate } from './data.js';
6
+ import { scopeFrom, modelScopeFrom } from './runtime.js';
7
+ /**
8
+ * Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
9
+ * Sert les options d'une relation to-one-owning ou m2m en JSON
10
+ * paginé — la voie prévue pour un futur widget autocomplete côté client
11
+ * quand le nombre d'options dépasse `selectThreshold`. Respecte le `where`
12
+ * de scoping configuré sur la relation, comme le select et la validation
13
+ * POST : même garantie anti-IDOR sur les trois chemins.
14
+ */
15
+ export async function handleSearch(runtime, event) {
16
+ const modelsConfig = runtime.config.models ?? {};
17
+ const relParam = event.url.searchParams.get('rel') ?? '';
18
+ const [modelName, fieldName] = relParam.split('.');
19
+ const q = event.url.searchParams.get('q') ?? '';
20
+ const { page } = paginate(event.url.searchParams.get('page'), runtime.perPage);
21
+ const model = runtime.findModel(modelName);
22
+ const edge = model && runtime.relationGraph
23
+ ? runtime.relationGraph.edges.get(`${model.name}.${fieldName}`)
24
+ : undefined;
25
+ if (!model || !edge || (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m') || edge.unsupported) {
26
+ return new Response(JSON.stringify({ error: 'unknown relation' }), {
27
+ status: 404,
28
+ headers: { 'Content-Type': 'application/json' }
29
+ });
30
+ }
31
+ const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
32
+ const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
33
+ const configWhere = scopeFrom(relConfig, { locals: event.locals });
34
+ const modelWhere = modelScopeFrom(runtime, targetModel, { locals: event.locals });
35
+ // Recherche sur le premier champ String candidat du modèle cible — le
36
+ // même champ que celui utilisé pour construire le label par défaut.
37
+ const searchField = runtime.labelFieldCandidates.find((c) => targetModel.fields.some((f) => f.name === c && f.type === 'String'));
38
+ // `_search` must stay case-sensitive on every adapter/provider. A
39
+ // `{ op: 'contains' }` leaf would pick up the adapter-wide
40
+ // `caseInsensitiveSearch` flag (Prisma `mode: 'insensitive'`, Drizzle
41
+ // `ilike`). `containsExact` compiles to `{ contains }` / `LIKE` with
42
+ // no case-folding — same observable Prisma behavior as the previous
43
+ // opaque `{ [field]: { contains: q } }` pass-through.
44
+ const containsFilter = q && searchField
45
+ ? { op: 'containsExact', field: searchField, value: q }
46
+ : undefined;
47
+ const searchClauses = [modelWhere, configWhere, containsFilter].filter(Boolean);
48
+ const searchFilter = searchClauses.length > 1
49
+ ? { op: 'and', clauses: searchClauses }
50
+ : searchClauses[0];
51
+ try {
52
+ // Count + fetch are independent reads — run them in parallel (as this
53
+ // endpoint always has, pre-refactor) rather than doubling latency with
54
+ // two sequential awaits.
55
+ const [total, rows] = await Promise.all([
56
+ runtime.adapter.data.countRecords(targetModel, searchFilter),
57
+ runtime.adapter.data.findMany(targetModel, {
58
+ filter: searchFilter,
59
+ orderBy: relConfig?.orderBy,
60
+ skip: (page - 1) * runtime.perPage,
61
+ take: runtime.perPage
62
+ })
63
+ ]);
64
+ const options = rows.map((row) => ({
65
+ id: row[primaryKeyOf(targetModel)],
66
+ label: runtime.resolveLabel(targetModel, row, relConfig?.labelTemplate)
67
+ }));
68
+ return new Response(JSON.stringify({ options, total, page }), {
69
+ headers: { 'Content-Type': 'application/json' }
70
+ });
71
+ }
72
+ catch {
73
+ return new Response(JSON.stringify({ error: 'search failed' }), {
74
+ status: 500,
75
+ headers: { 'Content-Type': 'application/json' }
76
+ });
77
+ }
78
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Generic schema shapes shared by every schema-source adapter (Prisma today,
3
+ * others later). Deliberately identical in shape to what `introspection/
4
+ * parser.ts` has always produced — this file is a rename of that shape, not
5
+ * a redesign of it. `PrismaSchema`/`PrismaModel`/`PrismaField` in parser.ts
6
+ * become aliases of these.
7
+ */
8
+ export interface Field {
9
+ name: string;
10
+ type: string;
11
+ isRequired: boolean;
12
+ isList: boolean;
13
+ isUnique: boolean;
14
+ isId: boolean;
15
+ isUpdatedAt: boolean;
16
+ isCreatedAt: boolean;
17
+ hasDefault: boolean;
18
+ defaultValue?: string;
19
+ /** true si `type` correspond à un `enum` déclaré dans le même schéma. */
20
+ isEnum?: boolean;
21
+ relation?: {
22
+ name?: string;
23
+ model: string;
24
+ fields?: string[];
25
+ references?: string[];
26
+ };
27
+ documentation?: string;
28
+ }
29
+ export interface Model {
30
+ name: string;
31
+ fields: Field[];
32
+ documentation?: string;
33
+ primaryKey?: string;
34
+ isPivotTable?: boolean;
35
+ }
36
+ export interface Schema {
37
+ models: Model[];
38
+ enums: Map<string, string[]>;
39
+ provider?: string;
40
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Generic schema shapes shared by every schema-source adapter (Prisma today,
3
+ * others later). Deliberately identical in shape to what `introspection/
4
+ * parser.ts` has always produced — this file is a rename of that shape, not
5
+ * a redesign of it. `PrismaSchema`/`PrismaModel`/`PrismaField` in parser.ts
6
+ * become aliases of these.
7
+ */
8
+ export {};
@@ -1,23 +1,26 @@
1
1
  <script lang="ts">
2
2
  import type { AdminHandlerConfig } from '../handler.js';
3
- import type { ViewModel } from './types.js';
3
+ import type { RecordAction, ViewModel } from './types.js';
4
4
  import FieldInput from './FieldInput.svelte';
5
5
  import RelationSelect from './RelationSelect.svelte';
6
6
  import RelationCheckboxes from './RelationCheckboxes.svelte';
7
7
  import RelatedBlock from './RelatedBlock.svelte';
8
+ import { escapeHtml } from './html.js';
8
9
 
9
10
  let {
10
11
  mode,
11
12
  model,
12
13
  basePath,
13
14
  config,
14
- item
15
+ item,
16
+ recordActions = []
15
17
  }: {
16
18
  mode: 'create' | 'edit';
17
19
  model: ViewModel;
18
20
  basePath: string;
19
21
  config: AdminHandlerConfig;
20
22
  item?: any;
23
+ recordActions?: RecordAction[];
21
24
  } = $props();
22
25
 
23
26
  const modelConfig = $derived(config.models?.[model.name] || {});
@@ -69,7 +72,7 @@
69
72
  ? [...model.relationGraph.edges.values()].filter(
70
73
  (e) =>
71
74
  e.model === model.name &&
72
- e.kind === 'm2m-implicit' &&
75
+ e.kind === 'm2m' &&
73
76
  !e.unsupported &&
74
77
  !hidden.includes(e.field) &&
75
78
  model.relationOptions?.has(`${e.model}.${e.field}`)
@@ -77,6 +80,24 @@
77
80
  : []
78
81
  );
79
82
 
83
+ // Built as a single string (rather than {#if}/{#each}) so an empty/create-mode
84
+ // render stays a single @html call: Svelte 5's SSR wraps every {#if}/{#each} node
85
+ // in its own hydration-boundary comment regardless of the branch/array taken, so
86
+ // nesting recordActions in its own control-flow blocks would add bytes to every
87
+ // edit-form render even when recordActions is []. `label` and `href` are both
88
+ // escaped manually since this goes through @html instead of Svelte's
89
+ // auto-escaped text/attributes.
90
+ const recordActionsHtml = $derived(
91
+ mode === 'edit' && recordActions.length > 0
92
+ ? `<div class="ska-record-actions">${recordActions
93
+ .map(
94
+ (action) =>
95
+ `<a href="${escapeHtml(action.href)}" class="ska-btn ska-btn--secondary ska-btn--sm">${escapeHtml(action.label)}</a>`
96
+ )
97
+ .join('')}</div>`
98
+ : ''
99
+ );
100
+
80
101
  const inverseEdges = $derived(
81
102
  model.relationGraph
82
103
  ? [...model.relationGraph.edges.values()].filter(
@@ -92,6 +113,9 @@
92
113
  <p class="ska-subtitle">ID: {item[model.primaryKey]}</p>
93
114
  {/if}
94
115
 
116
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- recordActionsHtml escapes both action.label and action.href via escapeHtml -->
117
+ {@html recordActionsHtml}
118
+
95
119
  <div class="ska-card">
96
120
  <form method="POST" class="ska-form">
97
121
  <input type="hidden" name="_action" value={mode === 'create' ? 'create' : 'update'} />
@@ -1,11 +1,12 @@
1
1
  import type { AdminHandlerConfig } from '../handler.js';
2
- import type { ViewModel } from './types.js';
2
+ import type { RecordAction, ViewModel } from './types.js';
3
3
  type $$ComponentProps = {
4
4
  mode: 'create' | 'edit';
5
5
  model: ViewModel;
6
6
  basePath: string;
7
7
  config: AdminHandlerConfig;
8
8
  item?: any;
9
+ recordActions?: RecordAction[];
9
10
  };
10
11
  declare const Form: import("svelte").Component<$$ComponentProps, {}, "">;
11
12
  type Form = ReturnType<typeof Form>;
@@ -6,12 +6,16 @@
6
6
  content,
7
7
  config,
8
8
  modelList,
9
- currentModel
9
+ currentModel,
10
+ extraStyles = '',
11
+ extraScripts = ''
10
12
  }: {
11
13
  content: string;
12
14
  config: AdminHandlerConfig;
13
15
  modelList: Array<{ name: string; label: string }>;
14
16
  currentModel?: string;
17
+ extraStyles?: string;
18
+ extraScripts?: string;
15
19
  } = $props();
16
20
 
17
21
  const branding = $derived(config.branding ?? {});
@@ -21,6 +25,25 @@
21
25
  // No button at all if `logout` isn't configured — an admin that never
22
26
  // opted into this option looks exactly as it did before it existed.
23
27
  const showLogout = $derived(Boolean(config.logout));
28
+
29
+ // extraStyles is concatenated into the SAME @html expression as the theme <style>
30
+ // below, rather than a sibling {#if}/{@html} block: Svelte 5's SSR unconditionally
31
+ // wraps every {#if}/{#each}/{@html} node in its own hydration-boundary comment, even
32
+ // for a false/empty branch (see svelte/internal/server's `html()` helper) — a sibling
33
+ // block would add bytes to every render regardless of extraStyles being set.
34
+ // Concatenating keeps this ONE @html call, byte-identical to the pre-plugin-slots
35
+ // template when extraStyles is ''. extraScripts (bottom of <body>) has no such
36
+ // pre-existing @html call to fold into, so it stays its own @html — the smallest
37
+ // achievable footprint, though it still adds a fixed hydration-boundary comment
38
+ // pair even when empty (see task-6-report.md fix-round-1 notes).
39
+ //
40
+ // Built as a $derived here (rather than a nested template literal inline in the
41
+ // markup below) so tooling that tag-sniffs {@html} expressions for literal
42
+ // <style>/<script> text doesn't misparse the nested backticks.
43
+ const headStyleHtml = $derived(
44
+ `<style>${styles(primaryColor)}</style>${extraStyles ? `<style>${extraStyles}</style>` : ''}`
45
+ );
46
+ const bodyScriptHtml = $derived(extraScripts ? '<script>' + extraScripts + '</scr' + 'ipt>' : '');
24
47
  </script>
25
48
 
26
49
  <!doctype html>
@@ -31,7 +54,7 @@
31
54
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
32
55
  <title>{title}</title>
33
56
  <!-- eslint-disable-next-line svelte/no-at-html-tags -- CSS injected as raw text; a literal <style> block can't take a dynamic value; primaryColor is developer-supplied config, not request/database data, and this raw interpolation is unchanged from the original layout.ts implementation, not a new injection point introduced by this migration -->
34
- {@html `<style>${styles(primaryColor)}</style>`}
57
+ {@html headStyleHtml}
35
58
  </head>
36
59
  <!-- eslint-disable-next-line svelte/no-raw-special-elements -- server-only full-document template, never mounted client-side -->
37
60
  <body>
@@ -74,5 +97,7 @@
74
97
  {@html content}
75
98
  </main>
76
99
  </div>
100
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- plugin JS is developer-supplied, same trust as branding.primaryColor -->
101
+ {@html bodyScriptHtml}
77
102
  </body>
78
103
  </html>
@@ -7,6 +7,8 @@ type $$ComponentProps = {
7
7
  label: string;
8
8
  }>;
9
9
  currentModel?: string;
10
+ extraStyles?: string;
11
+ extraScripts?: string;
10
12
  };
11
13
  declare const Layout: import("svelte").Component<$$ComponentProps, {}, "">;
12
14
  type Layout = ReturnType<typeof Layout>;
@@ -1,10 +1,9 @@
1
1
  <script lang="ts">
2
2
  import type { AdminHandlerConfig } from '../handler.js';
3
- import type { ViewModel } from './types.js';
3
+ import type { ViewModel, ListRecordAction, FkFilterMeta } from './types.js';
4
4
  import type { ListQuery } from '../query/listQuery.js';
5
5
  import type { ResolvedFilterField } from '../query/filterDetection.js';
6
6
  import { DATETIME_PRESETS } from '../query/filterDetection.js';
7
- import type { FkFilterMeta } from './types.js';
8
7
  import { getDisplayFields } from '../introspection/parser.js';
9
8
  import { buildListUrl, hiddenParams } from '../query/urls.js';
10
9
  import { escapeHtml, toLabel, formatValue } from './html.js';
@@ -19,7 +18,8 @@
19
18
  query,
20
19
  currentUrl,
21
20
  listFilters,
22
- fkFilterMeta
21
+ fkFilterMeta,
22
+ recordActions = []
23
23
  }: {
24
24
  model: ViewModel;
25
25
  items: any[];
@@ -34,6 +34,7 @@
34
34
  listFilters?: ResolvedFilterField[];
35
35
  /** Métadonnées async (options scopées + label actif) pour les filtres FK configurés. */
36
36
  fkFilterMeta?: Map<string, FkFilterMeta>;
37
+ recordActions?: ListRecordAction[];
37
38
  } = $props();
38
39
 
39
40
  const modelConfig = $derived(config.models?.[model.name] || {});
@@ -126,6 +127,28 @@
126
127
  * pour un champ sensible que pour un champ inconnu (§0.a, §5.4) — ne
127
128
  * jamais dire "champ interdit", ça confirmerait son existence.
128
129
  */
130
+ // A per-row function (rather than {#each} in the markup) so an empty recordActions
131
+ // keeps this to the smallest possible footprint: Svelte 5's SSR wraps every
132
+ // {#each}/{@html} node in its own hydration-boundary comment regardless of the
133
+ // array's length/content (verified empirically — even {@html ''} still emits
134
+ // `<!--hash--><!---->`), so there is no template-level construct that renders zero
135
+ // bytes for an empty array here. Folding this into the pre-existing delete-form
136
+ // {@html} call (right below) was considered and rejected: recordActions must render
137
+ // *before* Edit (see list.test.ts "rend le lien avant Edit"), but the delete form's
138
+ // pre-existing {@html} — and thus its hydration marker — sits *after* Edit, so
139
+ // reusing it would either reorder Edit/recordActions or move the marker in front of
140
+ // Edit for every row, not just when recordActions is non-empty. Neither is
141
+ // byte-identical to the pre-recordActions baseline (see task-6-report.md fix-round-1
142
+ // notes). `action.label` and `hrefFor`'s return value are both escaped manually
143
+ // since this goes through @html instead of Svelte's auto-escaped text/attributes.
144
+ const recordActionsHtml = (id: string | number) =>
145
+ recordActions
146
+ .map(
147
+ (action) =>
148
+ `<a href="${escapeHtml(action.hrefFor(id))}" class="ska-btn ska-btn--secondary ska-btn--sm">${escapeHtml(action.label)}</a>`
149
+ )
150
+ .join('');
151
+
129
152
  const ignoredMessages = $derived.by(() => {
130
153
  return (query?.ignored ?? []).map((entry) => {
131
154
  // `param` est soit `f.<field>` / `f.<field>__<op>` (nouveau format),
@@ -217,6 +240,8 @@
217
240
  <!-- eslint-disable-next-line svelte/no-at-html-tags -- formatValue already escapes string values itself and returns a literal <span> only for null/undefined -->
218
241
  {#each displayFields as f (f.name)}<td>{@html formatValue(item[f.name], f.type)}</td>{/each}
219
242
  <td class="ska-table__actions">
243
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- recordActionsHtml escapes both action.label and hrefFor's return value via escapeHtml -->
244
+ {@html recordActionsHtml(item[model.primaryKey])}
220
245
  <a href="{listPath}/{item[model.primaryKey]}" class="ska-btn ska-btn--secondary ska-btn--sm">Edit</a>
221
246
  <!-- eslint-disable-next-line svelte/no-at-html-tags -- Svelte 5 rejects a literal onsubmit string as an event attribute; the PK is escaped manually here since it can't go through Svelte's native attribute escaping; the whole form (not just onsubmit) is rendered as raw HTML because there's no native-Svelte way to attach a plain inline onsubmit="..." string attribute at all in Svelte 5 templates, so the whole element had to be raw text to preserve the exact prior confirm-dialog behavior in a page that's never hydrated by a Svelte runtime -->
222
247
  {@html `<form method="POST" action="${listPath}/${escapeHtml(String(item[model.primaryKey]))}" style="display:inline" onsubmit="return confirm('Delete this item?')"><input type="hidden" name="_action" value="delete"><button type="submit" class="ska-btn ska-btn--danger ska-btn--sm">Delete</button></form>`}
@@ -1,8 +1,7 @@
1
1
  import type { AdminHandlerConfig } from '../handler.js';
2
- import type { ViewModel } from './types.js';
2
+ import type { ViewModel, ListRecordAction, FkFilterMeta } from './types.js';
3
3
  import type { ListQuery } from '../query/listQuery.js';
4
4
  import type { ResolvedFilterField } from '../query/filterDetection.js';
5
- import type { FkFilterMeta } from './types.js';
6
5
  type $$ComponentProps = {
7
6
  model: ViewModel;
8
7
  items: any[];
@@ -21,6 +20,7 @@ type $$ComponentProps = {
21
20
  listFilters?: ResolvedFilterField[];
22
21
  /** Métadonnées async (options scopées + label actif) pour les filtres FK configurés. */
23
22
  fkFilterMeta?: Map<string, FkFilterMeta>;
23
+ recordActions?: ListRecordAction[];
24
24
  };
25
25
  declare const List: import("svelte").Component<$$ComponentProps, {}, "">;
26
26
  type List = ReturnType<typeof List>;
@@ -17,7 +17,7 @@
17
17
  </script>
18
18
 
19
19
  <!--
20
- Fieldset de checkboxes pour une arête m2m-implicite.
20
+ Fieldset de checkboxes pour une arête m2m.
21
21
 
22
22
  Nommage `__rel__<field>` pour les valeurs cochées et un hidden sentinelle
23
23
  `__rel_present__<field>` toujours émis : en HTML, zéro checkbox cochée
@@ -22,6 +22,14 @@ export interface ViewModel {
22
22
  /** Compteurs des relations inverses (1-N, 1-1), indexés par "Model.field" */
23
23
  relatedCounts?: Map<string, number>;
24
24
  }
25
+ export interface RecordAction {
26
+ label: string;
27
+ href: string;
28
+ }
29
+ export interface ListRecordAction {
30
+ label: string;
31
+ hrefFor: (id: string | number) => string;
32
+ }
25
33
  /**
26
34
  * Résolution async d'un filtre FK (kind 'fk' dans ResolvedFilterField) :
27
35
  * options scopées pour la sidebar + label du chip actif scopé lui aussi