@agence-zef/astro 0.3.1 → 0.5.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.
- package/components/Accordion.astro +61 -0
- package/components/AccordionItem.astro +23 -0
- package/components/Checkbox.astro +20 -0
- package/components/Dialog.astro +72 -0
- package/components/Field.astro +27 -0
- package/components/Image.astro +21 -5
- package/components/Input.astro +49 -0
- package/components/Popover.astro +21 -0
- package/components/Select.astro +55 -0
- package/components/Seo.astro +24 -0
- package/components/routes/llms.ts +24 -0
- package/components/routes/page.astro +13 -2
- package/components/routes/robots.ts +6 -6
- package/components/routes/sitemap.ts +16 -0
- package/dist/helpers.d.ts +29 -1
- package/dist/helpers.js +64 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +18 -0
- package/dist/seo.d.ts +103 -0
- package/dist/seo.js +258 -0
- package/dist/ui.d.ts +7 -0
- package/dist/ui.js +33 -0
- package/package.json +22 -5
- package/styles.css +468 -0
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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:
|
|
99
|
-
seo:
|
|
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`). */
|
|
@@ -83,6 +84,12 @@ export default function zef(options = {}) {
|
|
|
83
84
|
]),
|
|
84
85
|
},
|
|
85
86
|
});
|
|
87
|
+
// Performance : le CSS (quelques Ko par page) est écrit dans la page plutôt que chargé à
|
|
88
|
+
// part, ce qui retire une requête bloquante du premier affichage. Un site peut choisir
|
|
89
|
+
// « never » dans son `astro.config`.
|
|
90
|
+
if (config.build.inlineStylesheets === "auto") {
|
|
91
|
+
updateConfig({ build: { inlineStylesheets: "always" } });
|
|
92
|
+
}
|
|
86
93
|
addWatchFile(join(root, ".env"));
|
|
87
94
|
const layout = join(root, options.layout ?? "src/layouts/Page.astro");
|
|
88
95
|
const configFile = join(root, "zef.config.ts");
|
|
@@ -96,6 +103,17 @@ export default function zef(options = {}) {
|
|
|
96
103
|
entrypoint: "@agence-zef/astro/routes/robots.ts",
|
|
97
104
|
prerender: true,
|
|
98
105
|
});
|
|
106
|
+
// Référencement prêt à l'emploi (§ 5.13) : sitemap et présentation pour les IA.
|
|
107
|
+
injectRoute({
|
|
108
|
+
pattern: "/sitemap.xml",
|
|
109
|
+
entrypoint: "@agence-zef/astro/routes/sitemap.ts",
|
|
110
|
+
prerender: true,
|
|
111
|
+
});
|
|
112
|
+
injectRoute({
|
|
113
|
+
pattern: "/llms.txt",
|
|
114
|
+
entrypoint: "@agence-zef/astro/routes/llms.ts",
|
|
115
|
+
prerender: true,
|
|
116
|
+
});
|
|
99
117
|
// Aperçu de l'éditeur : rendu à la demande, donc avec un adaptateur (Vercel) ou en dev.
|
|
100
118
|
if (command === "dev" || config.adapter) {
|
|
101
119
|
injectRoute({
|
package/dist/seo.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
/** Icône de l'onglet (`/favicon.svg`) ; à défaut, le logo du client dans le CMS. */
|
|
50
|
+
icon?: string | null;
|
|
51
|
+
};
|
|
52
|
+
export type SeoTags = {
|
|
53
|
+
title: string;
|
|
54
|
+
meta: {
|
|
55
|
+
name?: string;
|
|
56
|
+
property?: string;
|
|
57
|
+
content: string;
|
|
58
|
+
}[];
|
|
59
|
+
links: {
|
|
60
|
+
rel: string;
|
|
61
|
+
href: string;
|
|
62
|
+
hreflang?: string;
|
|
63
|
+
}[];
|
|
64
|
+
jsonLd: Record<string, unknown>;
|
|
65
|
+
};
|
|
66
|
+
export declare const ogLocale: (locale: string) => string;
|
|
67
|
+
/** Origine du site, sans `/` final. */
|
|
68
|
+
export declare function siteOrigin(site: ApiSite, fallback?: string | null): string;
|
|
69
|
+
/** Adresse absolue d'un chemin du site (une adresse déjà absolue est gardée). */
|
|
70
|
+
export declare function absolute(origin: string, path: string): string;
|
|
71
|
+
/** Balises du `<head>` et données structurées d'une page. */
|
|
72
|
+
export declare function seoTags(input: SeoInput): SeoTags;
|
|
73
|
+
/** JSON-LD sûr dans un `<script>` (pas de `</script>` ni de `<!--` dans les textes). */
|
|
74
|
+
export declare const jsonLdScript: (data: unknown) => string;
|
|
75
|
+
export type IndexEntry = {
|
|
76
|
+
url: string;
|
|
77
|
+
locale: string;
|
|
78
|
+
title: string;
|
|
79
|
+
description: string | null;
|
|
80
|
+
noindex: boolean;
|
|
81
|
+
canonical: string | null;
|
|
82
|
+
updatedAt: string | null;
|
|
83
|
+
alternates: Record<string, string>;
|
|
84
|
+
/** `null` pour une page, nom de la collection pour un élément. */
|
|
85
|
+
collection: string | null;
|
|
86
|
+
};
|
|
87
|
+
/** Pages et éléments indexables : ni `noindex`, ni canonique vers une autre adresse. */
|
|
88
|
+
export declare const indexable: (e: IndexEntry, origin: string) => boolean;
|
|
89
|
+
/** `sitemap.xml` avec les versions de chaque langue (`xhtml:link`) et la date de publication. */
|
|
90
|
+
export declare function sitemapXml(entries: IndexEntry[], origin: string): string;
|
|
91
|
+
/**
|
|
92
|
+
* `robots.txt` : tout bloqué tant que le site est en développement ; en ligne, moteurs et IA
|
|
93
|
+
* peuvent tout lire (sauf les routes techniques), avec le sitemap et `llms.txt`.
|
|
94
|
+
*/
|
|
95
|
+
export declare function robotsTxt(site: ApiSite, origin: string): string;
|
|
96
|
+
/**
|
|
97
|
+
* `llms.txt` (format llmstxt.org) : présentation du site pour les assistants IA, puis ses pages
|
|
98
|
+
* et les éléments de chaque collection, avec leur description, dans la langue par défaut.
|
|
99
|
+
*/
|
|
100
|
+
export declare function llmsTxt(site: ApiSite, entries: IndexEntry[], origin: string, collections?: {
|
|
101
|
+
name: string;
|
|
102
|
+
label: string;
|
|
103
|
+
}[]): string;
|
package/dist/seo.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
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 icon = input.icon ?? site.logo;
|
|
77
|
+
if (icon)
|
|
78
|
+
links.push({ rel: "icon", href: icon });
|
|
79
|
+
const langs = Object.entries(alternates);
|
|
80
|
+
if (langs.length > 1) {
|
|
81
|
+
for (const [lang, href] of langs)
|
|
82
|
+
links.push({ rel: "alternate", hreflang: lang, href: absolute(origin, href) });
|
|
83
|
+
const fallback = alternates[site.defaultLocale];
|
|
84
|
+
if (fallback)
|
|
85
|
+
links.push({ rel: "alternate", hreflang: "x-default", href: absolute(origin, fallback) });
|
|
86
|
+
}
|
|
87
|
+
if (!noindex)
|
|
88
|
+
links.push({ rel: "sitemap", href: absolute(origin, "/sitemap.xml") });
|
|
89
|
+
return {
|
|
90
|
+
title,
|
|
91
|
+
meta,
|
|
92
|
+
links,
|
|
93
|
+
jsonLd: structuredData(input, { origin, url: canonical, title: pageTitle, description, image }),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Graphe JSON-LD de la page (`@graph`), entités reliées par leurs `@id`. */
|
|
97
|
+
function structuredData(input, page) {
|
|
98
|
+
const { site, locale, organization: org = {}, breadcrumbs = [], item } = input;
|
|
99
|
+
const { origin, url } = page;
|
|
100
|
+
const orgId = `${origin}/#organization`;
|
|
101
|
+
const websiteId = `${origin}/#website`;
|
|
102
|
+
const logo = mediaUrl(org.logo) ?? site.logo ?? undefined;
|
|
103
|
+
const address = typeof org.address === "string"
|
|
104
|
+
? org.address.trim()
|
|
105
|
+
? { "@type": "PostalAddress", streetAddress: org.address.trim() }
|
|
106
|
+
: undefined
|
|
107
|
+
: org.address && Object.keys(compact(org.address)).length
|
|
108
|
+
? { "@type": "PostalAddress", ...compact(org.address) }
|
|
109
|
+
: undefined;
|
|
110
|
+
const graph = [
|
|
111
|
+
compact({
|
|
112
|
+
"@type": "WebSite",
|
|
113
|
+
"@id": websiteId,
|
|
114
|
+
url: `${origin}/`,
|
|
115
|
+
name: site.name,
|
|
116
|
+
inLanguage: site.locales,
|
|
117
|
+
publisher: { "@id": orgId },
|
|
118
|
+
}),
|
|
119
|
+
compact({
|
|
120
|
+
"@type": org.type ?? "Organization",
|
|
121
|
+
"@id": orgId,
|
|
122
|
+
name: org.name ?? site.name,
|
|
123
|
+
url: `${origin}/`,
|
|
124
|
+
logo: logo ? absolute(origin, logo) : undefined,
|
|
125
|
+
image: logo ? absolute(origin, logo) : undefined,
|
|
126
|
+
description: org.description,
|
|
127
|
+
telephone: org.telephone,
|
|
128
|
+
email: org.email,
|
|
129
|
+
address,
|
|
130
|
+
sameAs: (org.sameAs ?? []).filter((s) => Boolean(s)),
|
|
131
|
+
openingHours: org.openingHours,
|
|
132
|
+
priceRange: org.priceRange,
|
|
133
|
+
...org.extra,
|
|
134
|
+
}),
|
|
135
|
+
compact({
|
|
136
|
+
"@type": "WebPage",
|
|
137
|
+
"@id": `${url}#webpage`,
|
|
138
|
+
url,
|
|
139
|
+
name: page.title || site.name,
|
|
140
|
+
description: page.description,
|
|
141
|
+
inLanguage: locale,
|
|
142
|
+
isPartOf: { "@id": websiteId },
|
|
143
|
+
about: { "@id": orgId },
|
|
144
|
+
primaryImageOfPage: page.image
|
|
145
|
+
? { "@type": "ImageObject", url: absolute(origin, page.image.url) }
|
|
146
|
+
: undefined,
|
|
147
|
+
breadcrumb: breadcrumbs.length > 1 ? { "@id": `${url}#breadcrumb` } : undefined,
|
|
148
|
+
dateModified: item?.updatedAt ?? undefined,
|
|
149
|
+
}),
|
|
150
|
+
];
|
|
151
|
+
if (breadcrumbs.length > 1) {
|
|
152
|
+
graph.push({
|
|
153
|
+
"@type": "BreadcrumbList",
|
|
154
|
+
"@id": `${url}#breadcrumb`,
|
|
155
|
+
itemListElement: breadcrumbs.map((b, i) => ({
|
|
156
|
+
"@type": "ListItem",
|
|
157
|
+
position: i + 1,
|
|
158
|
+
name: b.title,
|
|
159
|
+
item: absolute(origin, b.url),
|
|
160
|
+
})),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const product = item?.product;
|
|
164
|
+
if (product) {
|
|
165
|
+
const images = product.media
|
|
166
|
+
.filter((m) => m.mime.startsWith("image/"))
|
|
167
|
+
.map((m) => absolute(origin, m.url));
|
|
168
|
+
const currency = "EUR";
|
|
169
|
+
const offers = product.variants.length
|
|
170
|
+
? product.variants.map((v) => compact({
|
|
171
|
+
"@type": "Offer",
|
|
172
|
+
url,
|
|
173
|
+
sku: v.sku ?? undefined,
|
|
174
|
+
price: (v.price / 100).toFixed(2),
|
|
175
|
+
priceCurrency: currency,
|
|
176
|
+
availability: `https://schema.org/${v.available ? "InStock" : "OutOfStock"}`,
|
|
177
|
+
}))
|
|
178
|
+
: undefined;
|
|
179
|
+
graph.push(compact({
|
|
180
|
+
"@type": "Product",
|
|
181
|
+
"@id": `${url}#product`,
|
|
182
|
+
name: page.title,
|
|
183
|
+
description: page.description,
|
|
184
|
+
image: images.length ? images : page.image ? [absolute(origin, page.image.url)] : undefined,
|
|
185
|
+
sku: product.variants.length === 1 ? (product.variants[0].sku ?? undefined) : undefined,
|
|
186
|
+
brand: { "@type": "Brand", name: org.name ?? site.name },
|
|
187
|
+
category: product.categories.map((c) => c.name).join(", ") || undefined,
|
|
188
|
+
offers: offers && offers.length === 1 ? offers[0] : offers,
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
const extra = input.jsonLd ? (Array.isArray(input.jsonLd) ? input.jsonLd : [input.jsonLd]) : [];
|
|
192
|
+
graph.push(...extra);
|
|
193
|
+
return { "@context": "https://schema.org", "@graph": graph };
|
|
194
|
+
}
|
|
195
|
+
/** JSON-LD sûr dans un `<script>` (pas de `</script>` ni de `<!--` dans les textes). */
|
|
196
|
+
export const jsonLdScript = (data) => JSON.stringify(data).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
197
|
+
const xml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
198
|
+
/** Pages et éléments indexables : ni `noindex`, ni canonique vers une autre adresse. */
|
|
199
|
+
export const indexable = (e, origin) => !e.noindex && (!e.canonical || absolute(origin, e.canonical) === absolute(origin, e.url));
|
|
200
|
+
/** `sitemap.xml` avec les versions de chaque langue (`xhtml:link`) et la date de publication. */
|
|
201
|
+
export function sitemapXml(entries, origin) {
|
|
202
|
+
const rows = entries
|
|
203
|
+
.filter((e) => indexable(e, origin))
|
|
204
|
+
.map((e) => {
|
|
205
|
+
const alternates = Object.entries(e.alternates);
|
|
206
|
+
const links = alternates.length > 1
|
|
207
|
+
? alternates
|
|
208
|
+
.map(([lang, href]) => `\n <xhtml:link rel="alternate" hreflang="${xml(lang)}" href="${xml(absolute(origin, href))}"/>`)
|
|
209
|
+
.join("")
|
|
210
|
+
: "";
|
|
211
|
+
const lastmod = e.updatedAt ? `\n <lastmod>${e.updatedAt.slice(0, 10)}</lastmod>` : "";
|
|
212
|
+
return ` <url>\n <loc>${xml(absolute(origin, e.url))}</loc>${lastmod}${links}\n </url>`;
|
|
213
|
+
});
|
|
214
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
215
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
|
216
|
+
${rows.join("\n")}
|
|
217
|
+
</urlset>
|
|
218
|
+
`;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* `robots.txt` : tout bloqué tant que le site est en développement ; en ligne, moteurs et IA
|
|
222
|
+
* peuvent tout lire (sauf les routes techniques), avec le sitemap et `llms.txt`.
|
|
223
|
+
*/
|
|
224
|
+
export function robotsTxt(site, origin) {
|
|
225
|
+
if (!site.indexable)
|
|
226
|
+
return "User-agent: *\nDisallow: /\n";
|
|
227
|
+
const lines = ["User-agent: *", "Allow: /", "Disallow: /__zef/", ""];
|
|
228
|
+
if (origin)
|
|
229
|
+
lines.push(`Sitemap: ${origin}/sitemap.xml`, "");
|
|
230
|
+
return lines.join("\n");
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* `llms.txt` (format llmstxt.org) : présentation du site pour les assistants IA, puis ses pages
|
|
234
|
+
* et les éléments de chaque collection, avec leur description, dans la langue par défaut.
|
|
235
|
+
*/
|
|
236
|
+
export function llmsTxt(site, entries, origin, collections = []) {
|
|
237
|
+
const visible = entries.filter((e) => e.locale === site.defaultLocale && indexable(e, origin));
|
|
238
|
+
const home = visible.find((e) => e.collection === null && (e.url === "/" || e.url === ""));
|
|
239
|
+
const line = (e) => `- [${e.title || e.url}](${absolute(origin, e.url)})${e.description ? `: ${e.description}` : ""}`;
|
|
240
|
+
const out = [`# ${site.name}`, ""];
|
|
241
|
+
if (home?.description)
|
|
242
|
+
out.push(`> ${home.description}`, "");
|
|
243
|
+
if (origin)
|
|
244
|
+
out.push(`Site : ${origin}/`, "");
|
|
245
|
+
const pages = visible.filter((e) => e.collection === null);
|
|
246
|
+
if (pages.length)
|
|
247
|
+
out.push("## Pages", "", ...pages.map(line), "");
|
|
248
|
+
for (const c of collections) {
|
|
249
|
+
const items = visible.filter((e) => e.collection === c.name);
|
|
250
|
+
if (items.length)
|
|
251
|
+
out.push(`## ${c.label}`, "", ...items.map(line), "");
|
|
252
|
+
}
|
|
253
|
+
const others = site.locales.filter((l) => l !== site.defaultLocale);
|
|
254
|
+
if (others.length) {
|
|
255
|
+
out.push("## Autres langues", "", ...others.map((l) => `- ${l.toUpperCase()} : ${origin}/${l}`), "");
|
|
256
|
+
}
|
|
257
|
+
return out.join("\n");
|
|
258
|
+
}
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Ouvre une fenêtre `<Dialog>` par son identifiant. */
|
|
2
|
+
export declare function openDialog(id: string): void;
|
|
3
|
+
/**
|
|
4
|
+
* Ferme une fenêtre en laissant jouer son animation de sortie (`[data-closing]`), puis la
|
|
5
|
+
* ferme vraiment ; tout de suite si le visiteur réduit les animations ou sans animation CSS.
|
|
6
|
+
*/
|
|
7
|
+
export declare function closeDialog(target: HTMLDialogElement | string): void;
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Comportements des composants d'interface (`<Dialog>`, `<Accordion>`) partagés par leurs
|
|
3
|
+
* scripts : exportés pour qu'un site les déclenche lui-même.
|
|
4
|
+
*/
|
|
5
|
+
const reduced = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
6
|
+
/** Ouvre une fenêtre `<Dialog>` par son identifiant. */
|
|
7
|
+
export function openDialog(id) {
|
|
8
|
+
const dialog = document.getElementById(id);
|
|
9
|
+
if (dialog instanceof HTMLDialogElement && !dialog.open)
|
|
10
|
+
dialog.showModal();
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Ferme une fenêtre en laissant jouer son animation de sortie (`[data-closing]`), puis la
|
|
14
|
+
* ferme vraiment ; tout de suite si le visiteur réduit les animations ou sans animation CSS.
|
|
15
|
+
*/
|
|
16
|
+
export function closeDialog(target) {
|
|
17
|
+
const dialog = typeof target === "string" ? document.getElementById(target) : target;
|
|
18
|
+
if (!(dialog instanceof HTMLDialogElement) || !dialog.open || dialog.hasAttribute("data-closing"))
|
|
19
|
+
return;
|
|
20
|
+
const finish = () => {
|
|
21
|
+
dialog.removeAttribute("data-closing");
|
|
22
|
+
dialog.close();
|
|
23
|
+
};
|
|
24
|
+
if (reduced())
|
|
25
|
+
return finish();
|
|
26
|
+
dialog.setAttribute("data-closing", "");
|
|
27
|
+
const animated = getComputedStyle(dialog).animationName !== "none";
|
|
28
|
+
if (!animated)
|
|
29
|
+
return finish();
|
|
30
|
+
dialog.addEventListener("animationend", finish, { once: true });
|
|
31
|
+
// Filet : si l'animation ne se termine pas (onglet en arrière-plan), on ferme quand même.
|
|
32
|
+
setTimeout(() => dialog.hasAttribute("data-closing") && finish(), 400);
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agence-zef/astro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
@@ -47,11 +50,25 @@
|
|
|
47
50
|
"./VideoPlayer.astro": "./components/VideoPlayer.astro",
|
|
48
51
|
"./Subscriptions.astro": "./components/Subscriptions.astro",
|
|
49
52
|
"./ReservedNotice.astro": "./components/ReservedNotice.astro",
|
|
50
|
-
"./routes/shop.ts": "./components/routes/shop.ts"
|
|
53
|
+
"./routes/shop.ts": "./components/routes/shop.ts",
|
|
54
|
+
"./Dialog.astro": "./components/Dialog.astro",
|
|
55
|
+
"./Accordion.astro": "./components/Accordion.astro",
|
|
56
|
+
"./AccordionItem.astro": "./components/AccordionItem.astro",
|
|
57
|
+
"./Popover.astro": "./components/Popover.astro",
|
|
58
|
+
"./Field.astro": "./components/Field.astro",
|
|
59
|
+
"./Input.astro": "./components/Input.astro",
|
|
60
|
+
"./Select.astro": "./components/Select.astro",
|
|
61
|
+
"./Checkbox.astro": "./components/Checkbox.astro",
|
|
62
|
+
"./styles.css": "./styles.css",
|
|
63
|
+
"./ui": {
|
|
64
|
+
"types": "./dist/ui.d.ts",
|
|
65
|
+
"import": "./dist/ui.js"
|
|
66
|
+
}
|
|
51
67
|
},
|
|
52
68
|
"files": [
|
|
53
69
|
"dist",
|
|
54
|
-
"components"
|
|
70
|
+
"components",
|
|
71
|
+
"styles.css"
|
|
55
72
|
],
|
|
56
73
|
"publishConfig": {
|
|
57
74
|
"access": "public"
|
|
@@ -64,8 +81,8 @@
|
|
|
64
81
|
"@types/node": "^24.0.0"
|
|
65
82
|
},
|
|
66
83
|
"dependencies": {
|
|
67
|
-
"@agence-zef/schema": "0.
|
|
68
|
-
"@agence-zef/sdk": "0.
|
|
84
|
+
"@agence-zef/schema": "0.5.0",
|
|
85
|
+
"@agence-zef/sdk": "0.5.0"
|
|
69
86
|
},
|
|
70
87
|
"homepage": "https://agence-zef.fr",
|
|
71
88
|
"scripts": {
|