@agence-zef/astro 0.3.1 → 0.4.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,24 @@
1
+ ---
2
+ /**
3
+ * Référencement de la page (§ 5.13), à placer dans le `<head>` du gabarit :
4
+ * `<Seo {...Astro.props} organization={…} />`. Titre, description, robots, canonique, hreflang
5
+ * (avec `x-default`), Open Graph, Twitter et données structurées JSON-LD (WebSite, Organization
6
+ * ou LocalBusiness, WebPage, BreadcrumbList, Product pour la Boutique). Les champs viennent de
7
+ * l'onglet SEO de l'éditeur ; l'image de partage par défaut et l'adresse du site, du CMS.
8
+ */
9
+ import { jsonLdScript, type SeoInput, seoTags } from "@agence-zef/astro";
10
+
11
+ type Props = Omit<SeoInput, "url" | "origin"> & { url?: string };
12
+
13
+ const props = Astro.props;
14
+ const tags = seoTags({
15
+ ...props,
16
+ url: props.url ?? Astro.url.pathname,
17
+ origin: Astro.site?.origin ?? Astro.url.origin,
18
+ });
19
+ ---
20
+
21
+ <title>{tags.title}</title>
22
+ {tags.meta.map((m) => (m.name ? <meta name={m.name} content={m.content} /> : <meta property={m.property} content={m.content} />))}
23
+ {tags.links.map((l) => <link rel={l.rel} href={l.href} hreflang={l.hreflang} />)}
24
+ <script type="application/ld+json" is:inline set:html={jsonLdScript(tags.jsonLd)} />
@@ -0,0 +1,24 @@
1
+ import config from "virtual:zef/config";
2
+ import { getClient, llmsTxt, siteOrigin, zefSiteIndex } from "@agence-zef/astro";
3
+ import type { APIRoute } from "astro";
4
+
5
+ /*
6
+ * `llms.txt` injecté par `@agence-zef/astro` (§ 5.13, format llmstxt.org) : le site présenté aux
7
+ * assistants IA, avec ses pages et ses collections et leur description.
8
+ */
9
+
10
+ export const prerender = true;
11
+
12
+ export const GET: APIRoute = async ({ site: astroSite }) => {
13
+ const site = await getClient().site();
14
+ const collections = (config.collections ?? [])
15
+ .filter((c) => c.page)
16
+ .map((c) => ({ name: c.name, label: c.label }));
17
+ const body = llmsTxt(
18
+ site,
19
+ await zefSiteIndex(config),
20
+ siteOrigin(site, astroSite?.origin),
21
+ collections,
22
+ );
23
+ return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
24
+ };
@@ -14,11 +14,22 @@ export async function getStaticPaths() {
14
14
  }
15
15
 
16
16
  const props = Astro.props as RouteProps;
17
- const { site, locale, title, alternates, header, footer } = props;
17
+ const { site, locale, title, alternates, header, footer, url, breadcrumbs } = props;
18
18
  // Site en développement (§ 5.11) : aucune page n'est indexable.
19
19
  const seo = site.indexable === false ? { ...props.seo, noindex: true } : props.seo;
20
20
  ---
21
21
 
22
- <Layout site={site} locale={locale} title={title} seo={seo} alternates={alternates} header={header} footer={footer}>
22
+ <Layout
23
+ site={site}
24
+ locale={locale}
25
+ title={title}
26
+ seo={seo}
27
+ alternates={alternates}
28
+ url={url}
29
+ breadcrumbs={breadcrumbs}
30
+ item={props.kind === "item" ? props.item : undefined}
31
+ header={header}
32
+ footer={footer}
33
+ >
23
34
  {props.kind === "page" ? <ZefSections sections={props.page.sections} /> : <ZefSections sections={props.item.sections ?? []} item={props.item} />}
24
35
  </Layout>
@@ -1,16 +1,16 @@
1
- import { getClient } from "@agence-zef/astro";
1
+ import { getClient, robotsTxt, siteOrigin } from "@agence-zef/astro";
2
2
  import type { APIRoute } from "astro";
3
3
 
4
4
  /*
5
- * `robots.txt` injecté par `@agence-zef/astro` (§ 5.11) : tout est bloqué tant que le site est en
6
- * développement ; une fois en ligne, les moteurs peuvent tout indexer. Construit avec le site :
7
- * la mise en ligne relance un build.
5
+ * `robots.txt` injecté par `@agence-zef/astro` (§ 5.11, § 5.13) : tout est bloqué tant que le site
6
+ * est en développement ; une fois en ligne, moteurs et assistants IA peuvent tout lire, le
7
+ * sitemap est déclaré. Construit avec le site : la mise en ligne relance un build.
8
8
  */
9
9
 
10
10
  export const prerender = true;
11
11
 
12
- export const GET: APIRoute = async () => {
12
+ export const GET: APIRoute = async ({ site: astroSite }) => {
13
13
  const site = await getClient().site();
14
- const body = site.indexable ? "User-agent: *\nAllow: /\n" : "User-agent: *\nDisallow: /\n";
14
+ const body = robotsTxt(site, siteOrigin(site, astroSite?.origin));
15
15
  return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
16
16
  };
@@ -0,0 +1,16 @@
1
+ import config from "virtual:zef/config";
2
+ import { getClient, sitemapXml, siteOrigin, zefSiteIndex } from "@agence-zef/astro";
3
+ import type { APIRoute } from "astro";
4
+
5
+ /*
6
+ * `sitemap.xml` injecté par `@agence-zef/astro` (§ 5.13) : pages et éléments publiés et
7
+ * indexables, chaque langue liée par `xhtml:link`, date de dernière publication.
8
+ */
9
+
10
+ export const prerender = true;
11
+
12
+ export const GET: APIRoute = async ({ site: astroSite }) => {
13
+ const site = await getClient().site();
14
+ const body = sitemapXml(await zefSiteIndex(config), siteOrigin(site, astroSite?.origin));
15
+ return new Response(body, { headers: { "Content-Type": "application/xml; charset=utf-8" } });
16
+ };
package/dist/helpers.d.ts CHANGED
@@ -23,12 +23,22 @@ export type LayoutProps = {
23
23
  seo: ApiSeo;
24
24
  /** Adresse dans chaque langue (sélecteur de langue, balises hreflang). */
25
25
  alternates: Record<string, string>;
26
+ /** Adresse de la page (`/la-cave`), base de la canonique et de `og:url`. */
27
+ url?: string;
28
+ /** Fil d'Ariane, de l'accueil à la page (données structurées BreadcrumbList). */
29
+ breadcrumbs?: Breadcrumb[];
30
+ /** Élément de collection affiché (données structurées : Product pour la Boutique). */
31
+ item?: ApiItem;
26
32
  /** Header et footer (sections partagées), à rendre avec `<ZefSections>` ; `null` sans `layout`. */
27
33
  header: ApiSection | null;
28
34
  footer: ApiSection | null;
29
35
  /** Aperçu de l'éditeur : à passer à `<ZefSections preview>` pour le header et le footer. */
30
36
  preview?: boolean;
31
37
  };
38
+ export type Breadcrumb = {
39
+ title: string;
40
+ url: string;
41
+ };
32
42
  export type RouteProps = LayoutProps & ({
33
43
  kind: "page";
34
44
  page: ApiPage;
@@ -46,13 +56,31 @@ export declare function zefLayoutProps(opts: {
46
56
  locale?: string;
47
57
  noindex?: boolean;
48
58
  }): Promise<LayoutProps>;
49
- /** Toutes les routes du site : pages publiées, puis éléments des collections avec page. */
59
+ /**
60
+ * Toutes les routes du site : pages publiées, puis éléments des collections avec page. Calculé
61
+ * une fois par build : la route des pages, le sitemap et `llms.txt` partagent le résultat.
62
+ */
50
63
  export declare function zefStaticPaths(config: ZefConfig): Promise<{
51
64
  params: {
52
65
  path: string | undefined;
53
66
  };
54
67
  props: RouteProps;
55
68
  }[]>;
69
+ /**
70
+ * Index du site pour le sitemap et `llms.txt` : une entrée par page et par élément avec page,
71
+ * dans chaque langue (titre, description, indexation, date de publication, langues).
72
+ */
73
+ export declare function zefSiteIndex(config: ZefConfig): Promise<{
74
+ url: string;
75
+ locale: string;
76
+ title: string;
77
+ description: string | null;
78
+ noindex: boolean;
79
+ canonical: string | null;
80
+ updatedAt: string | null;
81
+ alternates: Record<string, string>;
82
+ collection: string | null;
83
+ }[]>;
56
84
  /**
57
85
  * Origines du CMS autorisées à afficher l'aperçu : le sous-domaine du client (`ZEF_URL`) et
58
86
  * l'espace agence (`admin.` du même domaine), d'où l'agence gère ses clients.
package/dist/helpers.js CHANGED
@@ -43,13 +43,41 @@ export async function zefLayoutProps(opts) {
43
43
  };
44
44
  }
45
45
  const toParam = (url) => url.replace(/^\/+|\/+$/g, "") || undefined;
46
- /** Toutes les routes du site : pages publiées, puis éléments des collections avec page. */
47
- export async function zefStaticPaths(config) {
46
+ let staticPaths;
47
+ /**
48
+ * Toutes les routes du site : pages publiées, puis éléments des collections avec page. Calculé
49
+ * une fois par build : la route des pages, le sitemap et `llms.txt` partagent le résultat.
50
+ */
51
+ export function zefStaticPaths(config) {
52
+ staticPaths ??= buildStaticPaths(config).catch((error) => {
53
+ staticPaths = undefined;
54
+ throw error;
55
+ });
56
+ return staticPaths;
57
+ }
58
+ async function buildStaticPaths(config) {
48
59
  const api = getClient();
49
60
  const site = await api.site();
50
61
  const paths = [];
51
62
  const layouts = new Map(await Promise.all(site.locales.map(async (l) => [l, await api.layout({ locale: l })])));
52
- for (const entry of await api.pages()) {
63
+ 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
+ };
80
+ for (const entry of entries) {
53
81
  for (const [locale, url] of Object.entries(entry.urls)) {
54
82
  const page = await api.page(url);
55
83
  // Contenu réservé : rendu à la demande avec la session du compte (`/[...zefReserved]`).
@@ -65,6 +93,8 @@ export async function zefStaticPaths(config) {
65
93
  title: page.seo.title ?? page.title,
66
94
  seo: page.seo,
67
95
  alternates: page.alternates,
96
+ url,
97
+ breadcrumbs: trail(entry.id, locale),
68
98
  header: layouts.get(locale)?.header ?? null,
69
99
  footer: layouts.get(locale)?.footer ?? null,
70
100
  },
@@ -87,6 +117,9 @@ export async function zefStaticPaths(config) {
87
117
  if (url)
88
118
  alternates[other.locale] = url;
89
119
  }
120
+ // Fil d'Ariane : accueil, page de la liste si elle existe (`/vins`), puis l'élément.
121
+ const listing = entries.find((e) => e.urls[locale] === def.page.path || e.urls[locale] === `/${locale}${def.page.path}`) ?? homeOf(locale);
122
+ const title = String(item[def.titleField] ?? "");
90
123
  paths.push({
91
124
  params: { path: toParam(item.url) },
92
125
  props: {
@@ -95,9 +128,17 @@ export async function zefStaticPaths(config) {
95
128
  item,
96
129
  site,
97
130
  locale,
98
- title: String(item[def.titleField] ?? ""),
99
- seo: { title: null, description: null, noindex: false, canonical: null, image: null },
131
+ title: item.seo?.title ?? title,
132
+ seo: item.seo ?? {
133
+ title: null,
134
+ description: null,
135
+ noindex: false,
136
+ canonical: null,
137
+ image: null,
138
+ },
100
139
  alternates,
140
+ url: item.url,
141
+ breadcrumbs: [...(listing ? trail(listing.id, locale) : []), { title, url: item.url }],
101
142
  header: layouts.get(locale)?.header ?? null,
102
143
  footer: layouts.get(locale)?.footer ?? null,
103
144
  },
@@ -107,6 +148,24 @@ export async function zefStaticPaths(config) {
107
148
  }
108
149
  return paths;
109
150
  }
151
+ /**
152
+ * Index du site pour le sitemap et `llms.txt` : une entrée par page et par élément avec page,
153
+ * dans chaque langue (titre, description, indexation, date de publication, langues).
154
+ */
155
+ export async function zefSiteIndex(config) {
156
+ const paths = await zefStaticPaths(config);
157
+ return paths.map(({ props }) => ({
158
+ url: props.url ?? "/",
159
+ locale: props.locale,
160
+ title: props.title,
161
+ description: props.seo.description,
162
+ noindex: props.seo.noindex,
163
+ canonical: props.seo.canonical,
164
+ updatedAt: props.kind === "page" ? props.page.updatedAt : props.item.updatedAt,
165
+ alternates: props.alternates,
166
+ collection: props.kind === "item" ? props.collection : null,
167
+ }));
168
+ }
110
169
  /**
111
170
  * Origines du CMS autorisées à afficher l'aperçu : le sous-domaine du client (`ZEF_URL`) et
112
171
  * l'espace agence (`admin.` du même domaine), d'où l'agence gère ses clients.
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { AstroIntegration } from "astro";
2
2
  export type { ApiAccount, ApiCart, ApiCheckout, ApiItem, ApiOrder, ApiProduct, ApiProductMedia, ApiProductVariant, } from "@agence-zef/sdk";
3
3
  export * from "./accounts.js";
4
4
  export * from "./helpers.js";
5
+ export * from "./seo.js";
5
6
  export * from "./shop.js";
6
7
  export type ZefOptions = {
7
8
  /** Gabarit des pages du CMS (en-tête, pied, styles du site). */
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { loadZefEnv } from "./env.js";
5
5
  import { getClient } from "./helpers.js";
6
6
  export * from "./accounts.js";
7
7
  export * from "./helpers.js";
8
+ export * from "./seo.js";
8
9
  export * from "./shop.js";
9
10
  const MEDIA_URL = "https://medias.agence-zef.fr";
10
11
  /** Domaines autorisés pour l'optimisation des images distantes (`image.remotePatterns`). */
@@ -96,6 +97,17 @@ export default function zef(options = {}) {
96
97
  entrypoint: "@agence-zef/astro/routes/robots.ts",
97
98
  prerender: true,
98
99
  });
100
+ // Référencement prêt à l'emploi (§ 5.13) : sitemap et présentation pour les IA.
101
+ injectRoute({
102
+ pattern: "/sitemap.xml",
103
+ entrypoint: "@agence-zef/astro/routes/sitemap.ts",
104
+ prerender: true,
105
+ });
106
+ injectRoute({
107
+ pattern: "/llms.txt",
108
+ entrypoint: "@agence-zef/astro/routes/llms.ts",
109
+ prerender: true,
110
+ });
99
111
  // Aperçu de l'éditeur : rendu à la demande, donc avec un adaptateur (Vercel) ou en dev.
100
112
  if (command === "dev" || config.adapter) {
101
113
  injectRoute({
package/dist/seo.d.ts ADDED
@@ -0,0 +1,101 @@
1
+ import type { Media } from "@agence-zef/schema";
2
+ import type { ApiItem, ApiSeo, ApiSite } from "@agence-zef/sdk";
3
+ import type { Breadcrumb } from "./helpers.js";
4
+ /** Coordonnées de l'entreprise pour les données structurées (tirées du header et du footer). */
5
+ export type SeoOrganization = {
6
+ /** Type schema.org : `Organization` par défaut, `LocalBusiness`, `Store`, `Restaurant`… */
7
+ type?: string;
8
+ name?: string;
9
+ /** Logo : média de la médiathèque ou adresse. */
10
+ logo?: Media | string | null;
11
+ description?: string | null;
12
+ telephone?: string | null;
13
+ email?: string | null;
14
+ /** Adresse en une chaîne (« 12 rue de Gand, 59000 Lille ») ou détaillée. */
15
+ address?: string | {
16
+ streetAddress?: string;
17
+ postalCode?: string;
18
+ addressLocality?: string;
19
+ addressRegion?: string;
20
+ addressCountry?: string;
21
+ } | null;
22
+ /** Profils publics (réseaux sociaux, fiche Google) : `sameAs`. */
23
+ sameAs?: (string | null | undefined)[];
24
+ /** Horaires au format schema.org (`Mo-Sa 10:00-19:30`). */
25
+ openingHours?: string | string[] | null;
26
+ priceRange?: string | null;
27
+ /** Données en plus, recopiées telles quelles dans l'objet. */
28
+ extra?: Record<string, unknown>;
29
+ };
30
+ export type SeoInput = {
31
+ site: ApiSite;
32
+ locale: string;
33
+ title: string;
34
+ seo: ApiSeo;
35
+ alternates: Record<string, string>;
36
+ /** Adresse de la page (`/la-cave`). */
37
+ url: string;
38
+ /** Origine du site (`https://cave-vivante.fr`) quand `site.url` est vide. */
39
+ origin?: string | null;
40
+ breadcrumbs?: Breadcrumb[];
41
+ item?: ApiItem;
42
+ organization?: SeoOrganization;
43
+ /** Modèle du titre : `%s` = titre de la page (défaut « Page · Site », le nom seul sur l'accueil). */
44
+ titleTemplate?: string;
45
+ /** `og:type` : `website` par défaut, `product` pour un produit. */
46
+ type?: string;
47
+ /** Données structurées en plus (article, évènement, FAQ…). */
48
+ jsonLd?: Record<string, unknown> | Record<string, unknown>[];
49
+ };
50
+ export type SeoTags = {
51
+ title: string;
52
+ meta: {
53
+ name?: string;
54
+ property?: string;
55
+ content: string;
56
+ }[];
57
+ links: {
58
+ rel: string;
59
+ href: string;
60
+ hreflang?: string;
61
+ }[];
62
+ jsonLd: Record<string, unknown>;
63
+ };
64
+ export declare const ogLocale: (locale: string) => string;
65
+ /** Origine du site, sans `/` final. */
66
+ export declare function siteOrigin(site: ApiSite, fallback?: string | null): string;
67
+ /** Adresse absolue d'un chemin du site (une adresse déjà absolue est gardée). */
68
+ export declare function absolute(origin: string, path: string): string;
69
+ /** Balises du `<head>` et données structurées d'une page. */
70
+ export declare function seoTags(input: SeoInput): SeoTags;
71
+ /** JSON-LD sûr dans un `<script>` (pas de `</script>` ni de `<!--` dans les textes). */
72
+ export declare const jsonLdScript: (data: unknown) => string;
73
+ export type IndexEntry = {
74
+ url: string;
75
+ locale: string;
76
+ title: string;
77
+ description: string | null;
78
+ noindex: boolean;
79
+ canonical: string | null;
80
+ updatedAt: string | null;
81
+ alternates: Record<string, string>;
82
+ /** `null` pour une page, nom de la collection pour un élément. */
83
+ collection: string | null;
84
+ };
85
+ /** Pages et éléments indexables : ni `noindex`, ni canonique vers une autre adresse. */
86
+ export declare const indexable: (e: IndexEntry, origin: string) => boolean;
87
+ /** `sitemap.xml` avec les versions de chaque langue (`xhtml:link`) et la date de publication. */
88
+ export declare function sitemapXml(entries: IndexEntry[], origin: string): string;
89
+ /**
90
+ * `robots.txt` : tout bloqué tant que le site est en développement ; en ligne, moteurs et IA
91
+ * peuvent tout lire (sauf les routes techniques), avec le sitemap et `llms.txt`.
92
+ */
93
+ export declare function robotsTxt(site: ApiSite, origin: string): string;
94
+ /**
95
+ * `llms.txt` (format llmstxt.org) : présentation du site pour les assistants IA, puis ses pages
96
+ * et les éléments de chaque collection, avec leur description, dans la langue par défaut.
97
+ */
98
+ export declare function llmsTxt(site: ApiSite, entries: IndexEntry[], origin: string, collections?: {
99
+ name: string;
100
+ label: string;
101
+ }[]): string;
package/dist/seo.js ADDED
@@ -0,0 +1,255 @@
1
+ /** Locale Open Graph (`fr` → `fr_FR`). */
2
+ const OG_LOCALES = {
3
+ fr: "fr_FR",
4
+ en: "en_GB",
5
+ de: "de_DE",
6
+ es: "es_ES",
7
+ it: "it_IT",
8
+ nl: "nl_NL",
9
+ pt: "pt_PT",
10
+ };
11
+ export const ogLocale = (locale) => OG_LOCALES[locale] ??
12
+ (locale.includes("-") ? locale.replace("-", "_") : `${locale}_${locale.toUpperCase()}`);
13
+ /** Origine du site, sans `/` final. */
14
+ export function siteOrigin(site, fallback) {
15
+ return (site.url ?? fallback ?? "").replace(/\/+$/, "");
16
+ }
17
+ /** Adresse absolue d'un chemin du site (une adresse déjà absolue est gardée). */
18
+ export function absolute(origin, path) {
19
+ if (/^https?:\/\//.test(path))
20
+ return path;
21
+ return `${origin}${path.startsWith("/") ? path : `/${path}`}`;
22
+ }
23
+ const mediaUrl = (m) => (typeof m === "string" ? m : m?.url);
24
+ const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && !v.length)));
25
+ /** Balises du `<head>` et données structurées d'une page. */
26
+ export function seoTags(input) {
27
+ const { site, locale, seo, alternates } = input;
28
+ const origin = siteOrigin(site, input.origin);
29
+ const path = input.url || "/";
30
+ const url = absolute(origin, path);
31
+ const isHome = path === "/" || path === `/${locale}` || path === `/${locale}/`;
32
+ const pageTitle = seo.title ?? input.title;
33
+ const template = input.titleTemplate ?? `%s · ${site.name}`;
34
+ const title = isHome || pageTitle === site.name || !pageTitle
35
+ ? pageTitle || site.name
36
+ : template.replace("%s", pageTitle);
37
+ const description = seo.description ?? null;
38
+ const image = seo.image ?? site.shareImage ?? null;
39
+ const noindex = seo.noindex || site.indexable === false;
40
+ const canonical = seo.canonical ? absolute(origin, seo.canonical) : url;
41
+ const product = input.item?.product;
42
+ const type = input.type ?? (product ? "product" : "website");
43
+ const meta = [];
44
+ const add = (key, k, content) => {
45
+ if (content)
46
+ meta.push({ [key]: k, content });
47
+ };
48
+ add("name", "description", description);
49
+ add("name", "robots", noindex ? "noindex, nofollow" : "index, follow, max-image-preview:large, max-snippet:-1");
50
+ add("property", "og:type", type);
51
+ add("property", "og:site_name", site.name);
52
+ add("property", "og:title", pageTitle || site.name);
53
+ add("property", "og:description", description);
54
+ add("property", "og:url", canonical);
55
+ add("property", "og:locale", ogLocale(locale));
56
+ for (const other of Object.keys(alternates)) {
57
+ if (other !== locale)
58
+ add("property", "og:locale:alternate", ogLocale(other));
59
+ }
60
+ if (image) {
61
+ add("property", "og:image", absolute(origin, image.url));
62
+ add("property", "og:image:alt", image.alt);
63
+ if (image.width)
64
+ add("property", "og:image:width", String(image.width));
65
+ if (image.height)
66
+ add("property", "og:image:height", String(image.height));
67
+ if (image.mime)
68
+ add("property", "og:image:type", image.mime);
69
+ }
70
+ add("name", "twitter:card", image ? "summary_large_image" : "summary");
71
+ add("name", "twitter:title", pageTitle || site.name);
72
+ add("name", "twitter:description", description);
73
+ if (image)
74
+ add("name", "twitter:image", absolute(origin, image.url));
75
+ const links = [{ rel: "canonical", href: canonical }];
76
+ const langs = Object.entries(alternates);
77
+ if (langs.length > 1) {
78
+ for (const [lang, href] of langs)
79
+ links.push({ rel: "alternate", hreflang: lang, href: absolute(origin, href) });
80
+ const fallback = alternates[site.defaultLocale];
81
+ if (fallback)
82
+ links.push({ rel: "alternate", hreflang: "x-default", href: absolute(origin, fallback) });
83
+ }
84
+ if (!noindex)
85
+ links.push({ rel: "sitemap", href: absolute(origin, "/sitemap.xml") });
86
+ return {
87
+ title,
88
+ meta,
89
+ links,
90
+ jsonLd: structuredData(input, { origin, url: canonical, title: pageTitle, description, image }),
91
+ };
92
+ }
93
+ /** Graphe JSON-LD de la page (`@graph`), entités reliées par leurs `@id`. */
94
+ function structuredData(input, page) {
95
+ const { site, locale, organization: org = {}, breadcrumbs = [], item } = input;
96
+ const { origin, url } = page;
97
+ const orgId = `${origin}/#organization`;
98
+ const websiteId = `${origin}/#website`;
99
+ const logo = mediaUrl(org.logo) ?? site.logo ?? undefined;
100
+ const address = typeof org.address === "string"
101
+ ? org.address.trim()
102
+ ? { "@type": "PostalAddress", streetAddress: org.address.trim() }
103
+ : undefined
104
+ : org.address && Object.keys(compact(org.address)).length
105
+ ? { "@type": "PostalAddress", ...compact(org.address) }
106
+ : undefined;
107
+ const graph = [
108
+ compact({
109
+ "@type": "WebSite",
110
+ "@id": websiteId,
111
+ url: `${origin}/`,
112
+ name: site.name,
113
+ inLanguage: site.locales,
114
+ publisher: { "@id": orgId },
115
+ }),
116
+ compact({
117
+ "@type": org.type ?? "Organization",
118
+ "@id": orgId,
119
+ name: org.name ?? site.name,
120
+ url: `${origin}/`,
121
+ logo: logo ? absolute(origin, logo) : undefined,
122
+ image: logo ? absolute(origin, logo) : undefined,
123
+ description: org.description,
124
+ telephone: org.telephone,
125
+ email: org.email,
126
+ address,
127
+ sameAs: (org.sameAs ?? []).filter((s) => Boolean(s)),
128
+ openingHours: org.openingHours,
129
+ priceRange: org.priceRange,
130
+ ...org.extra,
131
+ }),
132
+ compact({
133
+ "@type": "WebPage",
134
+ "@id": `${url}#webpage`,
135
+ url,
136
+ name: page.title || site.name,
137
+ description: page.description,
138
+ inLanguage: locale,
139
+ isPartOf: { "@id": websiteId },
140
+ about: { "@id": orgId },
141
+ primaryImageOfPage: page.image
142
+ ? { "@type": "ImageObject", url: absolute(origin, page.image.url) }
143
+ : undefined,
144
+ breadcrumb: breadcrumbs.length > 1 ? { "@id": `${url}#breadcrumb` } : undefined,
145
+ dateModified: item?.updatedAt ?? undefined,
146
+ }),
147
+ ];
148
+ if (breadcrumbs.length > 1) {
149
+ graph.push({
150
+ "@type": "BreadcrumbList",
151
+ "@id": `${url}#breadcrumb`,
152
+ itemListElement: breadcrumbs.map((b, i) => ({
153
+ "@type": "ListItem",
154
+ position: i + 1,
155
+ name: b.title,
156
+ item: absolute(origin, b.url),
157
+ })),
158
+ });
159
+ }
160
+ const product = item?.product;
161
+ if (product) {
162
+ const images = product.media
163
+ .filter((m) => m.mime.startsWith("image/"))
164
+ .map((m) => absolute(origin, m.url));
165
+ const currency = "EUR";
166
+ const offers = product.variants.length
167
+ ? product.variants.map((v) => compact({
168
+ "@type": "Offer",
169
+ url,
170
+ sku: v.sku ?? undefined,
171
+ price: (v.price / 100).toFixed(2),
172
+ priceCurrency: currency,
173
+ availability: `https://schema.org/${v.available ? "InStock" : "OutOfStock"}`,
174
+ }))
175
+ : undefined;
176
+ graph.push(compact({
177
+ "@type": "Product",
178
+ "@id": `${url}#product`,
179
+ name: page.title,
180
+ description: page.description,
181
+ image: images.length ? images : page.image ? [absolute(origin, page.image.url)] : undefined,
182
+ sku: product.variants.length === 1 ? (product.variants[0].sku ?? undefined) : undefined,
183
+ brand: { "@type": "Brand", name: org.name ?? site.name },
184
+ category: product.categories.map((c) => c.name).join(", ") || undefined,
185
+ offers: offers && offers.length === 1 ? offers[0] : offers,
186
+ }));
187
+ }
188
+ const extra = input.jsonLd ? (Array.isArray(input.jsonLd) ? input.jsonLd : [input.jsonLd]) : [];
189
+ graph.push(...extra);
190
+ return { "@context": "https://schema.org", "@graph": graph };
191
+ }
192
+ /** JSON-LD sûr dans un `<script>` (pas de `</script>` ni de `<!--` dans les textes). */
193
+ export const jsonLdScript = (data) => JSON.stringify(data).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
194
+ const xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
195
+ /** Pages et éléments indexables : ni `noindex`, ni canonique vers une autre adresse. */
196
+ export const indexable = (e, origin) => !e.noindex && (!e.canonical || absolute(origin, e.canonical) === absolute(origin, e.url));
197
+ /** `sitemap.xml` avec les versions de chaque langue (`xhtml:link`) et la date de publication. */
198
+ export function sitemapXml(entries, origin) {
199
+ const rows = entries
200
+ .filter((e) => indexable(e, origin))
201
+ .map((e) => {
202
+ const alternates = Object.entries(e.alternates);
203
+ const links = alternates.length > 1
204
+ ? alternates
205
+ .map(([lang, href]) => `\n <xhtml:link rel="alternate" hreflang="${xml(lang)}" href="${xml(absolute(origin, href))}"/>`)
206
+ .join("")
207
+ : "";
208
+ const lastmod = e.updatedAt ? `\n <lastmod>${e.updatedAt.slice(0, 10)}</lastmod>` : "";
209
+ return ` <url>\n <loc>${xml(absolute(origin, e.url))}</loc>${lastmod}${links}\n </url>`;
210
+ });
211
+ return `<?xml version="1.0" encoding="UTF-8"?>
212
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
213
+ ${rows.join("\n")}
214
+ </urlset>
215
+ `;
216
+ }
217
+ /**
218
+ * `robots.txt` : tout bloqué tant que le site est en développement ; en ligne, moteurs et IA
219
+ * peuvent tout lire (sauf les routes techniques), avec le sitemap et `llms.txt`.
220
+ */
221
+ export function robotsTxt(site, origin) {
222
+ if (!site.indexable)
223
+ return "User-agent: *\nDisallow: /\n";
224
+ const lines = ["User-agent: *", "Allow: /", "Disallow: /__zef/", ""];
225
+ if (origin)
226
+ lines.push(`Sitemap: ${origin}/sitemap.xml`, "");
227
+ return lines.join("\n");
228
+ }
229
+ /**
230
+ * `llms.txt` (format llmstxt.org) : présentation du site pour les assistants IA, puis ses pages
231
+ * et les éléments de chaque collection, avec leur description, dans la langue par défaut.
232
+ */
233
+ export function llmsTxt(site, entries, origin, collections = []) {
234
+ const visible = entries.filter((e) => e.locale === site.defaultLocale && indexable(e, origin));
235
+ const home = visible.find((e) => e.collection === null && (e.url === "/" || e.url === ""));
236
+ const line = (e) => `- [${e.title || e.url}](${absolute(origin, e.url)})${e.description ? `: ${e.description}` : ""}`;
237
+ const out = [`# ${site.name}`, ""];
238
+ if (home?.description)
239
+ out.push(`> ${home.description}`, "");
240
+ if (origin)
241
+ out.push(`Site : ${origin}/`, "");
242
+ const pages = visible.filter((e) => e.collection === null);
243
+ if (pages.length)
244
+ out.push("## Pages", "", ...pages.map(line), "");
245
+ for (const c of collections) {
246
+ const items = visible.filter((e) => e.collection === c.name);
247
+ if (items.length)
248
+ out.push(`## ${c.label}`, "", ...items.map(line), "");
249
+ }
250
+ const others = site.locales.filter((l) => l !== site.defaultLocale);
251
+ if (others.length) {
252
+ out.push("## Autres langues", "", ...others.map((l) => `- ${l.toUpperCase()} : ${origin}/${l}`), "");
253
+ }
254
+ return out.join("\n");
255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agence-zef/astro",
3
- "version": "0.3.1",
3
+ "version": "0.4.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",
@@ -13,6 +13,9 @@
13
13
  "./routes/page.astro": "./components/routes/page.astro",
14
14
  "./routes/reserved.astro": "./components/routes/reserved.astro",
15
15
  "./routes/robots.ts": "./components/routes/robots.ts",
16
+ "./routes/sitemap.ts": "./components/routes/sitemap.ts",
17
+ "./routes/llms.ts": "./components/routes/llms.ts",
18
+ "./Seo.astro": "./components/Seo.astro",
16
19
  "./Button.astro": "./components/Button.astro",
17
20
  "./Image.astro": "./components/Image.astro",
18
21
  "./Video.astro": "./components/Video.astro",
@@ -64,8 +67,8 @@
64
67
  "@types/node": "^24.0.0"
65
68
  },
66
69
  "dependencies": {
67
- "@agence-zef/schema": "0.3.0",
68
- "@agence-zef/sdk": "0.3.0"
70
+ "@agence-zef/schema": "0.4.0",
71
+ "@agence-zef/sdk": "0.4.1"
69
72
  },
70
73
  "homepage": "https://agence-zef.fr",
71
74
  "scripts": {