@webround/core 0.3.13 → 0.3.15

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.
@@ -48,7 +48,7 @@ function WrRouteElement({ route, pageConfigMap, lazyFallback: LazyFallback, Page
48
48
  const Component = route.Component;
49
49
  return _jsx(Component, {});
50
50
  })();
51
- return (_jsxs(PageProvider, { config: config, pageName: pageName, children: [_jsx(WrScroll, {}), _jsxs(WrSharedContext.Provider, { value: shared, children: [_jsx(WrSeoHead, { site: seo, config: config, pathname: pathname, lang: lang, langs: langs }), _jsx(WrLayout, { Header: Header, Footer: Footer, showHeader: route.header, showFooter: route.footer, pageConfigMap: pageConfigMap, ScopeProvider: PageProvider, children: body })] })] }, pageName));
51
+ return (_jsxs(PageProvider, { config: config, pageName: pageName, children: [_jsx(WrScroll, {}), _jsxs(WrSharedContext.Provider, { value: shared, children: [_jsx(WrSeoHead, { site: seo, config: config, home: pageConfigMap.home, pathname: pathname, lang: lang, langs: langs }), _jsx(WrLayout, { Header: Header, Footer: Footer, showHeader: route.header, showFooter: route.footer, pageConfigMap: pageConfigMap, ScopeProvider: PageProvider, children: body })] })] }, pageName));
52
52
  }
53
53
  /**
54
54
  * An address that leads to no page is the home page. The address is replaced BEFORE the router reads it, so the first render is the home page's: what was served for that address is the home page's
package/dist/kinds.d.ts CHANGED
@@ -3,7 +3,7 @@ export declare const WR_KINDS: {
3
3
  readonly component: "WrSection";
4
4
  readonly tag: {
5
5
  readonly default: "section";
6
- readonly allowed: readonly ["section", "div", "article", "aside", "header", "footer", "main", "nav"];
6
+ readonly allowed: readonly ["section", "div", "article", "aside", "header", "footer", "main", "nav", "ul", "ol", "li", "dl", "dt", "dd", "figure", "figcaption", "details", "summary", "address", "blockquote", "hgroup"];
7
7
  };
8
8
  readonly holds: "elements";
9
9
  };
package/dist/kinds.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The one table that says what the Webround elements are. Everything that needs to know — the editor's engine, its field schema, the checks,
2
2
  // the components' own `tag` prop, the parent editor — derives from it: a new element, or a new tag, is written here and nowhere else.
3
3
  export const WR_KINDS = {
4
- section: { component: 'WrSection', tag: { default: 'section', allowed: ['section', 'div', 'article', 'aside', 'header', 'footer', 'main', 'nav'] }, holds: 'elements' },
4
+ section: { component: 'WrSection', tag: { default: 'section', allowed: ['section', 'div', 'article', 'aside', 'header', 'footer', 'main', 'nav', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'figure', 'figcaption', 'details', 'summary', 'address', 'blockquote', 'hgroup'] }, holds: 'elements' },
5
5
  text: { component: 'WrText', tag: { default: 'div', allowed: ['div', 'span'] }, holds: 'none' },
6
6
  image: { component: 'WrImage', tag: { fixed: 'img' }, holds: 'none' },
7
7
  link: { component: 'WrLink', tag: { fixed: 'a' }, holds: 'label' },
@@ -10,9 +10,11 @@ export declare function useWrSeo(seo: WrSeoPage | undefined): void;
10
10
  * A page's tags for search engines and social networks, rendered where the page is: React puts `<title>`, `<meta>` and `<link>` in
11
11
  * `<head>` by itself. Both packages use this one, so what the editor shows is what production serves.
12
12
  */
13
- export declare function WrSeoHead({ site, config, pathname, lang, langs }: {
13
+ export declare function WrSeoHead({ site, config, home, pathname, lang, langs }: {
14
14
  site?: WrSeoSite;
15
15
  config: WrPageConfig | undefined;
16
+ /** The home page's JSON: the structured data of a page that has none of its own. */
17
+ home?: WrPageConfig;
16
18
  pathname: string;
17
19
  lang: string;
18
20
  langs?: WrLangConfig;
package/dist/seo-react.js CHANGED
@@ -39,10 +39,10 @@ export function useWrSeo(seo) {
39
39
  * A page's tags for search engines and social networks, rendered where the page is: React puts `<title>`, `<meta>` and `<link>` in
40
40
  * `<head>` by itself. Both packages use this one, so what the editor shows is what production serves.
41
41
  */
42
- export function WrSeoHead({ site, config, pathname, lang, langs }) {
42
+ export function WrSeoHead({ site, config, home, pathname, lang, langs }) {
43
43
  const path = langs ? wrBuildLangPath('default', pathname, langs) : pathname;
44
44
  const dynamic = useSyncExternalStore(subscribe, () => dynamicSeo, () => undefined);
45
- const { title, tags, jsonLd } = wrSeoTags({ site, page: { ...wrSeoPageOf(config), ...dynamic }, path, lang, langs });
45
+ const { title, tags, jsonLd } = wrSeoTags({ site, page: { ...wrSeoPageOf(config), ...dynamic }, home: wrSeoPageOf(home), path, lang, langs });
46
46
  // The `<title>` that `index.html` has (a placeholder, the page app's own) is not the page's: when the page has one of its own, the head must have only that, as the HTML a crawler gets says it
47
47
  useEffect(() => {
48
48
  if (title)
package/dist/seo.d.ts CHANGED
@@ -26,6 +26,10 @@ export interface WrSeoSite {
26
26
  extraSitemaps?: string[];
27
27
  /** The code Google Search Console gives to prove the site is yours (the `content` of its `google-site-verification` tag): only the code, never the tag. It goes in `index.html` (vite-plugins/head.ts), not in the tags of `wrSeoTags`: a crawler looks for it before any script runs. */
28
28
  searchConsole?: string;
29
+ /** Whether the AI crawlers (the ones that train models and the ones that answer for a person) may read the site. Absent or `allow`: everybody may, as robots.txt always said. `block`: robots.txt says no to each of `WR_AI_BOTS`, and no `llms.txt` is written. robots.txt is a request, not a lock. */
30
+ aiBots?: 'allow' | 'block';
31
+ /** `false`: the site does not publish an `llms.txt` (a plain list of its pages, for AI assistants). Absent: it does. */
32
+ llms?: boolean;
29
33
  }
30
34
  /** What one page says (under `_seo` in its JSON). Text can be translated like every other text. */
31
35
  export interface WrSeoPage {
@@ -74,6 +78,11 @@ export type WrSeoJsonLd = WrJsonLd[] | Record<string, WrJsonLd[]>;
74
78
  export declare const WR_ROBOTS_FILE = "robots.txt";
75
79
  export declare const WR_SITEMAP_INDEX_FILE = "sitemap-index.xml";
76
80
  export declare const WR_SITEMAP_PAGES_FILE = "sitemap-pages.xml";
81
+ /** The list of the site's pages for AI assistants (llmstxt.org). Ends with `WR_LLMS_MARK`: a file without it is somebody's own, and is never overwritten nor removed. */
82
+ export declare const WR_LLMS_FILE = "llms.txt";
83
+ export declare const WR_LLMS_MARK = "<!-- Written by Webround from the pages of the site: change them, not this file. -->";
84
+ /** The user agents of the AI crawlers `aiBots: 'block'` turns away: the ones that gather text to train models and the ones that fetch pages for an assistant. */
85
+ export declare const WR_AI_BOTS: readonly ["GPTBot", "OAI-SearchBot", "ChatGPT-User", "ClaudeBot", "Claude-SearchBot", "Claude-User", "anthropic-ai", "PerplexityBot", "Perplexity-User", "Google-Extended", "Applebot-Extended", "Amazonbot", "Meta-ExternalAgent", "CCBot", "Bytespider", "cohere-ai", "Diffbot"];
77
86
  /** The sitemaps the shop's server writes every day (not the build): a site with the page of a product (or of a collection) always has its index point to the sitemap of them. */
78
87
  export declare const WR_SITEMAP_PRODUCTS_FILE = "sitemap-products.xml";
79
88
  export declare const WR_SITEMAP_COLLECTIONS_FILE = "sitemap-collections.xml";
@@ -105,11 +114,18 @@ export interface WrSeoContext {
105
114
  /** The language shown: `default`, or a code. */
106
115
  lang: string;
107
116
  langs?: WrLangConfig;
117
+ /** The home page's own SEO data: the structured data of a page that has none of its own is the home's. */
118
+ home?: WrSeoPage;
108
119
  }
109
120
  /** The text of the tab: the page's title in the template, or the site's name when the page has none. */
110
121
  export declare function wrSeoTitle({ site, page, lang }: Pick<WrSeoContext, 'site' | 'page' | 'lang'>): string | undefined;
111
122
  /** Everything a page puts in `<head>` for search engines and social networks: ONE function, whoever renders or injects it. */
112
123
  export declare function wrSeoTags(context: WrSeoContext): WrSeoHead;
124
+ /**
125
+ * What a head says twice: a `<meta>` (by its name or property) or a `<link>` (by its `rel`, and language) that more than one tag writes, and a structured-data type that is meant to be
126
+ * said once. A page's `meta` list can repeat what the page already writes (a second description), and a site's and the home's structured data can both be an organisation.
127
+ */
128
+ export declare function wrSeoDuplicates(head: Pick<WrSeoHead, 'tags' | 'jsonLd'>): string[];
113
129
  /** A page of the site as a sitemap sees it: its address in every language. */
114
130
  export interface WrSeoEntry {
115
131
  pageName: string;
@@ -158,10 +174,21 @@ export declare function wrSnapshotTargets(input: {
158
174
  export declare function wrSitemapPagesXml(entries: readonly WrSeoEntry[], langs?: WrLangConfig): string;
159
175
  /** The index of the sitemaps: the site's own, and whichever others the site says exist (e.g. the shop's, written by the server). */
160
176
  export declare function wrSitemapIndexXml(siteUrl: string, files: readonly string[]): string;
161
- export declare function wrRobotsTxt(siteUrl: string): string;
177
+ export declare function wrRobotsTxt(siteUrl: string, aiBots?: WrSeoSite['aiBots']): string;
178
+ /**
179
+ * The `llms.txt` of a site: its name, what its home says about it and, for each language, the pages a search engine may index (title, address and description). Plain
180
+ * Markdown, the shape llmstxt.org asks for. Pages are listed as the sitemap lists them: the same entries, the same exclusions.
181
+ */
182
+ export declare function wrLlmsTxt(input: {
183
+ entries: readonly WrSeoEntry[];
184
+ pages: Record<string, WrPageConfig | undefined>;
185
+ langs?: WrLangConfig;
186
+ site?: WrSeoSite;
187
+ siteUrl: string;
188
+ }): string;
162
189
  /**
163
- * The files a site has for search engines, by name (relative to `public/`): robots.txt, the index and the sitemap of the site's pages.
164
- * `null` while the site does not know its public address: there is nothing true to write.
190
+ * The files a site has for search engines and AI assistants, by name (relative to `public/`): robots.txt, the index and the sitemap of the site's pages, and `llms.txt` (unless the site
191
+ * turned it off or blocks the AI crawlers). `null` while the site does not know its public address: there is nothing true to write.
165
192
  */
166
193
  export declare function wrSeoFiles(input: {
167
194
  routes: WrRoutesFile;
package/dist/seo.js CHANGED
@@ -20,6 +20,14 @@ export function wrSearchConsoleCode(input) {
20
20
  export const WR_ROBOTS_FILE = 'robots.txt';
21
21
  export const WR_SITEMAP_INDEX_FILE = 'sitemap-index.xml';
22
22
  export const WR_SITEMAP_PAGES_FILE = 'sitemap-pages.xml';
23
+ /** The list of the site's pages for AI assistants (llmstxt.org). Ends with `WR_LLMS_MARK`: a file without it is somebody's own, and is never overwritten nor removed. */
24
+ export const WR_LLMS_FILE = 'llms.txt';
25
+ export const WR_LLMS_MARK = '<!-- Written by Webround from the pages of the site: change them, not this file. -->';
26
+ /** The user agents of the AI crawlers `aiBots: 'block'` turns away: the ones that gather text to train models and the ones that fetch pages for an assistant. */
27
+ export const WR_AI_BOTS = [
28
+ 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-SearchBot', 'Claude-User', 'anthropic-ai', 'PerplexityBot', 'Perplexity-User',
29
+ 'Google-Extended', 'Applebot-Extended', 'Amazonbot', 'Meta-ExternalAgent', 'CCBot', 'Bytespider', 'cohere-ai', 'Diffbot',
30
+ ];
23
31
  /** The sitemaps the shop's server writes every day (not the build): a site with the page of a product (or of a collection) always has its index point to the sitemap of them. */
24
32
  export const WR_SITEMAP_PRODUCTS_FILE = 'sitemap-products.xml';
25
33
  export const WR_SITEMAP_COLLECTIONS_FILE = 'sitemap-collections.xml';
@@ -135,9 +143,32 @@ export function wrSeoTags(context) {
135
143
  else if (extra.name?.trim())
136
144
  meta('name', extra.name.trim(), content);
137
145
  }
138
- const jsonLd = [...wrSeoJsonLdOf(site?.jsonLd, lang), ...wrSeoJsonLdOf(page?.jsonLd, lang)].map(wrJsonLdText);
146
+ // The page's own structured data; a page that has none uses the home's, but only what is about the whole site (who the organisation is): a FAQ, a product or an article of the home says nothing about
147
+ // another page, and structured data that is not about what the page shows is against the search engines' rules. A site whose home has none has none (nothing is made up).
148
+ const own = wrSeoJsonLdOf(page?.jsonLd, lang);
149
+ // The same object written at the site and at the home is one object, not two
150
+ const jsonLd = [...new Set([...wrSeoJsonLdOf(site?.jsonLd, lang), ...(own.length > 0 ? own : wrSeoJsonLdOf(context.home?.jsonLd, lang).filter(isSiteWideJsonLd))].map(wrJsonLdText))];
139
151
  return { title, tags, jsonLd };
140
152
  }
153
+ /** The structured-data types that are about the whole site, and that a page is meant to say once: two of them are two answers to "who is this?". */
154
+ const WR_SITE_WIDE_JSON_LD_TYPES = ['Organization', 'WebSite', 'LocalBusiness'];
155
+ const typesOfJsonLd = (item) => [item['@type']].flat().filter((one) => typeof one === 'string');
156
+ const isSiteWideJsonLd = (item) => typesOfJsonLd(item).some(type => WR_SITE_WIDE_JSON_LD_TYPES.includes(type));
157
+ /**
158
+ * What a head says twice: a `<meta>` (by its name or property) or a `<link>` (by its `rel`, and language) that more than one tag writes, and a structured-data type that is meant to be
159
+ * said once. A page's `meta` list can repeat what the page already writes (a second description), and a site's and the home's structured data can both be an organisation.
160
+ */
161
+ export function wrSeoDuplicates(head) {
162
+ const count = (keys) => keys.reduce((seen, key) => seen.set(key, (seen.get(key) ?? 0) + 1), new Map());
163
+ const tags = count(head.tags.map(({ tag, attrs }) => tag === 'meta'
164
+ ? `<meta ${attrs.name ? `name="${attrs.name}"` : `property="${attrs.property}"`}>`
165
+ : `<link rel="${attrs.rel}"${attrs.hreflang ? ` hreflang="${attrs.hreflang}"` : ''}>`));
166
+ const types = count(head.jsonLd.flatMap(text => typesOfJsonLd(JSON.parse(text)).filter(type => WR_SITE_WIDE_JSON_LD_TYPES.includes(type))));
167
+ return [
168
+ ...[...tags].filter(([, n]) => n > 1).map(([key, n]) => `${key} is written ${n} times`),
169
+ ...[...types].filter(([, n]) => n > 1).map(([type, n]) => `the structured data has ${n} objects of type ${type}`),
170
+ ];
171
+ }
141
172
  /**
142
173
  * The pages a sitemap can list: those with a fixed address (a `[slug]` route is a family of pages the shop's own sitemap tells about).
143
174
  * Pages marked noindex are kept in the list (the checks and the editor show them) and left out by `wrSitemapPagesXml`.
@@ -204,12 +235,36 @@ export function wrSitemapIndexXml(siteUrl, files) {
204
235
  const rows = files.map(file => ` <sitemap>\n <loc>${xml(isAbsolute(file) ? file : `${siteUrl}/${file.replace(/^\/+/, '')}`)}</loc>\n </sitemap>`);
205
236
  return `${XML_HEAD}<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join('\n')}\n</sitemapindex>\n`;
206
237
  }
207
- export function wrRobotsTxt(siteUrl) {
208
- return `User-agent: *\nAllow: /\n\nSitemap: ${siteUrl}/${WR_SITEMAP_INDEX_FILE}\n`;
238
+ export function wrRobotsTxt(siteUrl, aiBots = 'allow') {
239
+ const blocked = aiBots === 'block' ? `${WR_AI_BOTS.map(bot => `User-agent: ${bot}`).join('\n')}\nDisallow: /\n\n` : '';
240
+ return `${blocked}User-agent: *\nAllow: /\n\nSitemap: ${siteUrl}/${WR_SITEMAP_INDEX_FILE}\n`;
241
+ }
242
+ /**
243
+ * The `llms.txt` of a site: its name, what its home says about it and, for each language, the pages a search engine may index (title, address and description). Plain
244
+ * Markdown, the shape llmstxt.org asks for. Pages are listed as the sitemap lists them: the same entries, the same exclusions.
245
+ */
246
+ export function wrLlmsTxt(input) {
247
+ const { entries, pages, langs, site, siteUrl } = input;
248
+ const keyOf = (lang) => (lang === langs?.default ? 'default' : lang);
249
+ const name = wrLocalized(site?.siteName, 'default')?.trim() || new URL(siteUrl).host;
250
+ const about = wrLocalized(wrSeoPageOf(pages.home).description, 'default')?.trim();
251
+ const indexed = entries.filter(entry => !entry.noindex);
252
+ const sections = (langs && langs.available.length > 0 ? langs.available : ['default']).map((code) => {
253
+ const key = keyOf(code);
254
+ const items = indexed.map((entry) => {
255
+ const seo = wrSeoPageOf(pages[entry.pageName]);
256
+ const url = entry.urls.find(u => u.lang === code)?.url ?? entry.urls[0].url;
257
+ const title = wrLocalized(seo.title, key)?.trim() || (entry.pageName === 'home' ? wrLocalized(site?.siteName, key)?.trim() || name : entry.pageName);
258
+ const description = wrLocalized(seo.description, key)?.trim();
259
+ return `- [${title}](${url})${description ? `: ${description}` : ''}`;
260
+ });
261
+ return `## ${langs && langs.available.length > 1 ? `Pages (${code})` : 'Pages'}\n\n${items.join('\n')}`;
262
+ });
263
+ return `# ${name}\n\n${about ? `> ${about}\n\n` : ''}${sections.join('\n\n')}\n\n${WR_LLMS_MARK}\n`;
209
264
  }
210
265
  /**
211
- * The files a site has for search engines, by name (relative to `public/`): robots.txt, the index and the sitemap of the site's pages.
212
- * `null` while the site does not know its public address: there is nothing true to write.
266
+ * The files a site has for search engines and AI assistants, by name (relative to `public/`): robots.txt, the index and the sitemap of the site's pages, and `llms.txt` (unless the site
267
+ * turned it off or blocks the AI crawlers). `null` while the site does not know its public address: there is nothing true to write.
213
268
  */
214
269
  export function wrSeoFiles(input) {
215
270
  const siteUrl = wrSiteUrlOf(input.site);
@@ -219,10 +274,12 @@ export function wrSeoFiles(input) {
219
274
  // The shop's sitemaps are cited whether the files exist yet or not: the server writes them daily and the index must already say where they are
220
275
  const own = [WR_SITEMAP_PAGES_FILE, ...Object.entries(WR_SHOP_SITEMAPS).filter(([route]) => route in input.routes).map(([, file]) => file)];
221
276
  const extra = (input.site?.extraSitemaps ?? []).filter(name => typeof name === 'string' && name.trim() && !own.includes(name));
277
+ const llms = input.site?.llms !== false && input.site?.aiBots !== 'block';
222
278
  return {
223
- [WR_ROBOTS_FILE]: wrRobotsTxt(siteUrl),
279
+ [WR_ROBOTS_FILE]: wrRobotsTxt(siteUrl, input.site?.aiBots),
224
280
  [WR_SITEMAP_INDEX_FILE]: wrSitemapIndexXml(siteUrl, [...own, ...extra]),
225
281
  [WR_SITEMAP_PAGES_FILE]: wrSitemapPagesXml(entries, input.langs),
282
+ ...(llms ? { [WR_LLMS_FILE]: wrLlmsTxt({ entries, pages: input.pages, langs: input.langs, site: input.site, siteUrl }) } : {}),
226
283
  };
227
284
  }
228
285
  //# sourceMappingURL=seo.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webround/core",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",