@webround/editor 0.3.14 → 0.4.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.
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/check/rules.js
CHANGED
|
@@ -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',
|
package/dist/check/types.d.ts
CHANGED
|
@@ -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.
|
package/dist/messages.d.ts
CHANGED
|
@@ -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
|
+
"version": "0.4.0",
|
|
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.
|
|
78
|
+
"@webround/core": "^0.4.0",
|
|
79
79
|
"highlight.js": "^11.12.0",
|
|
80
80
|
"hls.js": "^1.5.0",
|
|
81
81
|
"lexical": "^0.51.0",
|