sveltekit-admin 0.6.0 → 0.9.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.
Files changed (69) hide show
  1. package/README.md +61 -4
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.js +1 -1
  4. package/dist/server/adapters/drizzle/dataAdapter.js +196 -42
  5. package/dist/server/adapters/drizzle/index.d.ts +10 -1
  6. package/dist/server/adapters/drizzle/index.js +4 -0
  7. package/dist/server/adapters/prisma/dataAdapter.js +71 -11
  8. package/dist/server/adapters/prisma/handler.d.ts +14 -0
  9. package/dist/server/adapters/prisma/handler.js +37 -0
  10. package/dist/server/adapters/retry.d.ts +27 -0
  11. package/dist/server/adapters/retry.js +53 -0
  12. package/dist/server/adapters/types.d.ts +42 -3
  13. package/dist/server/audit.d.ts +65 -0
  14. package/dist/server/audit.js +106 -0
  15. package/dist/server/csrf.d.ts +33 -0
  16. package/dist/server/csrf.js +55 -0
  17. package/dist/server/data.d.ts +18 -2
  18. package/dist/server/data.js +39 -8
  19. package/dist/server/errors.d.ts +47 -0
  20. package/dist/server/errors.js +90 -0
  21. package/dist/server/handler.d.ts +92 -26
  22. package/dist/server/handler.js +263 -560
  23. package/dist/server/introspection/parser.d.ts +15 -0
  24. package/dist/server/introspection/parser.js +17 -0
  25. package/dist/server/mutations.d.ts +13 -0
  26. package/dist/server/mutations.js +476 -0
  27. package/dist/server/plugin.d.ts +47 -0
  28. package/dist/server/plugin.js +1 -0
  29. package/dist/server/pluginAccess.d.ts +7 -0
  30. package/dist/server/pluginAccess.js +79 -0
  31. package/dist/server/pluginRegistry.d.ts +12 -0
  32. package/dist/server/pluginRegistry.js +72 -0
  33. package/dist/server/query/listColumns.d.ts +19 -0
  34. package/dist/server/query/listColumns.js +43 -0
  35. package/dist/server/query/listQuery.d.ts +1 -1
  36. package/dist/server/query/pageSize.d.ts +19 -0
  37. package/dist/server/query/pageSize.js +26 -0
  38. package/dist/server/query/sortQuery.d.ts +31 -0
  39. package/dist/server/query/sortQuery.js +33 -0
  40. package/dist/server/query/urls.js +9 -1
  41. package/dist/server/relationLoaders.d.ts +42 -0
  42. package/dist/server/relationLoaders.js +188 -0
  43. package/dist/server/router.d.ts +10 -0
  44. package/dist/server/router.js +42 -19
  45. package/dist/server/runtime.d.ts +51 -0
  46. package/dist/server/runtime.js +263 -0
  47. package/dist/server/search.d.ts +14 -0
  48. package/dist/server/search.js +78 -0
  49. package/dist/server/submitted.d.ts +20 -0
  50. package/dist/server/submitted.js +54 -0
  51. package/dist/server/views/FieldInput.svelte +114 -8
  52. package/dist/server/views/FieldInput.svelte.d.ts +7 -0
  53. package/dist/server/views/Form.svelte +96 -5
  54. package/dist/server/views/Form.svelte.d.ts +19 -1
  55. package/dist/server/views/Layout.svelte +32 -4
  56. package/dist/server/views/Layout.svelte.d.ts +2 -0
  57. package/dist/server/views/List.svelte +156 -26
  58. package/dist/server/views/List.svelte.d.ts +7 -2
  59. package/dist/server/views/RelationCheckboxes.svelte +26 -5
  60. package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
  61. package/dist/server/views/RelationSelect.svelte +14 -3
  62. package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
  63. package/dist/server/views/html.d.ts +19 -0
  64. package/dist/server/views/html.js +25 -0
  65. package/dist/server/views/pagination.d.ts +9 -0
  66. package/dist/server/views/pagination.js +31 -0
  67. package/dist/server/views/theme.js +147 -3
  68. package/dist/server/views/types.d.ts +15 -0
  69. package/package.json +24 -21
@@ -1,11 +1,45 @@
1
1
  <script lang="ts">
2
2
  import type { PrismaField } from '../introspection/parser.js';
3
- import { toLabel } from './html.js';
3
+ import type { SubmittedForm } from '../submitted.js';
4
+ import { toLabel, fieldErrorAttrs } from './html.js';
4
5
 
5
- let { field, value, isReadonly }: { field: PrismaField; value: any; isReadonly: boolean } =
6
- $props();
6
+ let {
7
+ field,
8
+ value,
9
+ isReadonly,
10
+ submitted,
11
+ errorMessage,
12
+ enums = new Map<string, string[]>()
13
+ }: {
14
+ field: PrismaField;
15
+ value: any;
16
+ isReadonly: boolean;
17
+ /** Voir `Form.svelte` : défini seulement sur un re-rendu après échec. */
18
+ submitted?: SubmittedForm;
19
+ /** Défini seulement si c'est ce champ que l'erreur désigne. */
20
+ errorMessage?: string;
21
+ /** Valeurs de chaque enum du schéma, indexées par nom de type. */
22
+ enums?: Map<string, string[]>;
23
+ } = $props();
24
+
25
+ const err = $derived(fieldErrorAttrs(field.name, errorMessage));
7
26
 
8
27
  const label = $derived(toLabel(field.name));
28
+
29
+ /**
30
+ * `submitted` défini signifie « ce formulaire vient d'être posté et refusé ».
31
+ * On teste sa présence, pas celle de la clé : une checkbox décochée et un
32
+ * champ sensible non ré-émis n'envoient rien, et retomber sur la valeur en
33
+ * base rallumerait à l'écran un booléen que l'utilisateur vient d'éteindre.
34
+ *
35
+ * Un champ readonly est exclu : la ligne en base fait foi puisqu'il n'est pas
36
+ * éditable. Un navigateur soumet bien un `readonly` (contrairement à un
37
+ * `disabled`), mais rien ne l'y oblige — un POST forgé ou un client partiel
38
+ * viderait alors l'ID et les horodatages à l'écran, sans que ce soit une
39
+ * donnée que l'utilisateur ait saisie.
40
+ */
41
+ const hasSubmission = $derived(submitted !== undefined && !isReadonly);
42
+ const submittedValue = $derived(submitted?.values[field.name]);
9
43
  const required = $derived(field.isRequired && !field.hasDefault && !isReadonly);
10
44
 
11
45
  const lower = $derived(field.name.toLowerCase());
@@ -29,35 +63,107 @@
29
63
  });
30
64
 
31
65
  const inputValue = $derived.by(() => {
66
+ // Rendue telle qu'elle a été tapée : la re-normaliser (un `datetime-local`
67
+ // incomplet, un nombre en cours de frappe) transformerait la valeur refusée
68
+ // en une autre, et l'utilisateur ne reconnaîtrait plus ce qu'il a saisi.
69
+ if (hasSubmission) return submittedValue ?? '';
32
70
  if (field.type === 'DateTime' && value) {
33
71
  return new Date(value).toISOString().slice(0, 16);
34
72
  }
35
73
  return value ?? '';
36
74
  });
37
75
 
38
- const jsonValue = $derived(value ? JSON.stringify(value, null, 2) : '');
76
+ /**
77
+ * Valeur retenue par le `<select>` d'un enum : même arbitrage que
78
+ * `inputValue` (ce qui vient d'être soumis prime sur la ligne en base),
79
+ * ramené à une chaîne pour se comparer aux valeurs déclarées de l'enum.
80
+ */
81
+ const enumValue = $derived(String(inputValue));
82
+
83
+ const jsonValue = $derived.by(() => {
84
+ if (hasSubmission) return submittedValue ?? '';
85
+ return value ? JSON.stringify(value, null, 2) : '';
86
+ });
87
+
88
+ // Absence = décochée : c'est ce que le navigateur envoie, et `formDataToPrisma`
89
+ // l'interprète déjà comme `false` côté écriture.
90
+ const isChecked = $derived(
91
+ hasSubmission ? submittedValue !== undefined : !!value
92
+ );
39
93
  </script>
40
94
 
41
95
  {#if field.type === 'Boolean'}
42
96
  <div class="ska-field">
43
97
  <label class="ska-checkbox-wrap">
44
- <input type="checkbox" name={field.name} class="ska-checkbox" checked={!!value} disabled={isReadonly} />
98
+ <input
99
+ type="checkbox"
100
+ name={field.name}
101
+ class="ska-checkbox"
102
+ checked={isChecked}
103
+ disabled={isReadonly}
104
+ aria-invalid={err.ariaInvalid}
105
+ aria-describedby={err.describedBy}
106
+ />
45
107
  <span class="ska-label">{label}</span>
46
108
  </label>
109
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
110
+ {@html err.html}
47
111
  </div>
48
112
  {:else if field.type === 'Json'}
49
113
  <div class="ska-field">
50
114
  <label class="ska-label" for={field.name}>{label}{required ? ' *' : ''}</label>
51
- <textarea id={field.name} name={field.name} class="ska-input" rows="4" readonly={isReadonly} required={required}>{jsonValue}</textarea>
115
+ <textarea id={field.name} name={field.name} class="ska-input" rows="4" readonly={isReadonly} required={required} aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy}>{jsonValue}</textarea>
116
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
117
+ {@html err.html}
52
118
  </div>
53
119
  {:else if isLongText}
54
120
  <div class="ska-field">
55
121
  <label class="ska-label" for={field.name}>{label}{required ? ' *' : ''}</label>
56
- <textarea id={field.name} name={field.name} class="ska-input" rows="4" readonly={isReadonly} required={required}>{inputValue}</textarea>
122
+ <textarea id={field.name} name={field.name} class="ska-input" rows="4" readonly={isReadonly} required={required} aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy}>{inputValue}</textarea>
123
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
124
+ {@html err.html}
125
+ </div>
126
+ {:else if field.isEnum && enums.has(field.type)}
127
+ <!--
128
+ Calqué sur `RelationSelect.svelte` (même structure, même « — aucun — »), à
129
+ deux écarts près, tous deux volontaires :
130
+
131
+ 1. Un placeholder désactivé quand la colonne est non nullable et n'a pas
132
+ encore de valeur. Sans lui le navigateur présélectionne la première
133
+ valeur de l'enum, et une création écrit un choix que l'utilisateur n'a
134
+ jamais fait. `RelationSelect` a le même défaut pour une FK obligatoire —
135
+ il n'est pas corrigé ici, ce serait un autre comportement dans une autre
136
+ PR.
137
+ 2. `disabled` et non `readonly` : un `<select>` n'a pas de `readonly`. Le
138
+ champ sort donc du POST, `formDataToPrisma` ignore la clé absente et la
139
+ colonne n'est pas réécrite — exactement ce que « lecture seule » veut
140
+ dire, et plus sûr que les branches texte qui, elles, réémettent la valeur.
141
+
142
+ L'option vide se décide sur `field.isRequired` (la colonne accepte-t-elle
143
+ NULL) et non sur `required` (l'attribut du widget, faux dès qu'il y a un
144
+ `@default`) : une colonne non nullable à défaut n'accepte toujours pas
145
+ « aucun ».
146
+ -->
147
+ <div class="ska-field">
148
+ <label class="ska-label" for={field.name}>{label}{required ? ' *' : ''}</label>
149
+ <select id={field.name} name={field.name} class="ska-input" disabled={isReadonly} required={required} aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy}>
150
+ {#if !field.isRequired}
151
+ <option value="">— aucun —</option>
152
+ {:else if !enumValue}
153
+ <option value="" disabled selected>Select…</option>
154
+ {/if}
155
+ {#each enums.get(field.type)! as v (v)}
156
+ <option value={v} selected={v === enumValue}>{v}</option>
157
+ {/each}
158
+ </select>
159
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
160
+ {@html err.html}
57
161
  </div>
58
162
  {:else}
59
163
  <div class="ska-field">
60
164
  <label class="ska-label" for={field.name}>{label}{required ? ' *' : ''}</label>
61
- <input id={field.name} type={inputType} name={field.name} value={inputValue} class="ska-input" readonly={isReadonly} required={required} />
165
+ <input id={field.name} type={inputType} name={field.name} value={inputValue} class="ska-input" readonly={isReadonly} required={required} aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy} />
166
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
167
+ {@html err.html}
62
168
  </div>
63
169
  {/if}
@@ -1,8 +1,15 @@
1
1
  import type { PrismaField } from '../introspection/parser.js';
2
+ import type { SubmittedForm } from '../submitted.js';
2
3
  type $$ComponentProps = {
3
4
  field: PrismaField;
4
5
  value: any;
5
6
  isReadonly: boolean;
7
+ /** Voir `Form.svelte` : défini seulement sur un re-rendu après échec. */
8
+ submitted?: SubmittedForm;
9
+ /** Défini seulement si c'est ce champ que l'erreur désigne. */
10
+ errorMessage?: string;
11
+ /** Valeurs de chaque enum du schéma, indexées par nom de type. */
12
+ enums?: Map<string, string[]>;
6
13
  };
7
14
  declare const FieldInput: import("svelte").Component<$$ComponentProps, {}, "">;
8
15
  type FieldInput = ReturnType<typeof FieldInput>;
@@ -1,35 +1,77 @@
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
+ import type { SubmittedForm } from '../submitted.js';
5
+ import { isSensitiveStringField } from '../introspection/parser.js';
4
6
  import FieldInput from './FieldInput.svelte';
5
7
  import RelationSelect from './RelationSelect.svelte';
6
8
  import RelationCheckboxes from './RelationCheckboxes.svelte';
7
9
  import RelatedBlock from './RelatedBlock.svelte';
10
+ import { escapeHtml } from './html.js';
8
11
 
9
12
  let {
10
13
  mode,
11
14
  model,
12
15
  basePath,
13
16
  config,
14
- item
17
+ item,
18
+ recordActions = [],
19
+ submitted,
20
+ mutationError
15
21
  }: {
16
22
  mode: 'create' | 'edit';
17
23
  model: ViewModel;
18
24
  basePath: string;
19
25
  config: AdminHandlerConfig;
20
26
  item?: any;
27
+ recordActions?: RecordAction[];
28
+ /**
29
+ * Ce que le POST venait de soumettre, quand la mutation a échoué. Défini
30
+ * uniquement sur un re-rendu d'erreur : `undefined` veut dire « affichage
31
+ * normal », et c'est cette distinction — pas le contenu — qui permet à un
32
+ * booléen décoché de rester décoché.
33
+ */
34
+ submitted?: SubmittedForm;
35
+ /**
36
+ * L'échec qui a provoqué ce re-rendu. `field` est absent quand la cause ne
37
+ * désigne pas un champ (conflit d'unicité, message générique) : le message
38
+ * ne vit alors que dans la bannière de page, posée par `handler.ts`.
39
+ */
40
+ mutationError?: { message: string; field?: string };
21
41
  } = $props();
22
42
 
43
+ /**
44
+ * Le message n'est rendu qu'au widget que l'erreur nomme. Un champ absent du
45
+ * formulaire (masqué, ou un scalaire sans widget propre) ne fait rien
46
+ * apparaître : la bannière reste le canal de repli.
47
+ */
48
+ const errorFor = (...names: Array<string | undefined>) =>
49
+ mutationError?.field !== undefined && names.includes(mutationError.field)
50
+ ? mutationError.message
51
+ : undefined;
52
+
23
53
  const modelConfig = $derived(config.models?.[model.name] || {});
24
54
  const hidden = $derived(modelConfig.hidden || []);
25
55
  const readonly = $derived(modelConfig.readonly || []);
26
56
  const listPath = $derived(`${basePath}/${model.name.toLowerCase()}`);
27
57
 
58
+ /**
59
+ * `Bytes` n'a pas de widget : rendue dans la branche texte générique, la
60
+ * colonne renvoyait au pilote une chaîne là où il attend un `Uint8Array`,
61
+ * donc aucune écriture n'a jamais pu aboutir. L'exclure rend un modèle à
62
+ * colonne `Bytes` obligatoire incréable depuis l'admin — c'est déjà le cas
63
+ * en pratique, à ceci près que l'échec arrive maintenant avant le formulaire
64
+ * plutôt qu'après le POST. La liste l'écarte déjà pareillement
65
+ * (`List.svelte`), c'est la même décision au même endroit du schéma.
66
+ */
67
+ const isEditableType = (f: { type: string }) => f.type !== 'Bytes';
68
+
28
69
  const formFields = $derived(
29
70
  mode === 'create'
30
71
  ? model.fields.filter(
31
72
  (f) =>
32
73
  !hidden.includes(f.name) &&
74
+ isEditableType(f) &&
33
75
  !f.isId &&
34
76
  !f.isCreatedAt &&
35
77
  !f.isUpdatedAt &&
@@ -41,7 +83,15 @@
41
83
  : model.fields.filter(
42
84
  (f) =>
43
85
  !hidden.includes(f.name) &&
86
+ isEditableType(f) &&
44
87
  !f.relation &&
88
+ // Exclu à l'édition seulement : il existe ici une valeur stockée,
89
+ // donc quelque chose à exposer dans le HTML et à écraser au
90
+ // prochain enregistrement. Le formulaire de création le garde — il
91
+ // n'y a rien à fuiter avant que la ligne existe, et le retirer
92
+ // rendrait un modèle à colonne obligatoire incréable depuis
93
+ // l'admin. `mutations.ts` refuse le vide de ce côté-là.
94
+ !isSensitiveStringField(f) &&
45
95
  !model.relationGraph?.scalarToRelation.has(f.name)
46
96
  )
47
97
  );
@@ -62,7 +112,14 @@
62
112
  : []
63
113
  );
64
114
 
65
- const currentValueOf = (scalarName: string) => (item ? item[scalarName] : null);
115
+ /**
116
+ * Sur un re-rendu d'erreur, le scalaire soumis fait foi ; sinon la ligne en
117
+ * base. Le `??` ne suffirait pas : une relation optionnelle qu'on vient de
118
+ * vider arrive comme `''`, ce qui doit rester `''` et non retomber sur
119
+ * l'ancienne cible.
120
+ */
121
+ const currentValueOf = (scalarName: string) =>
122
+ submitted ? (submitted.values[scalarName] ?? '') : item ? item[scalarName] : null;
66
123
 
67
124
  const relationCheckboxGroups = $derived(
68
125
  model.relationGraph
@@ -77,6 +134,24 @@
77
134
  : []
78
135
  );
79
136
 
137
+ // Built as a single string (rather than {#if}/{#each}) so an empty/create-mode
138
+ // render stays a single @html call: Svelte 5's SSR wraps every {#if}/{#each} node
139
+ // in its own hydration-boundary comment regardless of the branch/array taken, so
140
+ // nesting recordActions in its own control-flow blocks would add bytes to every
141
+ // edit-form render even when recordActions is []. `label` and `href` are both
142
+ // escaped manually since this goes through @html instead of Svelte's
143
+ // auto-escaped text/attributes.
144
+ const recordActionsHtml = $derived(
145
+ mode === 'edit' && recordActions.length > 0
146
+ ? `<div class="ska-record-actions">${recordActions
147
+ .map(
148
+ (action) =>
149
+ `<a href="${escapeHtml(action.href)}" class="ska-btn ska-btn--secondary ska-btn--sm">${escapeHtml(action.label)}</a>`
150
+ )
151
+ .join('')}</div>`
152
+ : ''
153
+ );
154
+
80
155
  const inverseEdges = $derived(
81
156
  model.relationGraph
82
157
  ? [...model.relationGraph.edges.values()].filter(
@@ -92,11 +167,21 @@
92
167
  <p class="ska-subtitle">ID: {item[model.primaryKey]}</p>
93
168
  {/if}
94
169
 
170
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- recordActionsHtml escapes both action.label and action.href via escapeHtml -->
171
+ {@html recordActionsHtml}
172
+
95
173
  <div class="ska-card">
96
174
  <form method="POST" class="ska-form">
97
175
  <input type="hidden" name="_action" value={mode === 'create' ? 'create' : 'update'} />
98
176
  {#each formFields as f (f.name)}
99
- <FieldInput field={f} value={item ? item[f.name] : null} isReadonly={isFieldReadonly(f)} />
177
+ <FieldInput
178
+ field={f}
179
+ value={item ? item[f.name] : null}
180
+ isReadonly={isFieldReadonly(f)}
181
+ {submitted}
182
+ errorMessage={errorFor(f.name)}
183
+ enums={model.enums}
184
+ />
100
185
  {/each}
101
186
  {#each relationSelects as edge (edge.field)}
102
187
  <RelationSelect
@@ -104,10 +189,16 @@
104
189
  meta={model.relationOptions!.get(`${edge.model}.${edge.field}`)!}
105
190
  currentValue={currentValueOf(edge.scalarFields[0])}
106
191
  {config}
192
+ errorMessage={errorFor(edge.field, edge.scalarFields[0])}
107
193
  />
108
194
  {/each}
109
195
  {#each relationCheckboxGroups as edge (edge.field)}
110
- <RelationCheckboxes {edge} meta={model.relationOptions!.get(`${edge.model}.${edge.field}`)!} />
196
+ <RelationCheckboxes
197
+ {edge}
198
+ meta={model.relationOptions!.get(`${edge.model}.${edge.field}`)!}
199
+ submittedIds={submitted?.m2m[edge.field]}
200
+ errorMessage={errorFor(edge.field)}
201
+ />
111
202
  {/each}
112
203
  <div class="ska-form__actions">
113
204
  <button type="submit" class="ska-btn ska-btn--primary">{mode === 'create' ? 'Create' : 'Save Changes'}</button>
@@ -1,11 +1,29 @@
1
1
  import type { AdminHandlerConfig } from '../handler.js';
2
- import type { ViewModel } from './types.js';
2
+ import type { RecordAction, ViewModel } from './types.js';
3
+ import type { SubmittedForm } from '../submitted.js';
3
4
  type $$ComponentProps = {
4
5
  mode: 'create' | 'edit';
5
6
  model: ViewModel;
6
7
  basePath: string;
7
8
  config: AdminHandlerConfig;
8
9
  item?: any;
10
+ recordActions?: RecordAction[];
11
+ /**
12
+ * Ce que le POST venait de soumettre, quand la mutation a échoué. Défini
13
+ * uniquement sur un re-rendu d'erreur : `undefined` veut dire « affichage
14
+ * normal », et c'est cette distinction — pas le contenu — qui permet à un
15
+ * booléen décoché de rester décoché.
16
+ */
17
+ submitted?: SubmittedForm;
18
+ /**
19
+ * L'échec qui a provoqué ce re-rendu. `field` est absent quand la cause ne
20
+ * désigne pas un champ (conflit d'unicité, message générique) : le message
21
+ * ne vit alors que dans la bannière de page, posée par `handler.ts`.
22
+ */
23
+ mutationError?: {
24
+ message: string;
25
+ field?: string;
26
+ };
9
27
  };
10
28
  declare const Form: import("svelte").Component<$$ComponentProps, {}, "">;
11
29
  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,14 +54,17 @@
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>
61
+ <!-- Premier élément focusable de la page : sans lui, atteindre le contenu au
62
+ clavier demande de retraverser toute la nav latérale à chaque page. -->
63
+ <a href="#ska-content" class="ska-skip">Skip to content</a>
38
64
  <div class="ska-layout">
39
65
  <aside class="ska-sidebar">
40
66
  <a href={basePath} class="ska-logo">{title}</a>
41
- <nav>
67
+ <nav aria-label="Main">
42
68
  <ul class="ska-nav">
43
69
  <li class="ska-nav__item">
44
70
  <a href={basePath} class="ska-nav__link" class:ska-nav__link--active={!currentModel}>
@@ -69,10 +95,12 @@
69
95
  </form>
70
96
  {/if}
71
97
  </aside>
72
- <main class="ska-main">
98
+ <main class="ska-main" id="ska-content" tabindex="-1">
73
99
  <!-- eslint-disable-next-line svelte/no-at-html-tags -- content is pre-rendered HTML from sibling view components / the handler's own escaped error string -->
74
100
  {@html content}
75
101
  </main>
76
102
  </div>
103
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- plugin JS is developer-supplied, same trust as branding.primaryColor -->
104
+ {@html bodyScriptHtml}
77
105
  </body>
78
106
  </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>;