@agence-zef/astro 0.5.0 → 0.6.0-pilot.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.
@@ -17,11 +17,13 @@ interface Props {
17
17
  * priorité (`fetchpriority="high"`). Une seule par page.
18
18
  */
19
19
  priority?: boolean;
20
+ /** Qualité de compression (1 à 100, défaut 75 : à l'œil identique, deux à trois fois plus léger). */
21
+ quality?: number;
20
22
  /** Largeur affichée (`(min-width: 48rem) 33vw, 100vw`) : active le `srcset`. */
21
23
  sizes?: string;
22
24
  }
23
25
 
24
- const { media, class: className, priority = false, sizes } = Astro.props;
26
+ const { media, class: className, priority = false, sizes, quality = 75 } = Astro.props;
25
27
  const loading = priority ? "eager" : (Astro.props.loading ?? "lazy");
26
28
  const fetchpriority = priority ? "high" : undefined;
27
29
  // Proportions réservées même si le CSS du site force `width: auto` (pas de décalage au chargement).
@@ -53,6 +55,7 @@ const widths =
53
55
  decoding={priority ? "sync" : "async"}
54
56
  sizes={sizes}
55
57
  widths={widths}
58
+ quality={quality}
56
59
  class={className}
57
60
  style={style}
58
61
  />
@@ -0,0 +1,10 @@
1
+ /*
2
+ * Rendu à la demande (`rendering: "cached"`) : règles de cache des pages du CMS. Le CDN garde
3
+ * une page un an ; une publication l'invalide par le tag `zef` (le CDN sert l'ancienne version
4
+ * le temps d'en rendre une nouvelle, `swr`).
5
+ */
6
+
7
+ /** Tag posé sur toutes les réponses du site rendues depuis le CMS. */
8
+ export const CACHE_TAG = "zef";
9
+
10
+ export const PAGE_CACHE = { maxAge: 60 * 60 * 24 * 365, swr: 60 * 60 * 24 * 7 };
@@ -1,15 +1,16 @@
1
1
  import config from "virtual:zef/config";
2
2
  import { getClient, llmsTxt, siteOrigin, zefSiteIndex } from "@agence-zef/astro";
3
3
  import type { APIRoute } from "astro";
4
+ import { CACHE_TAG, PAGE_CACHE } from "./cache";
4
5
 
5
6
  /*
6
7
  * `llms.txt` injecté par `@agence-zef/astro` (§ 5.13, format llmstxt.org) : le site présenté aux
7
8
  * assistants IA, avec ses pages et ses collections et leur description.
8
9
  */
9
10
 
10
- export const prerender = true;
11
-
12
- export const GET: APIRoute = async ({ site: astroSite }) => {
11
+ export const GET: APIRoute = async ({ site: astroSite, cache }) => {
12
+ // Rendu à la demande (`rendering: "cached"`) : gardé en cache, invalidé par une publication.
13
+ if (cache.enabled) cache.set({ ...PAGE_CACHE, tags: [CACHE_TAG] });
13
14
  const site = await getClient().site();
14
15
  const collections = (config.collections ?? [])
15
16
  .filter((c) => c.page)
@@ -0,0 +1,68 @@
1
+ ---
2
+ import Layout from "virtual:zef/layout";
3
+ /**
4
+ * Rendu à la demande (`defineConfig({ rendering: "cached" })`) : chaque page du CMS ou élément
5
+ * de collection est rendu à la première visite puis gardé en cache par le CDN (route caching
6
+ * d'Astro), avec le tag `zef`. Une publication invalide ce tag (`/__zef/revalidate`) : les pages
7
+ * sont rafraîchies à la visite suivante, sans build. Contenu réservé : jamais en cache.
8
+ * Adresse inconnue : redirection du CMS (C-17) si elle existe, sinon 404.
9
+ */
10
+ import { getClient, type RouteProps, zefRouteProps } from "@agence-zef/astro";
11
+ import { visitorOf } from "@agence-zef/astro/accounts";
12
+ import ReservedNotice from "../ReservedNotice.astro";
13
+ import ZefSections from "../ZefSections.astro";
14
+ import { CACHE_TAG, PAGE_CACHE } from "./cache";
15
+
16
+ const path = decodeURI(Astro.url.pathname);
17
+ let props: RouteProps | null;
18
+ try {
19
+ props = await zefRouteProps(path, visitorOf(Astro));
20
+ } catch {
21
+ // CMS injoignable : 503 sans cache, le CDN continue de servir la version qu'il a.
22
+ if (Astro.cache.enabled) Astro.cache.set(false);
23
+ return new Response("Service momentanément indisponible.", { status: 503 });
24
+ }
25
+
26
+ if (!props) {
27
+ const redirect = (
28
+ await getClient()
29
+ .redirects()
30
+ .catch(() => [])
31
+ ).find((r) => r.from === path || r.from === path.replace(/\/$/, ""));
32
+ if (Astro.cache.enabled) Astro.cache.set({ maxAge: 60, tags: [CACHE_TAG] });
33
+ if (redirect) return Astro.redirect(redirect.to, redirect.status === 302 ? 302 : 301);
34
+ return new Response(null, { status: 404 });
35
+ }
36
+
37
+ const doc = props.kind === "page" ? props.page : props.item;
38
+ const reserved = doc.access?.reserved === true;
39
+ // Le contenu réservé dépend du visiteur : jamais en cache.
40
+ if (Astro.cache.enabled) Astro.cache.set(reserved ? false : { ...PAGE_CACHE, tags: [CACHE_TAG] });
41
+
42
+ // Site en développement (§ 5.11) : aucune page n'est indexable.
43
+ const seo =
44
+ props.site.indexable === false || reserved ? { ...props.seo, noindex: true } : props.seo;
45
+ ---
46
+
47
+ <Layout
48
+ site={props.site}
49
+ locale={props.locale}
50
+ title={props.title}
51
+ seo={seo}
52
+ alternates={props.alternates}
53
+ url={props.url}
54
+ breadcrumbs={props.breadcrumbs}
55
+ item={props.kind === "item" ? props.item : undefined}
56
+ header={props.header}
57
+ footer={props.footer}
58
+ >
59
+ {
60
+ reserved && !doc.access?.granted ? (
61
+ <ReservedNotice title={props.title} products={doc.access?.products ?? []} />
62
+ ) : props.kind === "page" ? (
63
+ <ZefSections sections={props.page.sections} />
64
+ ) : (
65
+ <ZefSections sections={props.item.sections ?? []} item={props.item} />
66
+ )
67
+ }
68
+ </Layout>
@@ -0,0 +1,33 @@
1
+ import { createClient } from "@agence-zef/sdk";
2
+ import type { APIRoute } from "astro";
3
+ import { CACHE_TAG } from "./cache";
4
+
5
+ /*
6
+ * Invalidation du cache après une publication (`rendering: "cached"`) : le CMS appelle
7
+ * `POST /__zef/revalidate` avec un jeton d'aperçu de ce site (`Authorization: Bearer …`). Le site
8
+ * vérifie le jeton auprès de son CMS (`ZEF_URL` désigne le client : un jeton accepté là est
9
+ * forcément le sien), puis invalide le tag `zef` : toutes les pages sont rafraîchies à leur
10
+ * prochaine visite.
11
+ */
12
+
13
+ export const prerender = false;
14
+
15
+ export const POST: APIRoute = async ({ request, cache }) => {
16
+ const token = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
17
+ const url = process.env.ZEF_URL;
18
+ if (!token || !url) return json(401, { ok: false, error: "Jeton manquant." });
19
+ const valid = await createClient({ url, apiKey: token })
20
+ .site()
21
+ .then(() => true)
22
+ .catch(() => false);
23
+ if (!valid) return json(403, { ok: false, error: "Jeton refusé." });
24
+ if (!cache.enabled) return json(200, { ok: true, invalidated: false });
25
+ await cache.invalidate({ tags: [CACHE_TAG] });
26
+ return json(200, { ok: true, invalidated: true });
27
+ };
28
+
29
+ const json = (status: number, body: unknown) =>
30
+ new Response(JSON.stringify(body), {
31
+ status,
32
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
33
+ });
@@ -1,5 +1,6 @@
1
1
  import { getClient, robotsTxt, siteOrigin } from "@agence-zef/astro";
2
2
  import type { APIRoute } from "astro";
3
+ import { CACHE_TAG, PAGE_CACHE } from "./cache";
3
4
 
4
5
  /*
5
6
  * `robots.txt` injecté par `@agence-zef/astro` (§ 5.11, § 5.13) : tout est bloqué tant que le site
@@ -7,9 +8,9 @@ import type { APIRoute } from "astro";
7
8
  * sitemap est déclaré. Construit avec le site : la mise en ligne relance un build.
8
9
  */
9
10
 
10
- export const prerender = true;
11
-
12
- export const GET: APIRoute = async ({ site: astroSite }) => {
11
+ export const GET: APIRoute = async ({ site: astroSite, cache }) => {
12
+ // Rendu à la demande (`rendering: "cached"`) : gardé en cache, invalidé par une publication.
13
+ if (cache.enabled) cache.set({ ...PAGE_CACHE, tags: [CACHE_TAG] });
13
14
  const site = await getClient().site();
14
15
  const body = robotsTxt(site, siteOrigin(site, astroSite?.origin));
15
16
  return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
@@ -1,15 +1,16 @@
1
1
  import config from "virtual:zef/config";
2
2
  import { getClient, sitemapXml, siteOrigin, zefSiteIndex } from "@agence-zef/astro";
3
3
  import type { APIRoute } from "astro";
4
+ import { CACHE_TAG, PAGE_CACHE } from "./cache";
4
5
 
5
6
  /*
6
7
  * `sitemap.xml` injecté par `@agence-zef/astro` (§ 5.13) : pages et éléments publiés et
7
8
  * indexables, chaque langue liée par `xhtml:link`, date de dernière publication.
8
9
  */
9
10
 
10
- export const prerender = true;
11
-
12
- export const GET: APIRoute = async ({ site: astroSite }) => {
11
+ export const GET: APIRoute = async ({ site: astroSite, cache }) => {
12
+ // Rendu à la demande (`rendering: "cached"`) : gardé en cache, invalidé par une publication.
13
+ if (cache.enabled) cache.set({ ...PAGE_CACHE, tags: [CACHE_TAG] });
13
14
  const site = await getClient().site();
14
15
  const body = sitemapXml(await zefSiteIndex(config), siteOrigin(site, astroSite?.origin));
15
16
  return new Response(body, { headers: { "Content-Type": "application/xml; charset=utf-8" } });
package/dist/helpers.d.ts CHANGED
@@ -56,6 +56,12 @@ export declare function zefLayoutProps(opts: {
56
56
  locale?: string;
57
57
  noindex?: boolean;
58
58
  }): Promise<LayoutProps>;
59
+ /**
60
+ * Rendu à la demande (`rendering: "cached"`) : les props du gabarit pour une adresse, page ou
61
+ * élément de collection, sans parcourir tout le site. `null` si rien n'est publié à cette
62
+ * adresse ; une erreur du CMS remonte (la page en cache reste servie).
63
+ */
64
+ export declare function zefRouteProps(path: string, visitor?: Parameters<ZefClient["document"]>[1]): Promise<RouteProps | null>;
59
65
  /**
60
66
  * Toutes les routes du site : pages publiées, puis éléments des collections avec page. Calculé
61
67
  * une fois par build : la route des pages, le sitemap et `llms.txt` partagent le résultat.
package/dist/helpers.js CHANGED
@@ -43,6 +43,85 @@ export async function zefLayoutProps(opts) {
43
43
  };
44
44
  }
45
45
  const toParam = (url) => url.replace(/^\/+|\/+$/g, "") || undefined;
46
+ /** Arborescence des pages : fil d'Ariane d'une page, accueil de chaque langue. */
47
+ function pageTree(entries, site) {
48
+ const byId = new Map(entries.map((e) => [e.id, e]));
49
+ const homeOf = (locale) => entries.find((e) => e.urls[locale] === "/" || e.urls[locale] === `/${locale}`);
50
+ /** Accueil, puis les pages parentes, puis la page elle-même. */
51
+ const trail = (id, locale) => {
52
+ const out = [];
53
+ for (let e = byId.get(id); e; e = e.parentId ? byId.get(e.parentId) : undefined) {
54
+ const url = e.urls[locale];
55
+ if (url)
56
+ out.unshift({ title: e.titles[locale] || e.titles[site.defaultLocale] || "", url });
57
+ }
58
+ const home = homeOf(locale);
59
+ if (home && out[0]?.url !== home.urls[locale]) {
60
+ out.unshift({ title: home.titles[locale] || site.name, url: home.urls[locale] });
61
+ }
62
+ return out;
63
+ };
64
+ return { trail, homeOf };
65
+ }
66
+ /**
67
+ * Rendu à la demande (`rendering: "cached"`) : les props du gabarit pour une adresse, page ou
68
+ * élément de collection, sans parcourir tout le site. `null` si rien n'est publié à cette
69
+ * adresse ; une erreur du CMS remonte (la page en cache reste servie).
70
+ */
71
+ export async function zefRouteProps(path, visitor) {
72
+ const api = getClient();
73
+ const [found, site, entries] = await Promise.all([
74
+ api.document(path, visitor).catch((error) => {
75
+ if (error.status === 404)
76
+ return null;
77
+ throw error;
78
+ }),
79
+ api.site(),
80
+ api.pages(),
81
+ ]);
82
+ if (!found)
83
+ return null;
84
+ const { trail, homeOf } = pageTree(entries, site);
85
+ if (found.kind === "page") {
86
+ const { page } = found;
87
+ const layout = await api.layout({ locale: page.locale });
88
+ const entry = entries.find((e) => e.id === page.id);
89
+ return {
90
+ kind: "page",
91
+ page,
92
+ site,
93
+ locale: page.locale,
94
+ title: page.seo.title ?? page.title,
95
+ seo: page.seo,
96
+ alternates: page.alternates,
97
+ url: page.url,
98
+ breadcrumbs: entry ? trail(entry.id, page.locale) : [],
99
+ header: layout.header,
100
+ footer: layout.footer,
101
+ };
102
+ }
103
+ const { item, collection, titleField } = found;
104
+ const locale = Object.entries(item.alternates ?? {}).find(([, url]) => url === item.url)?.[0] ??
105
+ site.defaultLocale;
106
+ const layout = await api.layout({ locale });
107
+ const title = String(item[titleField] ?? "");
108
+ const listingPath = item.url?.replace(/\/[^/]+\/?$/, "") ?? "";
109
+ const listing = entries.find((e) => e.urls[locale] === listingPath) ?? homeOf(locale);
110
+ return {
111
+ kind: "item",
112
+ collection,
113
+ item,
114
+ site,
115
+ locale,
116
+ title: item.seo?.title ?? title,
117
+ seo: item.seo,
118
+ alternates: item.alternates ?? {},
119
+ url: item.url ?? path,
120
+ breadcrumbs: [...(listing ? trail(listing.id, locale) : []), { title, url: item.url ?? path }],
121
+ header: layout.header,
122
+ footer: layout.footer,
123
+ };
124
+ }
46
125
  let staticPaths;
47
126
  /**
48
127
  * Toutes les routes du site : pages publiées, puis éléments des collections avec page. Calculé
@@ -61,22 +140,7 @@ async function buildStaticPaths(config) {
61
140
  const paths = [];
62
141
  const layouts = new Map(await Promise.all(site.locales.map(async (l) => [l, await api.layout({ locale: l })])));
63
142
  const entries = await api.pages();
64
- const byId = new Map(entries.map((e) => [e.id, e]));
65
- const homeOf = (locale) => entries.find((e) => e.urls[locale] === "/" || e.urls[locale] === `/${locale}`);
66
- /** Accueil, puis les pages parentes, puis la page elle-même. */
67
- const trail = (id, locale) => {
68
- const out = [];
69
- for (let e = byId.get(id); e; e = e.parentId ? byId.get(e.parentId) : undefined) {
70
- const url = e.urls[locale];
71
- if (url)
72
- out.unshift({ title: e.titles[locale] || e.titles[site.defaultLocale] || "", url });
73
- }
74
- const home = homeOf(locale);
75
- if (home && out[0]?.url !== home.urls[locale]) {
76
- out.unshift({ title: home.titles[locale] || site.name, url: home.urls[locale] });
77
- }
78
- return out;
79
- };
143
+ const { trail, homeOf } = pageTree(entries, site);
80
144
  for (const entry of entries) {
81
145
  for (const [locale, url] of Object.entries(entry.urls)) {
82
146
  const page = await api.page(url);
@@ -111,12 +175,7 @@ async function buildStaticPaths(config) {
111
175
  for (const item of list.items) {
112
176
  if (!item.url || item.access?.reserved)
113
177
  continue;
114
- const alternates = {};
115
- for (const other of byLocale) {
116
- const url = other.list.items.find((i) => i.id === item.id)?.url;
117
- if (url)
118
- alternates[other.locale] = url;
119
- }
178
+ const alternates = { ...item.alternates };
120
179
  // Fil d'Ariane : accueil, page de la liste si elle existe (`/vins`), puis l'élément.
121
180
  const listing = entries.find((e) => e.urls[locale] === def.page.path || e.urls[locale] === `/${locale}${def.page.path}`) ?? homeOf(locale);
122
181
  const title = String(item[def.titleField] ?? "");
package/dist/index.js CHANGED
@@ -8,6 +8,20 @@ export * from "./helpers.js";
8
8
  export * from "./seo.js";
9
9
  export * from "./shop.js";
10
10
  const MEDIA_URL = "https://medias.agence-zef.fr";
11
+ /** `rendering` de `zef.config.ts` (lu comme la CLI, avec jiti) ; `static` en cas de doute. */
12
+ async function readRendering(file) {
13
+ if (!existsSync(file))
14
+ return "static";
15
+ try {
16
+ const { createJiti } = await import("jiti");
17
+ const jiti = createJiti(import.meta.url, { moduleCache: false, fsCache: false });
18
+ const config = await jiti.import(file, { default: true });
19
+ return config?.rendering === "cached" ? "cached" : "static";
20
+ }
21
+ catch {
22
+ return "static";
23
+ }
24
+ }
11
25
  /** Domaines autorisés pour l'optimisation des images distantes (`image.remotePatterns`). */
12
26
  function mediaPatterns(urls) {
13
27
  return urls.flatMap((raw) => {
@@ -93,27 +107,55 @@ export default function zef(options = {}) {
93
107
  addWatchFile(join(root, ".env"));
94
108
  const layout = join(root, options.layout ?? "src/layouts/Page.astro");
95
109
  const configFile = join(root, "zef.config.ts");
110
+ // Rendu des pages (`defineConfig({ rendering })`) : construites au build (`static`), ou
111
+ // rendues à la demande et gardées en cache par le CDN, invalidé par une publication
112
+ // (`cached`, demande un adaptateur).
113
+ const rendering = await readRendering(configFile);
114
+ const cached = rendering === "cached" && Boolean(config.adapter || command === "dev");
115
+ if (rendering === "cached" && !cached) {
116
+ logger.warn("rendering « cached » sans adaptateur : pages construites au build");
117
+ }
118
+ if (cached && !config.cache?.provider) {
119
+ // Cache du CDN de l'hébergeur : Vercel (tags, invalidation), sinon en mémoire.
120
+ const vercelProvider = config.adapter?.name === "@astrojs/vercel";
121
+ updateConfig({
122
+ cache: {
123
+ provider: vercelProvider
124
+ ? { name: "vercel", entrypoint: "@astrojs/vercel/cache/provider" }
125
+ : { name: "memory", entrypoint: "astro/cache/memory" },
126
+ },
127
+ });
128
+ }
96
129
  injectRoute({
97
130
  pattern: "/[...path]",
98
- entrypoint: "@agence-zef/astro/routes/page.astro",
99
- prerender: true,
131
+ entrypoint: cached
132
+ ? "@agence-zef/astro/routes/page-cached.astro"
133
+ : "@agence-zef/astro/routes/page.astro",
134
+ prerender: !cached,
100
135
  });
101
136
  injectRoute({
102
137
  pattern: "/robots.txt",
103
138
  entrypoint: "@agence-zef/astro/routes/robots.ts",
104
- prerender: true,
139
+ prerender: !cached,
105
140
  });
106
141
  // Référencement prêt à l'emploi (§ 5.13) : sitemap et présentation pour les IA.
107
142
  injectRoute({
108
143
  pattern: "/sitemap.xml",
109
144
  entrypoint: "@agence-zef/astro/routes/sitemap.ts",
110
- prerender: true,
145
+ prerender: !cached,
111
146
  });
112
147
  injectRoute({
113
148
  pattern: "/llms.txt",
114
149
  entrypoint: "@agence-zef/astro/routes/llms.ts",
115
- prerender: true,
150
+ prerender: !cached,
116
151
  });
152
+ if (cached) {
153
+ injectRoute({
154
+ pattern: "/__zef/revalidate",
155
+ entrypoint: "@agence-zef/astro/routes/revalidate.ts",
156
+ prerender: false,
157
+ });
158
+ }
117
159
  // Aperçu de l'éditeur : rendu à la demande, donc avec un adaptateur (Vercel) ou en dev.
118
160
  if (command === "dev" || config.adapter) {
119
161
  injectRoute({
@@ -137,7 +179,7 @@ export default function zef(options = {}) {
137
179
  }
138
180
  // Contenu réservé (lot 14e5c) : les adresses que la route statique n'a pas générées
139
181
  // (pages et éléments réservés) sont rendues à la demande, avec la session du compte.
140
- if (options.shop && (command === "dev" || config.adapter)) {
182
+ if (options.shop && !cached && (command === "dev" || config.adapter)) {
141
183
  injectRoute({
142
184
  pattern: "/[...zefReserved]",
143
185
  entrypoint: "@agence-zef/astro/routes/reserved.astro",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agence-zef/astro",
3
- "version": "0.5.0",
3
+ "version": "0.6.0-pilot.1",
4
4
  "description": "Intégration Astro du CMS ZEF : routes, rendu des sections, aperçu, composants réutilisables sans style.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -63,7 +63,9 @@
63
63
  "./ui": {
64
64
  "types": "./dist/ui.d.ts",
65
65
  "import": "./dist/ui.js"
66
- }
66
+ },
67
+ "./routes/page-cached.astro": "./components/routes/page-cached.astro",
68
+ "./routes/revalidate.ts": "./components/routes/revalidate.ts"
67
69
  },
68
70
  "files": [
69
71
  "dist",
@@ -81,8 +83,9 @@
81
83
  "@types/node": "^24.0.0"
82
84
  },
83
85
  "dependencies": {
84
- "@agence-zef/schema": "0.5.0",
85
- "@agence-zef/sdk": "0.5.0"
86
+ "jiti": "^2.6.1",
87
+ "@agence-zef/schema": "0.6.0-pilot.0",
88
+ "@agence-zef/sdk": "0.6.0-pilot.0"
86
89
  },
87
90
  "homepage": "https://agence-zef.fr",
88
91
  "scripts": {