@webround/editor 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.
|
@@ -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';
|
|
@@ -225,7 +225,7 @@ function fromJsx(el, config, file, options) {
|
|
|
225
225
|
const children = el.isKind(SyntaxKind.JsxElement) ? nodesUnder(el, config, file, options) : [];
|
|
226
226
|
// Plain components we know nothing about (`<Card>`) are only transparent: what they hold is what counts. A shared one is entered: what it renders is part of the page
|
|
227
227
|
if (!wrKind && /^[A-Z]/.test(name))
|
|
228
|
-
return [...sharedNodesOf(el, name, options), ...children];
|
|
228
|
+
return [...sharedNodesOf(el, name, options), ...children].map(node => ({ ...node, viaComponent: true }));
|
|
229
229
|
const attrs = {};
|
|
230
230
|
const alt = inLang(json.alt, options) ?? str(attrOf(el, 'alt'));
|
|
231
231
|
if (alt !== undefined)
|
|
@@ -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
|
@@ -49,6 +49,35 @@ export const RULES = [
|
|
|
49
49
|
report(null, `${problem.message} (${problem.file}:${problem.line})`);
|
|
50
50
|
},
|
|
51
51
|
},
|
|
52
|
+
{
|
|
53
|
+
name: 'html-structure',
|
|
54
|
+
severity: 'warning',
|
|
55
|
+
run(page, report) {
|
|
56
|
+
// What a tag may hold directly, and what it must sit in: the HTML content model, for the tags a WrSection can be. A node under a component
|
|
57
|
+
// we do not read (`viaComponent`) is not judged: that component may draw the list around it.
|
|
58
|
+
const OWN_CHILDREN = { ul: ['li'], ol: ['li'], menu: ['li'] };
|
|
59
|
+
const MUST_SIT_IN = { li: ['ul', 'ol', 'menu'], dt: ['dl', 'div'], dd: ['dl', 'div'], figcaption: ['figure'], summary: ['details'] };
|
|
60
|
+
const INTERACTIVE = new Set(['a', 'button']);
|
|
61
|
+
const visit = (node, parent, interactive) => {
|
|
62
|
+
if (!node.viaComponent) {
|
|
63
|
+
const allowed = parent ? OWN_CHILDREN[parent.tag] : undefined;
|
|
64
|
+
if (parent && allowed && !allowed.includes(node.tag))
|
|
65
|
+
report(node, `A <${node.tag}> inside a <${parent.tag}>: a list holds only <${allowed.join('>, <')}>.`);
|
|
66
|
+
const sits = MUST_SIT_IN[node.tag];
|
|
67
|
+
if (parent && sits && !sits.includes(parent.tag) && !parent.children.some(c => c.viaComponent))
|
|
68
|
+
report(node, `A <${node.tag}> must be inside ${sits.map(t => `a <${t}>`).join(' or ')}, not a <${parent.tag}>.`);
|
|
69
|
+
}
|
|
70
|
+
// Links and buttons cannot hold each other: a browser repairs it in a way that breaks the page
|
|
71
|
+
if (INTERACTIVE.has(node.tag) && interactive && !node.attrs['{spread}'])
|
|
72
|
+
report(node, `A <${node.tag}> inside a <${interactive.tag}>: an interactive element cannot hold another.`);
|
|
73
|
+
const next = INTERACTIVE.has(node.tag) ? node : interactive;
|
|
74
|
+
for (const child of node.children)
|
|
75
|
+
visit(child, node, next);
|
|
76
|
+
};
|
|
77
|
+
for (const node of page.nodes)
|
|
78
|
+
visit(node, null, null);
|
|
79
|
+
},
|
|
80
|
+
},
|
|
52
81
|
{
|
|
53
82
|
name: 'img-alt',
|
|
54
83
|
severity: 'warning',
|
|
@@ -114,6 +143,14 @@ export const RULES = [
|
|
|
114
143
|
report(null, `The structured data of the page is not valid: ${problem}. Search engines ignore it.`);
|
|
115
144
|
},
|
|
116
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
|
+
},
|
|
117
154
|
{
|
|
118
155
|
name: 'broken-link',
|
|
119
156
|
severity: 'warning',
|
|
@@ -432,6 +469,14 @@ export const SITE_RULES = [
|
|
|
432
469
|
run: (site, report) => { for (const problem of wrJsonLdProblems(site.seo.jsonLd))
|
|
433
470
|
report(`The structured data of the site is not valid: ${problem}. Search engines ignore it.`, undefined); },
|
|
434
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
|
+
},
|
|
435
480
|
{
|
|
436
481
|
name: 'focus-visible-css',
|
|
437
482
|
severity: 'warning',
|
package/dist/check/types.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface CheckNode {
|
|
|
26
26
|
key: string;
|
|
27
27
|
side: string;
|
|
28
28
|
}[];
|
|
29
|
+
/** Only the code producer says it: the node sits under a component we do not read (`<Card>`), so what is around it may be drawn by that component; the nesting rules do not judge it. */
|
|
30
|
+
viaComponent?: true;
|
|
29
31
|
/** Shown as hidden in the JSON: the checks do not count it (it does not render), an outline says so. */
|
|
30
32
|
hidden?: true;
|
|
31
33
|
/** What the element says in each language, for the fields that are translated (`content`, `alt`, `ariaLabel`): field → language key → value. */
|
|
@@ -64,6 +66,8 @@ export interface CheckPage {
|
|
|
64
66
|
family: boolean;
|
|
65
67
|
/** What is wrong with the page's JSON-LD (empty when it is fine). */
|
|
66
68
|
jsonLdProblems: string[];
|
|
69
|
+
/** What the page's head says twice (`wrSeoDuplicates`), in the language the page was read in. */
|
|
70
|
+
duplicates: string[];
|
|
67
71
|
};
|
|
68
72
|
}
|
|
69
73
|
/** Where a link of the site may lead. */
|
|
@@ -108,7 +112,8 @@ export interface CheckSite {
|
|
|
108
112
|
/** What `index.html` says (the page every route is served in); null while the project has none. */
|
|
109
113
|
index: {
|
|
110
114
|
lang: string | null;
|
|
111
|
-
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[];
|
|
112
117
|
} | null;
|
|
113
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. */
|
|
114
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.
|
|
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.
|
|
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",
|