@webround/editor 0.3.14 → 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.
@@ -3,8 +3,8 @@ import path from 'path';
3
3
  import { Project, SyntaxKind } from 'ts-morph';
4
4
  import { WR_COMPONENT_TO_KIND, wrTagOf } from '@webround/core/kinds';
5
5
  import { wrAriaLabel, wrLocalized } from '@webround/core/localized';
6
- import { WR_SHARED_SCOPES, wrPageConfigPath } from '@webround/core/routing';
7
- import { wrJsonLdProblems, wrSeoPageOf } from '@webround/core/seo';
6
+ import { WR_SHARED_SCOPES, wrPageConfigPath, wrRoutePattern } from '@webround/core/routing';
7
+ import { wrJsonLdProblems, wrSeoDuplicates, wrSeoPageOf, wrSeoTags } from '@webround/core/seo';
8
8
  import { findPageRoot, syntaxErrors } from '../vite-plugins/jsx-tree.js';
9
9
  import { createOperationContext } from '../operations/context.js';
10
10
  import { readJsonFile, readLangs, readRoutes, readSeoSite, scopesOfPage, sharedScopesOf } from '../operations/ops/project.js';
@@ -309,10 +309,18 @@ export function checkScopeFromCode(ctx, scope, options = {}) {
309
309
  return { name: scope.name, file: scope.file, nodes, syntax };
310
310
  }
311
311
  /** What a page tells search engines, as the rules read it: every text by language key (a plain string is the default's). */
312
- function seoOfPage(ctx, name) {
312
+ function seoOfPage(ctx, name, lang) {
313
313
  const seo = wrSeoPageOf(readJsonFile(ctx, wrPageConfigPath(name)) ?? undefined);
314
314
  const byLang = (value) => typeof value === 'string' ? { default: value } : { ...(value ?? {}) };
315
- return { title: byLang(seo.title), description: byLang(seo.description), noindex: seo.noindex === true, family: /\[[^\]]+\]/.test(name), jsonLdProblems: wrJsonLdProblems(seo.jsonLd) };
315
+ // The head the page really gets (the same function the page renders), to see what it says twice
316
+ const head = wrSeoTags({
317
+ site: readSeoSite(ctx), page: seo, home: wrSeoPageOf(readJsonFile(ctx, wrPageConfigPath('home')) ?? undefined),
318
+ path: wrRoutePattern(name), lang, langs: readLangs(ctx) ?? undefined,
319
+ });
320
+ return {
321
+ title: byLang(seo.title), description: byLang(seo.description), noindex: seo.noindex === true, family: /\[[^\]]+\]/.test(name),
322
+ jsonLdProblems: wrJsonLdProblems(seo.jsonLd), duplicates: wrSeoDuplicates(head),
323
+ };
316
324
  }
317
325
  /** What a link may lead to: the routes, the languages and the files served as they are. */
318
326
  function projectOf(ctx) {
@@ -345,7 +353,7 @@ export function checkPagesFromCode(root, lang = 'default') {
345
353
  const syntax = [...scopes.flatMap(scope => scope.syntax), ...follow.syntax];
346
354
  return {
347
355
  name, nodes: scopes.flatMap(scope => scope.nodes), ...(syntax.length ? { syntax } : {}),
348
- ...(multilingual ? { lang } : {}), project, labelledIds, seo: seoOfPage(ctx, name),
356
+ ...(multilingual ? { lang } : {}), project, labelledIds, seo: seoOfPage(ctx, name, lang),
349
357
  };
350
358
  });
351
359
  }
@@ -368,7 +376,17 @@ function readIndexHtml(root) {
368
376
  const lang = /<html\b[^>]*\blang\s*=\s*["']([^"']+)["']/i.exec(html)?.[1] ?? null;
369
377
  const viewport = /<meta\b[^>]*\bname\s*=\s*["']viewport["'][^>]*\bcontent\s*=\s*["']([^"']*)["']/i.exec(html)?.[1]
370
378
  ?? /<meta\b[^>]*\bcontent\s*=\s*["']([^"']*)["'][^>]*\bname\s*=\s*["']viewport["']/i.exec(html)?.[1] ?? null;
371
- return { lang, viewport };
379
+ // What each page writes by itself: if `index.html` has it, it is in every page and the page's own comes with it
380
+ const seoTags = [...html.matchAll(/<(meta|link)\b[^>]*>/gi)].flatMap(([tag, kind]) => {
381
+ const attr = (name) => new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, 'i').exec(tag)?.[1]?.toLowerCase();
382
+ const key = kind.toLowerCase() === 'link' ? (attr('rel') === 'canonical' ? 'canonical' : undefined) : attr('name') ?? attr('property');
383
+ if (key === 'canonical')
384
+ return ['<link rel="canonical">'];
385
+ if (key === 'description')
386
+ return ['<meta name="description">'];
387
+ return key && /^(og|twitter):/.test(key) ? [`<meta ${attr('name') ? 'name' : 'property'}="${key}">`] : [];
388
+ });
389
+ return { lang, viewport, seoTags: [...new Set(seoTags)] };
372
390
  }
373
391
  /** What the checks know of the site as a whole. */
374
392
  export function checkSiteFromCode(root) {
@@ -143,6 +143,14 @@ export const RULES = [
143
143
  report(null, `The structured data of the page is not valid: ${problem}. Search engines ignore it.`);
144
144
  },
145
145
  },
146
+ {
147
+ name: 'seo-duplicates',
148
+ severity: 'warning',
149
+ run(page, report) {
150
+ for (const problem of page.seo?.duplicates ?? [])
151
+ report(null, `The head of the page repeats itself: ${problem}. Search engines and social networks may read the wrong one: keep one.`, undefined, problem.replace(/\d+ (times|objects)/, '$1'));
152
+ },
153
+ },
146
154
  {
147
155
  name: 'broken-link',
148
156
  severity: 'warning',
@@ -461,6 +469,14 @@ export const SITE_RULES = [
461
469
  run: (site, report) => { for (const problem of wrJsonLdProblems(site.seo.jsonLd))
462
470
  report(`The structured data of the site is not valid: ${problem}. Search engines ignore it.`, undefined); },
463
471
  },
472
+ {
473
+ name: 'index-seo-tags',
474
+ severity: 'warning',
475
+ run: (site, report) => {
476
+ for (const tag of site.index?.seoTags ?? [])
477
+ report(`index.html has ${tag}: it is in every page, the same, and each page writes its own as well, so the head has two. Take it out of index.html and write it in the SEO panel.`, undefined, undefined, tag);
478
+ },
479
+ },
464
480
  {
465
481
  name: 'focus-visible-css',
466
482
  severity: 'warning',
@@ -66,6 +66,8 @@ export interface CheckPage {
66
66
  family: boolean;
67
67
  /** What is wrong with the page's JSON-LD (empty when it is fine). */
68
68
  jsonLdProblems: string[];
69
+ /** What the page's head says twice (`wrSeoDuplicates`), in the language the page was read in. */
70
+ duplicates: string[];
69
71
  };
70
72
  }
71
73
  /** Where a link of the site may lead. */
@@ -110,7 +112,8 @@ export interface CheckSite {
110
112
  /** What `index.html` says (the page every route is served in); null while the project has none. */
111
113
  index: {
112
114
  lang: string | null;
113
- viewport: string | null;
115
+ viewport: string | null; /** The tags the pages write themselves (description, canonical, Open Graph, Twitter) that `index.html` already has: they would be in every page, twice. */
116
+ seoTags: string[];
114
117
  } | null;
115
118
  /** Some element has an id the code computes: a key that looks unused may belong to it, so nothing can be said to be unused. */
116
119
  dynamicIds: boolean;
@@ -56,7 +56,7 @@ function WrRouteElement({ route, lazyFallback: LazyFallback, Header, Footer, })
56
56
  const Component = route.Component;
57
57
  return _jsx(Component, {});
58
58
  })();
59
- return (_jsxs(WrPageProvider, { config: config, pageName: pageName, file: file, children: [_jsx(WrScroll, {}), _jsx(WrSeoHead, { site: site, config: config, pathname: pathname, lang: lang, langs: langs }), _jsx(WrLayout, { Header: Header, Footer: Footer, showHeader: route.header, showFooter: route.footer, children: body })] }, pageName));
59
+ return (_jsxs(WrPageProvider, { config: config, pageName: pageName, file: file, children: [_jsx(WrScroll, {}), _jsx(WrSeoHead, { site: site, config: config, home: pageConfigMap.home, pathname: pathname, lang: lang, langs: langs }), _jsx(WrLayout, { Header: Header, Footer: Footer, showHeader: route.header, showFooter: route.footer, children: body })] }, pageName));
60
60
  }
61
61
  export function WrAppProvider({ routesFile, eagerModules, lazyModules, configModules, langs, initialLang = 'default', lazyFallback = WrDefaultPageFallback, Header, Footer, seo, shared, children, }) {
62
62
  // Config delle pagine: stato live, indipendente dal router.
@@ -162,6 +162,10 @@ export interface WrSeoSiteSetRequest {
162
162
  image?: string;
163
163
  /** Il codice (o il tag intero) di verifica di Google Search Console. */
164
164
  searchConsole?: string;
165
+ /** `block`: robots.txt manda via i crawler AI (e non si scrive `llms.txt`); `allow` è com'era. */
166
+ aiBots?: 'allow' | 'block';
167
+ /** `false`: il sito non pubblica `llms.txt`. */
168
+ llms?: boolean;
165
169
  twitterSite?: string;
166
170
  twitterCreator?: string;
167
171
  /** Per `lang` (default se omessa): nome, modello del titolo, immagine e dati strutturati. */
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { wrPageConfigPath } from '@webround/core/routing';
4
- import { WR_SEO_KEY, wrJsonLdProblems, wrSearchConsoleCode, wrSeoFiles, wrSnapshotTargets, wrSeoPageOf, wrSiteUrlOf } from '@webround/core/seo';
4
+ import { WR_LLMS_FILE, WR_LLMS_MARK, WR_SEO_KEY, wrJsonLdProblems, wrSearchConsoleCode, wrSeoFiles, wrSnapshotTargets, wrSeoPageOf, wrSiteUrlOf } from '@webround/core/seo';
5
5
  import { wrLocalizeItems, wrSetLocalized } from '@webround/core/localized';
6
6
  import { defineOperation, flag, invalid, list, OperationError, text } from '../define.js';
7
7
  import { checkScopeFromCode } from '../../check/producer-code.js';
@@ -40,13 +40,23 @@ export function writeSeoFiles(ctx) {
40
40
  return { written: [], reason: 'no-site-url' };
41
41
  const written = [];
42
42
  fs.mkdirSync(ctx.publicRoot, { recursive: true });
43
+ // An `llms.txt` without our mark is somebody's own: it is neither replaced nor removed
44
+ const llmsFile = path.join(ctx.publicRoot, WR_LLMS_FILE);
45
+ const ownLlms = fs.existsSync(llmsFile) && !fs.readFileSync(llmsFile, 'utf-8').includes(WR_LLMS_MARK);
43
46
  for (const [name, content] of Object.entries(files)) {
44
47
  const file = path.join(ctx.publicRoot, name);
48
+ if (name === WR_LLMS_FILE && ownLlms)
49
+ continue;
45
50
  if (fs.existsSync(file) && fs.readFileSync(file, 'utf-8') === content)
46
51
  continue;
47
52
  fs.writeFileSync(file, content);
48
53
  written.push(name);
49
54
  }
55
+ // The site no longer has one (turned off, or the AI crawlers are blocked): the one we wrote goes
56
+ if (!(WR_LLMS_FILE in files) && fs.existsSync(llmsFile) && !ownLlms) {
57
+ fs.rmSync(llmsFile);
58
+ written.push(`${WR_LLMS_FILE} (removed)`);
59
+ }
50
60
  return { written };
51
61
  }
52
62
  export const seoGet = defineOperation({
@@ -277,6 +287,8 @@ export const seoSiteSet = defineOperation({
277
287
  { name: 'image', type: 'string', help: 'the sharing picture of pages that have none: a path in the site or a full address (written in `lang`)' },
278
288
  { name: 'searchConsole', type: 'string', help: 'the code that proves the site is yours to Google Search Console: the code, or the whole <meta name="google-site-verification"…> tag as Google shows it (only the code is kept); it is written in every page\'s head' },
279
289
  { name: 'extraSitemaps', type: 'array', help: 'sitemaps the index must point to besides the site\'s own, e.g. ["sitemap-products.xml"] (the shop writes it); a list, [] for none' },
290
+ { name: 'aiBots', type: 'string', choices: ['allow', 'block'], help: 'whether AI crawlers (GPTBot, ClaudeBot, PerplexityBot, Google-Extended…) may read the site: allow (the default) or block, which robots.txt asks them to respect and which also turns the llms.txt off' },
291
+ { name: 'llms', type: 'boolean', help: 'publish an llms.txt, a plain list of the site\'s pages for AI assistants (on unless turned off with false)' },
280
292
  { name: 'twitterSite', type: 'string', help: 'the account of the site on X/Twitter (@acme), for every page' },
281
293
  { name: 'twitterCreator', type: 'string', help: 'the account of the author on X/Twitter (@jane), for every page' },
282
294
  { name: 'lang', type: 'string', help: 'the language of `siteName`, `titleTemplate`, `image` and `jsonLd`: `default` (the default one) or a language code of the site' },
@@ -317,6 +329,15 @@ export const seoSiteSet = defineOperation({
317
329
  throw invalid('a sitemap is a file name or an address, without spaces', 'invalid-sitemap');
318
330
  patch.extraSitemaps = names.length > 0 ? names : null;
319
331
  }
332
+ const aiBots = text(input, 'aiBots');
333
+ if (aiBots !== undefined) {
334
+ if (aiBots.trim() && !['allow', 'block'].includes(aiBots.trim()))
335
+ throw invalid('aiBots is allow or block', 'invalid-value');
336
+ patch.aiBots = aiBots.trim() === 'block' ? 'block' : null;
337
+ }
338
+ const llms = flag(input, 'llms');
339
+ if (llms !== undefined)
340
+ patch.llms = llms ? null : false;
320
341
  if (input.twitterSite !== undefined || input.twitterCreator !== undefined) {
321
342
  const twitter = { ...(readSeoSite(ctx).twitter ?? {}) };
322
343
  for (const [param, key] of [['twitterSite', 'site'], ['twitterCreator', 'creator']]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webround/editor",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -75,7 +75,7 @@
75
75
  "@lexical/selection": "^0.51.0",
76
76
  "@lexical/utils": "^0.51.0",
77
77
  "@monaco-editor/react": "^4.7.0",
78
- "@webround/core": "^0.3.14",
78
+ "@webround/core": "^0.3.15",
79
79
  "highlight.js": "^11.12.0",
80
80
  "hls.js": "^1.5.0",
81
81
  "lexical": "^0.51.0",