@agence-zef/astro 0.2.0 → 0.3.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.
@@ -0,0 +1,65 @@
1
+ import type { CustomFields } from "@agence-zef/schema";
2
+ import { type ApiAccount, type ApiAccountSession, type ApiAccountsSettings, type Visitor } from "@agence-zef/sdk";
3
+ import type { AstroCookies } from "astro";
4
+ /** Cookie httpOnly qui garde le jeton de session. */
5
+ export declare const ACCOUNT_COOKIE = "zef_account";
6
+ /** Cookie d'une minute qui porte le message d'un refus (validation du sur-mesure). */
7
+ export declare const MESSAGE_COOKIE = "zef_account_message";
8
+ /** Ce dont les helpers ont besoin du contexte d'Astro (`Astro` ou le contexte d'un endpoint). */
9
+ export type AccountContext = {
10
+ cookies: AstroCookies;
11
+ request: Request;
12
+ url: URL;
13
+ clientAddress?: string;
14
+ };
15
+ /** Actions des formulaires de compte (`<AccountForm action>`, `/__zef/account/<action>`). */
16
+ export declare const ACCOUNT_ACTIONS: readonly ["sign-in", "sign-up", "sign-out", "forgot-password", "reset-password", "profile", "resend-verification", "delete"];
17
+ export type AccountAction = (typeof ACCOUNT_ACTIONS)[number];
18
+ export declare const isAccountAction: (value: string) => value is AccountAction;
19
+ /** Messages affichés par `<AccountForm>` après une erreur (`?zef_error=<code>`). */
20
+ export declare const ACCOUNT_ERRORS: Record<string, string>;
21
+ /** Messages affichés après une action réussie qui reste sur la page (`?zef_ok=<action>`). */
22
+ export declare const ACCOUNT_SUCCESS: Partial<Record<AccountAction, string>>;
23
+ /** Réglages publics du module, gardés une minute ; `null` s'il n'est pas activé. */
24
+ export declare function getAccountsSettings(): Promise<ApiAccountsSettings | null>;
25
+ /** Session et adresse IP à relayer au CMS. */
26
+ export declare function visitorOf(ctx: AccountContext): Visitor;
27
+ export declare function setAccountSession(ctx: AccountContext, session: ApiAccountSession["session"]): void;
28
+ export declare function clearAccountSession(ctx: AccountContext): void;
29
+ /**
30
+ * Compte de la personne connectée, ou `null` : une seule lecture au CMS par requête. Une session
31
+ * expirée efface le cookie. À appeler dans une page rendue à la demande.
32
+ */
33
+ export declare function getAccount(ctx: AccountContext): Promise<ApiAccount | null>;
34
+ /**
35
+ * Page de vérification (lien reçu par email, `?token=…`) : confirme l'adresse.
36
+ * `{ ok: false, code: "invalid_token" }` si le lien a déjà servi ou a expiré.
37
+ */
38
+ export declare function verifyEmailFromUrl(ctx: AccountContext): Promise<{
39
+ ok: true;
40
+ account: ApiAccount;
41
+ } | {
42
+ ok: false;
43
+ code: string;
44
+ message: string;
45
+ }>;
46
+ /** Chemin interne seulement (pas de redirection vers un autre site). */
47
+ export declare function safePath(value: unknown, fallback: string): string;
48
+ /**
49
+ * Valeurs des champs personnalisés envoyées par `<AccountForm>` (`custom.<champ>`), converties
50
+ * selon leur type ; seuls les champs listés dans `zef_custom` (affichés) sont envoyés, une case
51
+ * décochée valant `false` et aucune case cochée une liste vide.
52
+ */
53
+ export declare function customFromForm(form: FormData, fields?: CustomFields): Record<string, unknown> | undefined;
54
+ /**
55
+ * Traite l'envoi d'un formulaire de compte et renvoie l'adresse où rediriger : `redirect` après
56
+ * un succès, sinon la page du formulaire avec `?zef_ok=<action>` ou `?zef_error=<code>&zef_form=<action>`.
57
+ */
58
+ export declare function handleAccountForm(ctx: AccountContext, action: AccountAction, form: FormData,
59
+ /** Champs personnalisés du compte (`defineFields` de `zef.config.ts`). */
60
+ customFields?: CustomFields): Promise<string>;
61
+ /**
62
+ * Le chemin est-il protégé par l'option `accounts.protect` ? Chaque entrée est un chemin exact
63
+ * (`/compte`) ou, terminée par `/*`, tout ce qui est en dessous (`/compte/commandes/*`).
64
+ */
65
+ export declare function isProtected(pathname: string, patterns: string[]): boolean;
@@ -0,0 +1,301 @@
1
+ import { ZefApiError, } from "@agence-zef/sdk";
2
+ import { getClient } from "./helpers.js";
3
+ /*
4
+ * Module Comptes côté site (§ 7 U, lot 14c3) : la session d'une personne est un jeton gardé dans
5
+ * un cookie httpOnly du site et relayé au CMS par le serveur du site. Les pages de compte sont
6
+ * rendues à la demande (`export const prerender = false`) ; les formulaires (`<AccountForm>`)
7
+ * s'envoient sans JavaScript à `/__zef/account/<action>`.
8
+ */
9
+ /** Cookie httpOnly qui garde le jeton de session. */
10
+ export const ACCOUNT_COOKIE = "zef_account";
11
+ /** Cookie d'une minute qui porte le message d'un refus (validation du sur-mesure). */
12
+ export const MESSAGE_COOKIE = "zef_account_message";
13
+ /** Actions des formulaires de compte (`<AccountForm action>`, `/__zef/account/<action>`). */
14
+ export const ACCOUNT_ACTIONS = [
15
+ "sign-in",
16
+ "sign-up",
17
+ "sign-out",
18
+ "forgot-password",
19
+ "reset-password",
20
+ "profile",
21
+ "resend-verification",
22
+ "delete",
23
+ ];
24
+ export const isAccountAction = (value) => ACCOUNT_ACTIONS.includes(value);
25
+ /** Messages affichés par `<AccountForm>` après une erreur (`?zef_error=<code>`). */
26
+ export const ACCOUNT_ERRORS = {
27
+ invalid: "Vérifiez les champs du formulaire.",
28
+ invalid_credentials: "Email ou mot de passe incorrect.",
29
+ invalid_token: "Ce lien n'est plus valable. Demandez-en un nouveau.",
30
+ email_taken: "Un compte existe déjà avec cette adresse. Connectez-vous.",
31
+ turnstile: "La vérification anti-robot a échoué. Réessayez.",
32
+ locked: "Trop d'essais : le compte est bloqué 15 minutes.",
33
+ too_many_requests: "Trop d'essais. Réessayez dans un instant.",
34
+ unauthenticated: "Votre session a expiré. Connectez-vous à nouveau.",
35
+ refused: "Inscription refusée.",
36
+ module_inactive: "Les comptes ne sont pas disponibles sur ce site.",
37
+ invalid_url: "Lien de retour refusé par le CMS.",
38
+ unknown: "Une erreur est survenue. Réessayez dans un instant.",
39
+ };
40
+ /** Messages affichés après une action réussie qui reste sur la page (`?zef_ok=<action>`). */
41
+ export const ACCOUNT_SUCCESS = {
42
+ "forgot-password": "Si un compte existe avec cette adresse, un lien pour choisir un nouveau mot de passe vient de lui être envoyé.",
43
+ "reset-password": "Mot de passe enregistré. Vous pouvez vous connecter.",
44
+ profile: "Vos informations sont enregistrées.",
45
+ "resend-verification": "Un nouveau lien de vérification vient de vous être envoyé.",
46
+ };
47
+ /* ---------- Réglages du module (U-03), lus dans `GET /site` ---------- */
48
+ let cached;
49
+ /** Réglages publics du module, gardés une minute ; `null` s'il n'est pas activé. */
50
+ export async function getAccountsSettings() {
51
+ if (cached && Date.now() - cached.at < 60_000)
52
+ return cached.value;
53
+ const value = (await getClient().site()).accounts ?? null;
54
+ cached = { at: Date.now(), value };
55
+ return value;
56
+ }
57
+ /* ---------- Session ---------- */
58
+ /** Adresse IP du visiteur (absente pendant le build). */
59
+ function ipOf(ctx) {
60
+ try {
61
+ return ctx.clientAddress ?? null;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /** Session et adresse IP à relayer au CMS. */
68
+ export function visitorOf(ctx) {
69
+ return { session: ctx.cookies.get(ACCOUNT_COOKIE)?.value ?? null, ip: ipOf(ctx) };
70
+ }
71
+ export function setAccountSession(ctx, session) {
72
+ ctx.cookies.set(ACCOUNT_COOKIE, session.token, {
73
+ path: "/",
74
+ httpOnly: true,
75
+ sameSite: "lax",
76
+ secure: ctx.url.protocol === "https:",
77
+ expires: new Date(session.expiresAt),
78
+ });
79
+ }
80
+ export function clearAccountSession(ctx) {
81
+ ctx.cookies.delete(ACCOUNT_COOKIE, { path: "/" });
82
+ }
83
+ const current = new WeakMap();
84
+ /**
85
+ * Compte de la personne connectée, ou `null` : une seule lecture au CMS par requête. Une session
86
+ * expirée efface le cookie. À appeler dans une page rendue à la demande.
87
+ */
88
+ export function getAccount(ctx) {
89
+ const known = current.get(ctx.request);
90
+ if (known)
91
+ return known;
92
+ const visitor = visitorOf(ctx);
93
+ const promise = visitor.session
94
+ ? getClient()
95
+ .accounts.me(visitor)
96
+ .then((account) => {
97
+ if (!account)
98
+ clearAccountSession(ctx);
99
+ return account;
100
+ })
101
+ : Promise.resolve(null);
102
+ current.set(ctx.request, promise);
103
+ return promise;
104
+ }
105
+ /**
106
+ * Page de vérification (lien reçu par email, `?token=…`) : confirme l'adresse.
107
+ * `{ ok: false, code: "invalid_token" }` si le lien a déjà servi ou a expiré.
108
+ */
109
+ export async function verifyEmailFromUrl(ctx) {
110
+ const token = ctx.url.searchParams.get("token");
111
+ if (!token)
112
+ return failure("invalid_token");
113
+ try {
114
+ const { account } = await getClient().accounts.verify(token);
115
+ return { ok: true, account };
116
+ }
117
+ catch (error) {
118
+ return failure(errorCode(error));
119
+ }
120
+ }
121
+ /**
122
+ * Code d'une erreur de l'API (`ZefApiError`), sans `instanceof` : Vite peut charger deux copies
123
+ * du SDK. Une autre erreur est journalisée et devient `unknown`.
124
+ */
125
+ function errorCode(error) {
126
+ if (error instanceof ZefApiError)
127
+ return error.code;
128
+ if (error && typeof error === "object" && "code" in error && "status" in error) {
129
+ return String(error.code);
130
+ }
131
+ console.error("[@agence-zef/astro] accounts :", error);
132
+ return "unknown";
133
+ }
134
+ const failure = (code) => ({
135
+ ok: false,
136
+ code,
137
+ message: ACCOUNT_ERRORS[code] ?? ACCOUNT_ERRORS.unknown,
138
+ });
139
+ /* ---------- Formulaires (`/__zef/account/<action>`) ---------- */
140
+ /** Chemin interne seulement (pas de redirection vers un autre site). */
141
+ export function safePath(value, fallback) {
142
+ return typeof value === "string" && value.startsWith("/") && !value.startsWith("//")
143
+ ? value
144
+ : fallback;
145
+ }
146
+ /**
147
+ * Page du site qui reçoit un lien d'email, passée au CMS quand le site tourne en local ou sur un
148
+ * aperçu Vercel ; en ligne, le CMS prend celle de U-03 sur le domaine public.
149
+ */
150
+ function linkUrl(ctx, path) {
151
+ if (!path)
152
+ return undefined;
153
+ const host = ctx.url.hostname;
154
+ const local = host === "localhost" || host === "127.0.0.1" || host.endsWith(".vercel.app");
155
+ return local ? new URL(path, ctx.url.origin).toString() : undefined;
156
+ }
157
+ /**
158
+ * Valeurs des champs personnalisés envoyées par `<AccountForm>` (`custom.<champ>`), converties
159
+ * selon leur type ; seuls les champs listés dans `zef_custom` (affichés) sont envoyés, une case
160
+ * décochée valant `false` et aucune case cochée une liste vide.
161
+ */
162
+ export function customFromForm(form, fields = {}) {
163
+ const shown = String(form.get("zef_custom") ?? "")
164
+ .split(",")
165
+ .filter((k) => fields[k]);
166
+ if (!shown.length)
167
+ return undefined;
168
+ const values = {};
169
+ for (const key of shown) {
170
+ const field = fields[key];
171
+ const raw = form.getAll(`custom.${key}`).map((v) => (typeof v === "string" ? v.trim() : ""));
172
+ const one = raw[0] ?? "";
173
+ if (field.type === "boolean")
174
+ values[key] = one === "1";
175
+ else if (field.type === "select" && field.multiple)
176
+ values[key] = raw.filter(Boolean);
177
+ else if (field.type === "number")
178
+ values[key] = one === "" ? null : Number(one);
179
+ else
180
+ values[key] = one === "" ? null : one;
181
+ }
182
+ return values;
183
+ }
184
+ const text = (form, name) => {
185
+ const value = form.get(name);
186
+ return typeof value === "string" ? value.trim() : "";
187
+ };
188
+ /**
189
+ * Traite l'envoi d'un formulaire de compte et renvoie l'adresse où rediriger : `redirect` après
190
+ * un succès, sinon la page du formulaire avec `?zef_ok=<action>` ou `?zef_error=<code>&zef_form=<action>`.
191
+ */
192
+ export async function handleAccountForm(ctx, action, form,
193
+ /** Champs personnalisés du compte (`defineFields` de `zef.config.ts`). */
194
+ customFields) {
195
+ const referer = ctx.request.headers.get("referer");
196
+ const refererPath = referer ? new URL(referer).pathname : "/";
197
+ const page = safePath(form.get("zef_page"), refererPath);
198
+ const redirect = safePath(form.get("zef_redirect"), "");
199
+ const back = (key, value, message) => {
200
+ const url = new URL(page, ctx.url.origin);
201
+ // Refus d'une validation du sur-mesure : son message passe par un cookie de courte durée
202
+ // (jamais par l'adresse, qu'on pourrait forger pour afficher n'importe quoi sur le site).
203
+ if (message) {
204
+ ctx.cookies.set(MESSAGE_COOKIE, message.slice(0, 300), {
205
+ path: "/",
206
+ httpOnly: true,
207
+ sameSite: "lax",
208
+ secure: ctx.url.protocol === "https:",
209
+ maxAge: 60,
210
+ });
211
+ }
212
+ url.searchParams.set(key, value);
213
+ // Plusieurs formulaires sur une page : l'erreur ne s'affiche que sous celui envoyé.
214
+ if (key === "zef_error")
215
+ url.searchParams.set("zef_form", action);
216
+ if (redirect)
217
+ url.searchParams.set("redirect", redirect);
218
+ if (action === "reset-password" && key === "zef_error" && text(form, "token")) {
219
+ url.searchParams.set("token", text(form, "token"));
220
+ }
221
+ return `${url.pathname}${url.search}`;
222
+ };
223
+ const accounts = getClient().accounts;
224
+ const visitor = visitorOf(ctx);
225
+ const settings = await getAccountsSettings().catch(() => null);
226
+ try {
227
+ switch (action) {
228
+ case "sign-in": {
229
+ const { session } = await accounts.signIn(text(form, "email"), String(form.get("password") ?? ""), visitor);
230
+ setAccountSession(ctx, session);
231
+ return redirect || "/";
232
+ }
233
+ case "sign-up": {
234
+ const phone = text(form, "phone");
235
+ const { session } = await accounts.register({
236
+ email: text(form, "email"),
237
+ password: String(form.get("password") ?? ""),
238
+ firstName: text(form, "firstName"),
239
+ lastName: text(form, "lastName"),
240
+ phone: phone || null,
241
+ turnstileToken: text(form, "cf-turnstile-response"),
242
+ verifyUrl: linkUrl(ctx, settings?.pages.verification),
243
+ custom: customFromForm(form, customFields),
244
+ }, visitor);
245
+ setAccountSession(ctx, session);
246
+ return redirect || "/";
247
+ }
248
+ case "sign-out": {
249
+ if (visitor.session)
250
+ await accounts.signOut(visitor).catch(() => undefined);
251
+ clearAccountSession(ctx);
252
+ return redirect || "/";
253
+ }
254
+ case "forgot-password": {
255
+ await accounts.requestPasswordReset(text(form, "email"), linkUrl(ctx, settings?.pages.password), visitor);
256
+ return back("zef_ok", action);
257
+ }
258
+ case "reset-password": {
259
+ await accounts.resetPassword(text(form, "token"), String(form.get("password") ?? ""), visitor);
260
+ clearAccountSession(ctx);
261
+ return redirect || back("zef_ok", action);
262
+ }
263
+ case "profile": {
264
+ const phone = form.has("phone") ? text(form, "phone") || null : undefined;
265
+ await accounts.updateMe({
266
+ firstName: text(form, "firstName") || undefined,
267
+ lastName: text(form, "lastName") || undefined,
268
+ email: text(form, "email") || undefined,
269
+ ...(phone !== undefined ? { phone } : {}),
270
+ verifyUrl: linkUrl(ctx, settings?.pages.verification),
271
+ custom: customFromForm(form, customFields),
272
+ }, visitor);
273
+ return redirect || back("zef_ok", action);
274
+ }
275
+ case "resend-verification": {
276
+ await accounts.resendVerification(visitor, linkUrl(ctx, settings?.pages.verification));
277
+ return back("zef_ok", action);
278
+ }
279
+ case "delete": {
280
+ await accounts.deleteMe(String(form.get("password") ?? ""), visitor);
281
+ clearAccountSession(ctx);
282
+ return redirect || "/";
283
+ }
284
+ }
285
+ }
286
+ catch (error) {
287
+ const code = errorCode(error);
288
+ if (code === "unauthenticated")
289
+ clearAccountSession(ctx);
290
+ const message = code === "refused" ? error.message : undefined;
291
+ return back("zef_error", code, message);
292
+ }
293
+ }
294
+ /**
295
+ * Le chemin est-il protégé par l'option `accounts.protect` ? Chaque entrée est un chemin exact
296
+ * (`/compte`) ou, terminée par `/*`, tout ce qui est en dessous (`/compte/commandes/*`).
297
+ */
298
+ export function isProtected(pathname, patterns) {
299
+ const path = pathname.length > 1 ? pathname.replace(/\/$/, "") : pathname;
300
+ return patterns.some((p) => p.endsWith("/*") ? path.startsWith(p.slice(0, -1)) || path === p.slice(0, -2) : path === p);
301
+ }
package/dist/helpers.d.ts CHANGED
@@ -37,6 +37,15 @@ export type RouteProps = LayoutProps & ({
37
37
  collection: string;
38
38
  item: ApiItem;
39
39
  });
40
+ /**
41
+ * Props du gabarit pour une page codée dans le site (`src/pages/…`, ex. les pages de compte) :
42
+ * site, header et footer du CMS, titre ; `noindex` par défaut (pages privées).
43
+ */
44
+ export declare function zefLayoutProps(opts: {
45
+ title: string;
46
+ locale?: string;
47
+ noindex?: boolean;
48
+ }): Promise<LayoutProps>;
40
49
  /** Toutes les routes du site : pages publiées, puis éléments des collections avec page. */
41
50
  export declare function zefStaticPaths(config: ZefConfig): Promise<{
42
51
  params: {
package/dist/helpers.js CHANGED
@@ -17,6 +17,31 @@ export const getSite = () => getClient().site();
17
17
  export const getPage = (url) => getClient().page(url);
18
18
  export const getCollection = (name, opts) => getClient().collection(name, opts);
19
19
  export const getItem = (name, slug, opts) => getClient().item(name, slug, opts);
20
+ /**
21
+ * Props du gabarit pour une page codée dans le site (`src/pages/…`, ex. les pages de compte) :
22
+ * site, header et footer du CMS, titre ; `noindex` par défaut (pages privées).
23
+ */
24
+ export async function zefLayoutProps(opts) {
25
+ const api = getClient();
26
+ const site = await api.site();
27
+ const locale = opts.locale ?? site.defaultLocale;
28
+ const layout = await api.layout({ locale });
29
+ return {
30
+ site,
31
+ locale,
32
+ title: opts.title,
33
+ seo: {
34
+ title: null,
35
+ description: null,
36
+ noindex: opts.noindex ?? true,
37
+ canonical: null,
38
+ image: null,
39
+ },
40
+ alternates: {},
41
+ header: layout?.header ?? null,
42
+ footer: layout?.footer ?? null,
43
+ };
44
+ }
20
45
  const toParam = (url) => url.replace(/^\/+|\/+$/g, "") || undefined;
21
46
  /** Toutes les routes du site : pages publiées, puis éléments des collections avec page. */
22
47
  export async function zefStaticPaths(config) {
@@ -27,7 +52,8 @@ export async function zefStaticPaths(config) {
27
52
  for (const entry of await api.pages()) {
28
53
  for (const [locale, url] of Object.entries(entry.urls)) {
29
54
  const page = await api.page(url);
30
- if (!page)
55
+ // Contenu réservé : rendu à la demande avec la session du compte (`/[...zefReserved]`).
56
+ if (!page || page.access?.reserved)
31
57
  continue;
32
58
  paths.push({
33
59
  params: { path: toParam(url) },
@@ -53,7 +79,7 @@ export async function zefStaticPaths(config) {
53
79
  })));
54
80
  for (const { locale, list } of byLocale) {
55
81
  for (const item of list.items) {
56
- if (!item.url)
82
+ if (!item.url || item.access?.reserved)
57
83
  continue;
58
84
  const alternates = {};
59
85
  for (const other of byLocale) {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { AstroIntegration } from "astro";
2
+ export type { ApiAccount, ApiCart, ApiCheckout, ApiItem, ApiOrder, ApiProduct, ApiProductMedia, ApiProductVariant, } from "@agence-zef/sdk";
3
+ export * from "./accounts.js";
2
4
  export * from "./helpers.js";
5
+ export * from "./shop.js";
3
6
  export type ZefOptions = {
4
7
  /** Gabarit des pages du CMS (en-tête, pied, styles du site). */
5
8
  layout?: string;
@@ -9,6 +12,25 @@ export type ZefOptions = {
9
12
  * (MinIO en local : `http://localhost:9000`).
10
13
  */
11
14
  mediaUrls?: string[];
15
+ /**
16
+ * Module Comptes (§ 7 U) : route des formulaires `<AccountForm>` (`/__zef/account/<action>`)
17
+ * et middleware qui renvoie vers `signIn` (« /compte/connexion » par défaut) les pages de
18
+ * `protect` (chemins exacts, ou `/…/*` pour tout un dossier, ex. `["/compte"]`) tant que personne n'est connecté. Demande un
19
+ * adaptateur (pages de compte rendues à la demande).
20
+ */
21
+ accounts?: {
22
+ signIn?: string;
23
+ protect?: string[];
24
+ };
25
+ /**
26
+ * Boutique (§ 7 M) : route des formulaires du panier et du paiement (`/__zef/shop/<action>`)
27
+ * et adresses des pages du site (panier, paiement, confirmation). Demande l'option `accounts`.
28
+ */
29
+ shop?: {
30
+ cart?: string;
31
+ checkout?: string;
32
+ confirmation?: string;
33
+ };
12
34
  };
13
35
  /**
14
36
  * Intégration Astro du CMS ZEF (cahier des charges § 10) : route attrape-tout des pages
package/dist/index.js CHANGED
@@ -3,7 +3,9 @@ import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { loadZefEnv } from "./env.js";
5
5
  import { getClient } from "./helpers.js";
6
+ export * from "./accounts.js";
6
7
  export * from "./helpers.js";
8
+ export * from "./shop.js";
7
9
  const MEDIA_URL = "https://medias.agence-zef.fr";
8
10
  /** Domaines autorisés pour l'optimisation des images distantes (`image.remotePatterns`). */
9
11
  function mediaPatterns(urls) {
@@ -27,6 +29,8 @@ const VIRTUAL = {
27
29
  config: "virtual:zef/config",
28
30
  sections: "virtual:zef/sections",
29
31
  layout: "virtual:zef/layout",
32
+ accounts: "virtual:zef/accounts",
33
+ shop: "virtual:zef/shop",
30
34
  };
31
35
  /** `{ hero: "/…/src/sections/hero/index.astro" }` pour chaque dossier de `src/sections`. */
32
36
  function sectionFiles(root) {
@@ -53,7 +57,7 @@ export default function zef(options = {}) {
53
57
  return {
54
58
  name: "@agence-zef/astro",
55
59
  hooks: {
56
- "astro:config:setup": async ({ config, command, injectRoute, updateConfig, addWatchFile, logger, }) => {
60
+ "astro:config:setup": async ({ config, command, injectRoute, updateConfig, addWatchFile, addMiddleware, logger, }) => {
57
61
  const root = fileURLToPath(config.root);
58
62
  loadZefEnv(root);
59
63
  // Redirections du CMS (C-17 : slugs modifiés, pages déplacées, redirections manuelles).
@@ -113,6 +117,38 @@ export default function zef(options = {}) {
113
117
  else {
114
118
  logger.info("pas d'adaptateur : la route d'aperçu de l'éditeur n'est pas construite");
115
119
  }
120
+ // Contenu réservé (lot 14e5c) : les adresses que la route statique n'a pas générées
121
+ // (pages et éléments réservés) sont rendues à la demande, avec la session du compte.
122
+ if (options.shop && (command === "dev" || config.adapter)) {
123
+ injectRoute({
124
+ pattern: "/[...zefReserved]",
125
+ entrypoint: "@agence-zef/astro/routes/reserved.astro",
126
+ prerender: false,
127
+ });
128
+ }
129
+ // Module Comptes : formulaires et pages protégées, rendus à la demande.
130
+ const signIn = options.accounts?.signIn ?? "/compte/connexion";
131
+ const protect = (options.accounts?.protect ?? []).filter((p) => p !== signIn);
132
+ if (options.accounts) {
133
+ if (command === "dev" || config.adapter) {
134
+ injectRoute({
135
+ pattern: "/__zef/account/[action]",
136
+ entrypoint: "@agence-zef/astro/routes/account.ts",
137
+ prerender: false,
138
+ });
139
+ addMiddleware({ entrypoint: "@agence-zef/astro/middleware", order: "pre" });
140
+ if (options.shop) {
141
+ injectRoute({
142
+ pattern: "/__zef/shop/[action]",
143
+ entrypoint: "@agence-zef/astro/routes/shop.ts",
144
+ prerender: false,
145
+ });
146
+ }
147
+ }
148
+ else {
149
+ logger.warn("option accounts sans adaptateur : les formulaires de compte ne sont pas construits");
150
+ }
151
+ }
116
152
  updateConfig({
117
153
  vite: {
118
154
  plugins: [
@@ -133,6 +169,17 @@ export default function zef(options = {}) {
133
169
  }
134
170
  if (id === `\0${VIRTUAL.sections}`)
135
171
  return importMap(sectionFiles(root));
172
+ if (id === `\0${VIRTUAL.shop}`) {
173
+ return `export default ${JSON.stringify({
174
+ cart: options.shop?.cart ?? "/panier",
175
+ checkout: options.shop?.checkout ?? "/paiement",
176
+ confirmation: options.shop?.confirmation ?? "/commande",
177
+ signIn,
178
+ })};`;
179
+ }
180
+ if (id === `\0${VIRTUAL.accounts}`) {
181
+ return `export default ${JSON.stringify({ signIn, protect })};`;
182
+ }
136
183
  return undefined;
137
184
  },
138
185
  },
@@ -0,0 +1,7 @@
1
+ import type { MiddlewareHandler } from "astro";
2
+ /**
3
+ * Middleware ajouté par l'option `accounts` de l'intégration : une page protégée
4
+ * (`accounts.protect`) rendue à la demande renvoie vers la page de connexion
5
+ * (`accounts.signIn`, avec `?redirect=`) tant que personne n'est connecté.
6
+ */
7
+ export declare const onRequest: MiddlewareHandler;
@@ -0,0 +1,20 @@
1
+ import options from "virtual:zef/accounts";
2
+ import { getAccount, isProtected } from "./accounts.js";
3
+ /**
4
+ * Middleware ajouté par l'option `accounts` de l'intégration : une page protégée
5
+ * (`accounts.protect`) rendue à la demande renvoie vers la page de connexion
6
+ * (`accounts.signIn`, avec `?redirect=`) tant que personne n'est connecté.
7
+ */
8
+ export const onRequest = async (context, next) => {
9
+ const { pathname, search } = context.url;
10
+ if (context.isPrerendered ||
11
+ pathname === options.signIn ||
12
+ !isProtected(pathname, options.protect)) {
13
+ return next();
14
+ }
15
+ if (await getAccount(context))
16
+ return next();
17
+ const to = new URL(options.signIn, context.url.origin);
18
+ to.searchParams.set("redirect", `${pathname}${search}`);
19
+ return context.redirect(`${to.pathname}${to.search}`, 303);
20
+ };
package/dist/overlay.js CHANGED
@@ -127,6 +127,8 @@ export function startOverlay() {
127
127
  #zef-overlay .zef-frame { position: fixed; border: 1.5px solid #8c40f7; border-radius: 2px; }
128
128
  #zef-overlay .zef-frame.zef-section { border-style: dashed; border-color: #8c40f7aa; }
129
129
  #zef-overlay .zef-frame.zef-selected { border-width: 2px; }
130
+ #zef-overlay .zef-frame.zef-field { border: 1.5px solid #8c40f7; border-radius: 3px; background: #8c40f70d; }
131
+ #zef-overlay .zef-frame.zef-field .zef-tag { padding: 1px 5px; font-size: 10px; }
130
132
  #zef-overlay .zef-tag { position: absolute; left: -1.5px; bottom: 100%; margin-bottom: 2px; padding: 2px 6px; border-radius: 4px; background: #8c40f7; color: #fff; white-space: nowrap; }
131
133
  #zef-overlay .zef-compare { border: 2px solid #ea580c; }
132
134
  #zef-overlay .zef-compare .zef-tag { left: 8px; top: 8px; bottom: auto; margin: 0; background: #fff7ed; color: #c2410c; }
@@ -283,6 +285,11 @@ export function startOverlay() {
283
285
  if (wrapper)
284
286
  insertLine(section, rect(wrapper));
285
287
  }
288
+ // Champ survolé (titre, texte, bouton, image) : cadre et libellé ; un clic l'ouvre dans le
289
+ // panneau de droite.
290
+ const field = target && !editing ? target.closest(`[${ATTR}]`) : null;
291
+ if (field && field !== editing)
292
+ frame(rect(field), "zef-field", info(field)?.label);
286
293
  }
287
294
  let frameRequest = 0;
288
295
  const redraw = () => {
@@ -335,9 +342,10 @@ export function startOverlay() {
335
342
  });
336
343
  return;
337
344
  }
338
- // Un clic choisit toute la section (ou l'élément de collection pour ses propres champs).
345
+ // Un clic choisit toute la section (ou l'élément de collection pour ses propres champs) et
346
+ // désigne le champ cliqué, que l'éditeur ouvre et met au premier plan dans son panneau.
339
347
  selected = { section: location?.scope === "@" ? "@" : section, path: null };
340
- post({ zef: "select", ...selected });
348
+ post({ zef: "select", ...selected, path: location?.path ?? null });
341
349
  redraw();
342
350
  }, true);
343
351
  document.addEventListener("submit", (e) => e.preventDefault(), true);