@webround/editor 0.0.0-stage → 0.3.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/dist/blocks.d.ts +90 -0
- package/dist/blocks.js +119 -0
- package/dist/bridge.d.ts +96 -0
- package/dist/bridge.js +970 -0
- package/dist/check/aria.d.ts +19 -0
- package/dist/check/aria.js +106 -0
- package/dist/check/cli.d.ts +3 -0
- package/dist/check/cli.js +30 -0
- package/dist/check/css.d.ts +13 -0
- package/dist/check/css.js +63 -0
- package/dist/check/html.d.ts +8 -0
- package/dist/check/html.js +29 -0
- package/dist/check/index.d.ts +4 -0
- package/dist/check/index.js +5 -0
- package/dist/check/links.d.ts +7 -0
- package/dist/check/links.js +23 -0
- package/dist/check/orphans.d.ts +20 -0
- package/dist/check/orphans.js +57 -0
- package/dist/check/producer-code.d.ts +53 -0
- package/dist/check/producer-code.js +379 -0
- package/dist/check/producer-dom.d.ts +21 -0
- package/dist/check/producer-dom.js +52 -0
- package/dist/check/project.d.ts +20 -0
- package/dist/check/project.js +51 -0
- package/dist/check/rules.d.ts +7 -0
- package/dist/check/rules.js +517 -0
- package/dist/check/types.d.ts +160 -0
- package/dist/check/types.js +22 -0
- package/dist/components/WrAltcha.d.ts +5 -0
- package/dist/components/WrAltcha.js +8 -0
- package/dist/components/WrButton.d.ts +8 -0
- package/dist/components/WrButton.js +34 -0
- package/dist/components/WrForm.d.ts +6 -0
- package/dist/components/WrForm.js +29 -0
- package/dist/components/WrFormPrivacy.d.ts +4 -0
- package/dist/components/WrFormPrivacy.js +7 -0
- package/dist/components/WrFormSuccess.d.ts +4 -0
- package/dist/components/WrFormSuccess.js +12 -0
- package/dist/components/WrIframe.d.ts +12 -0
- package/dist/components/WrIframe.js +30 -0
- package/dist/components/WrImage.d.ts +9 -0
- package/dist/components/WrImage.js +28 -0
- package/dist/components/WrInput.d.ts +8 -0
- package/dist/components/WrInput.js +29 -0
- package/dist/components/WrLink.d.ts +10 -0
- package/dist/components/WrLink.js +38 -0
- package/dist/components/WrNav.d.ts +26 -0
- package/dist/components/WrNav.js +30 -0
- package/dist/components/WrSection.d.ts +11 -0
- package/dist/components/WrSection.js +5 -0
- package/dist/components/WrShared.d.ts +12 -0
- package/dist/components/WrShared.js +20 -0
- package/dist/components/WrText.d.ts +11 -0
- package/dist/components/WrText.js +41 -0
- package/dist/components/WrVideo.d.ts +9 -0
- package/dist/components/WrVideo.js +73 -0
- package/dist/components/text-editor/CodeThemeContext.d.ts +8 -0
- package/dist/components/text-editor/CodeThemeContext.js +10 -0
- package/dist/components/text-editor/ExampleTheme.d.ts +77 -0
- package/dist/components/text-editor/ExampleTheme.js +76 -0
- package/dist/components/text-editor/ToolbarContext.d.ts +17 -0
- package/dist/components/text-editor/ToolbarContext.js +32 -0
- package/dist/components/text-editor/WrTextEditor.d.ts +13 -0
- package/dist/components/text-editor/WrTextEditor.js +48 -0
- package/dist/components/text-editor/common/constants.d.ts +109 -0
- package/dist/components/text-editor/common/constants.js +58 -0
- package/dist/components/text-editor/common/surface.d.ts +3 -0
- package/dist/components/text-editor/common/surface.js +8 -0
- package/dist/components/text-editor/common/utils.d.ts +7 -0
- package/dist/components/text-editor/common/utils.js +78 -0
- package/dist/components/text-editor/hooks/useLexicalCommitOnBlur.d.ts +5 -0
- package/dist/components/text-editor/hooks/useLexicalCommitOnBlur.js +41 -0
- package/dist/components/text-editor/hooks/useLexicalMarkdown.d.ts +33 -0
- package/dist/components/text-editor/hooks/useLexicalMarkdown.js +284 -0
- package/dist/components/text-editor/nodes/CodeBlockNode.d.ts +23 -0
- package/dist/components/text-editor/nodes/CodeBlockNode.js +138 -0
- package/dist/components/text-editor/nodes/HorizontalRuleNode.d.ts +13 -0
- package/dist/components/text-editor/nodes/HorizontalRuleNode.js +22 -0
- package/dist/components/text-editor/plugins/CodeBlockPlugin.d.ts +2 -0
- package/dist/components/text-editor/plugins/CodeBlockPlugin.js +38 -0
- package/dist/components/text-editor/plugins/FontPlugin.d.ts +12 -0
- package/dist/components/text-editor/plugins/FontPlugin.js +114 -0
- package/dist/components/text-editor/plugins/FontSizePlugin.d.ts +7 -0
- package/dist/components/text-editor/plugins/FontSizePlugin.js +105 -0
- package/dist/components/text-editor/plugins/FontWeightPlugin.d.ts +6 -0
- package/dist/components/text-editor/plugins/FontWeightPlugin.js +103 -0
- package/dist/components/text-editor/plugins/HTMLTagPlugin.d.ts +7 -0
- package/dist/components/text-editor/plugins/HTMLTagPlugin.js +82 -0
- package/dist/components/text-editor/plugins/HorizontalRulePlugin.d.ts +2 -0
- package/dist/components/text-editor/plugins/HorizontalRulePlugin.js +35 -0
- package/dist/components/text-editor/plugins/LineSpacingPlugin.d.ts +7 -0
- package/dist/components/text-editor/plugins/LineSpacingPlugin.js +83 -0
- package/dist/components/text-editor/plugins/LinkPlugin.d.ts +8 -0
- package/dist/components/text-editor/plugins/LinkPlugin.js +183 -0
- package/dist/components/text-editor/plugins/ListToolbarPlugin.d.ts +7 -0
- package/dist/components/text-editor/plugins/ListToolbarPlugin.js +40 -0
- package/dist/components/text-editor/plugins/LoadContentPlugin.d.ts +4 -0
- package/dist/components/text-editor/plugins/LoadContentPlugin.js +63 -0
- package/dist/components/text-editor/plugins/MarkdownExportPlugin.d.ts +4 -0
- package/dist/components/text-editor/plugins/MarkdownExportPlugin.js +17 -0
- package/dist/components/text-editor/plugins/MarkdownPastePlugin.d.ts +2 -0
- package/dist/components/text-editor/plugins/MarkdownPastePlugin.js +45 -0
- package/dist/components/text-editor/plugins/SanitizePastePlugin.d.ts +3 -0
- package/dist/components/text-editor/plugins/SanitizePastePlugin.js +40 -0
- package/dist/components/text-editor/plugins/TextAlignPlugin.d.ts +7 -0
- package/dist/components/text-editor/plugins/TextAlignPlugin.js +65 -0
- package/dist/components/text-editor/plugins/TextColorPlugin.d.ts +7 -0
- package/dist/components/text-editor/plugins/TextColorPlugin.js +117 -0
- package/dist/components/text-editor/plugins/ToolbarPlugin.d.ts +10 -0
- package/dist/components/text-editor/plugins/ToolbarPlugin.js +73 -0
- package/dist/components/text-editor/theme-colors.d.ts +11 -0
- package/dist/components/text-editor/theme-colors.js +28 -0
- package/dist/context/WrAppContext.d.ts +35 -0
- package/dist/context/WrAppContext.js +113 -0
- package/dist/context/WrLayout.d.ts +15 -0
- package/dist/context/WrLayout.js +22 -0
- package/dist/context/WrPageContext.d.ts +28 -0
- package/dist/context/WrPageContext.js +282 -0
- package/dist/context/restoreValues.d.ts +2 -0
- package/dist/context/restoreValues.js +6 -0
- package/dist/contrast.d.ts +45 -0
- package/dist/contrast.js +122 -0
- package/dist/drop.d.ts +7 -0
- package/dist/drop.js +51 -0
- package/dist/gaps.d.ts +14 -0
- package/dist/gaps.js +50 -0
- package/dist/hooks/useWrMessage.d.ts +5 -0
- package/dist/hooks/useWrMessage.js +24 -0
- package/dist/hooks/useWrSave.d.ts +2 -0
- package/dist/hooks/useWrSave.js +11 -0
- package/dist/index.css +467 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +20 -0
- package/dist/interactive.d.ts +8 -0
- package/dist/interactive.js +13 -0
- package/dist/measure-rules.d.ts +34 -0
- package/dist/measure-rules.js +54 -0
- package/dist/measure.d.ts +11 -0
- package/dist/measure.js +139 -0
- package/dist/messages.d.ts +860 -0
- package/dist/messages.js +2 -0
- package/dist/nav.d.ts +5 -0
- package/dist/nav.js +10 -0
- package/dist/operations/animations-store.d.ts +6 -0
- package/dist/operations/animations-store.js +63 -0
- package/dist/operations/cli.d.ts +3 -0
- package/dist/operations/cli.js +52 -0
- package/dist/operations/comments-store.d.ts +17 -0
- package/dist/operations/comments-store.js +62 -0
- package/dist/operations/context.d.ts +12 -0
- package/dist/operations/context.js +35 -0
- package/dist/operations/define.d.ts +81 -0
- package/dist/operations/define.js +86 -0
- package/dist/operations/index.d.ts +6 -0
- package/dist/operations/index.js +6 -0
- package/dist/operations/ops/check.d.ts +5 -0
- package/dist/operations/ops/check.js +53 -0
- package/dist/operations/ops/comments.d.ts +6 -0
- package/dist/operations/ops/comments.js +133 -0
- package/dist/operations/ops/content.d.ts +16 -0
- package/dist/operations/ops/content.js +73 -0
- package/dist/operations/ops/json.d.ts +4 -0
- package/dist/operations/ops/json.js +27 -0
- package/dist/operations/ops/langs.d.ts +4 -0
- package/dist/operations/ops/langs.js +32 -0
- package/dist/operations/ops/nodes.d.ts +8 -0
- package/dist/operations/ops/nodes.js +270 -0
- package/dist/operations/ops/pages.d.ts +8 -0
- package/dist/operations/ops/pages.js +154 -0
- package/dist/operations/ops/project.d.ts +36 -0
- package/dist/operations/ops/project.js +97 -0
- package/dist/operations/ops/seo.d.ts +17 -0
- package/dist/operations/ops/seo.js +358 -0
- package/dist/operations/ops/style.d.ts +76 -0
- package/dist/operations/ops/style.js +268 -0
- package/dist/operations/ops/stylesets.d.ts +9 -0
- package/dist/operations/ops/stylesets.js +145 -0
- package/dist/operations/ops/theme.d.ts +6 -0
- package/dist/operations/ops/theme.js +304 -0
- package/dist/operations/registry.d.ts +8 -0
- package/dist/operations/registry.js +19 -0
- package/dist/operations/stylesets-store.d.ts +50 -0
- package/dist/operations/stylesets-store.js +216 -0
- package/dist/registry.d.ts +5 -0
- package/dist/registry.js +24 -0
- package/dist/save.d.ts +65 -0
- package/dist/save.js +86 -0
- package/dist/schema.d.ts +69 -0
- package/dist/schema.js +110 -0
- package/dist/scope.d.ts +41 -0
- package/dist/scope.js +44 -0
- package/dist/vite-plugins/ast.d.ts +3 -0
- package/dist/vite-plugins/ast.js +238 -0
- package/dist/vite-plugins/core-swap.d.ts +3 -0
- package/dist/vite-plugins/core-swap.js +57 -0
- package/dist/vite-plugins/create-page.d.ts +4 -0
- package/dist/vite-plugins/create-page.js +59 -0
- package/dist/vite-plugins/guard.d.ts +15 -0
- package/dist/vite-plugins/guard.js +35 -0
- package/dist/vite-plugins/head.d.ts +13 -0
- package/dist/vite-plugins/head.js +53 -0
- package/dist/vite-plugins/index.d.ts +7 -0
- package/dist/vite-plugins/index.js +7 -0
- package/dist/vite-plugins/jsx-tree.d.ts +118 -0
- package/dist/vite-plugins/jsx-tree.js +686 -0
- package/dist/vite-plugins/langs.d.ts +3 -0
- package/dist/vite-plugins/langs.js +44 -0
- package/dist/vite-plugins/operations.d.ts +10 -0
- package/dist/vite-plugins/operations.js +66 -0
- package/dist/vite-plugins/pages-api.d.ts +3 -0
- package/dist/vite-plugins/pages-api.js +112 -0
- package/dist/vite-plugins/pages.d.ts +3 -0
- package/dist/vite-plugins/pages.js +64 -0
- package/dist/vite-plugins/read.d.ts +3 -0
- package/dist/vite-plugins/read.js +45 -0
- package/dist/vite-plugins/seo-files.d.ts +8 -0
- package/dist/vite-plugins/seo-files.js +42 -0
- package/dist/vite-plugins/theme.d.ts +3 -0
- package/dist/vite-plugins/theme.js +207 -0
- package/dist/vite-plugins/update.d.ts +3 -0
- package/dist/vite-plugins/update.js +46 -0
- package/dist/vite-plugins/wr-plugins.d.ts +15 -0
- package/dist/vite-plugins/wr-plugins.js +34 -0
- package/dist/vite-plugins.d.ts +11 -0
- package/dist/vite-plugins.js +518 -0
- package/package.json +96 -5
- package/README.md +0 -3
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { wrPageConfigPath } from '@webround/core/routing';
|
|
4
|
+
import { WR_SEO_KEY, wrJsonLdProblems, wrSearchConsoleCode, wrSeoFiles, wrSnapshotTargets, wrSeoPageOf, wrSiteUrlOf } from '@webround/core/seo';
|
|
5
|
+
import { wrLocalizeItems, wrSetLocalized } from '@webround/core/localized';
|
|
6
|
+
import { defineOperation, flag, invalid, list, OperationError, text } from '../define.js';
|
|
7
|
+
import { checkScopeFromCode } from '../../check/producer-code.js';
|
|
8
|
+
import { SEO_FILE, langKeyOf, patchJsonFile, readJsonFile, readLangs, readRoutes, readSeoSite, scopeFilesOf } from './project.js';
|
|
9
|
+
// What search engines and social networks read: a page's title, description, picture, noindex (under `_seo` in its JSON, translated like any
|
|
10
|
+
// text) and the site's own data (`config/seo.json`). The tags are built by `@webround/core/seo` and the files (robots.txt, the sitemaps) by
|
|
11
|
+
// `wrSeoFiles`: this file only reads and writes the data, for the editor, `wr`, an MCP client and the dev server's watcher alike.
|
|
12
|
+
const PAGE_FIELDS = ['title', 'description'];
|
|
13
|
+
const LANGUAGE_FREE_FIELDS = ['canonical'];
|
|
14
|
+
/** `https://www.example.com` from whatever was typed: only the address, no path. Throws when it is not one. */
|
|
15
|
+
function siteUrlFrom(value) {
|
|
16
|
+
let url;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(value.trim());
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw invalid('siteUrl must be a full address, e.g. https://www.example.com', 'invalid-site-url');
|
|
22
|
+
}
|
|
23
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
24
|
+
throw invalid('siteUrl must start with https:// (or http://)', 'invalid-site-url');
|
|
25
|
+
if (url.pathname !== '/' || url.search || url.hash)
|
|
26
|
+
throw invalid('siteUrl is the address of the site without a path', 'invalid-site-url');
|
|
27
|
+
return url.origin;
|
|
28
|
+
}
|
|
29
|
+
/** Every page's JSON, by page name: what `wrSeoFiles` needs to know who is noindex. */
|
|
30
|
+
function readPages(ctx) {
|
|
31
|
+
return Object.fromEntries(Object.keys(readRoutes(ctx)).map(name => [name, readJsonFile(ctx, wrPageConfigPath(name)) ?? undefined]));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Writes robots.txt and the sitemaps into `public/` (only what changed). Nothing is written while the site does not know its public address.
|
|
35
|
+
* ONE function: the dev server's watcher calls it whenever pages, languages or SEO data change, `seo-sync` calls it on demand.
|
|
36
|
+
*/
|
|
37
|
+
export function writeSeoFiles(ctx) {
|
|
38
|
+
const files = wrSeoFiles({ routes: readRoutes(ctx), pages: readPages(ctx), langs: readLangs(ctx) ?? undefined, site: readSeoSite(ctx) });
|
|
39
|
+
if (!files)
|
|
40
|
+
return { written: [], reason: 'no-site-url' };
|
|
41
|
+
const written = [];
|
|
42
|
+
fs.mkdirSync(ctx.publicRoot, { recursive: true });
|
|
43
|
+
for (const [name, content] of Object.entries(files)) {
|
|
44
|
+
const file = path.join(ctx.publicRoot, name);
|
|
45
|
+
if (fs.existsSync(file) && fs.readFileSync(file, 'utf-8') === content)
|
|
46
|
+
continue;
|
|
47
|
+
fs.writeFileSync(file, content);
|
|
48
|
+
written.push(name);
|
|
49
|
+
}
|
|
50
|
+
return { written };
|
|
51
|
+
}
|
|
52
|
+
export const seoGet = defineOperation({
|
|
53
|
+
name: 'seo-get',
|
|
54
|
+
readOnly: true,
|
|
55
|
+
summary: 'What search engines and social networks read: the site\'s data (address, name, title template, favicon, picture, extra sitemaps) and, for a page, its title and description in every language, picture, noindex and canonical.',
|
|
56
|
+
params: [{ name: 'page', type: 'string', positional: true, help: 'a page; without it only the site\'s data' }],
|
|
57
|
+
run: (input, ctx) => {
|
|
58
|
+
const page = text(input, 'page');
|
|
59
|
+
const site = readSeoSite(ctx);
|
|
60
|
+
return {
|
|
61
|
+
site,
|
|
62
|
+
siteUrlKnown: wrSiteUrlOf(site) !== undefined,
|
|
63
|
+
langs: readLangs(ctx),
|
|
64
|
+
...(page ? { page: { pageName: page, seo: wrSeoPageOf(readJsonFile(ctx, scopeFilesOf(ctx, page).json) ?? undefined) } } : {}),
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const TITLE_SUGGESTED_MAX = 60;
|
|
69
|
+
const DESCRIPTION_SUGGESTED_MAX = 155;
|
|
70
|
+
/** `text` cut at a word, with an ellipsis, so that it fits in `max` characters. */
|
|
71
|
+
function fitted(text, max) {
|
|
72
|
+
if (text.length <= max)
|
|
73
|
+
return text;
|
|
74
|
+
const cut = text.slice(0, max - 1);
|
|
75
|
+
return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), max / 2))}…`.replace(/[\s,;:.-]+…$/, '…');
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The text of the page that is written in a language, in document order: the elements read for that language (a rich text's headings and
|
|
79
|
+
* paragraphs included). In a language other than the default only what is TRANSLATED counts — an element whose text comes from the code, or
|
|
80
|
+
* a rich text with no translation, would be the default language's words under another name.
|
|
81
|
+
*/
|
|
82
|
+
function wordsIn(nodes, lang, translated = lang === 'default') {
|
|
83
|
+
return nodes.flatMap(node => {
|
|
84
|
+
if (node.hidden)
|
|
85
|
+
return [];
|
|
86
|
+
// An element that holds a translated field says whether it is; what is made of its text (a rich text's headings) follows it
|
|
87
|
+
const own = node.values?.content ? lang === 'default' || lang in node.values.content : translated;
|
|
88
|
+
return [...(node.text && own ? [{ tag: node.tag, text: node.text }] : []), ...wordsIn(node.children, lang, own)];
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export const seoSuggest = defineOperation({
|
|
92
|
+
name: 'seo-suggest',
|
|
93
|
+
readOnly: true,
|
|
94
|
+
summary: 'A title and a description for a page, taken from what it says: its first heading and its first paragraph, in the language asked (nothing for a language the page is not translated in). Only a proposal: nothing is written.',
|
|
95
|
+
params: [
|
|
96
|
+
{ name: 'page', type: 'string', positional: true, required: true, help: 'the page' },
|
|
97
|
+
{ name: 'lang', type: 'string', help: 'the language: `default` (the default one) or a language code of the site' },
|
|
98
|
+
],
|
|
99
|
+
run: (input, ctx) => {
|
|
100
|
+
const page = text(input, 'page', { required: true });
|
|
101
|
+
const lang = langKeyOf(ctx, text(input, 'lang'));
|
|
102
|
+
// The page's own code, not the shared header and footer: their text says what the site is, not what the page is
|
|
103
|
+
const words = wordsIn(checkScopeFromCode(ctx, scopeFilesOf(ctx, page), { lang }).nodes, lang);
|
|
104
|
+
const heading = words.find(w => w.tag === 'h1')?.text;
|
|
105
|
+
const paragraph = words.find(w => w.tag === 'p' && w.text.length >= 20)?.text;
|
|
106
|
+
return {
|
|
107
|
+
page, lang,
|
|
108
|
+
...(heading ? { title: fitted(heading, TITLE_SUGGESTED_MAX) } : {}),
|
|
109
|
+
...(paragraph ? { description: fitted(paragraph, DESCRIPTION_SUGGESTED_MAX) } : {}),
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
114
|
+
/** A translated text with one language written (an empty text takes it away); undefined when nothing is left of it. */
|
|
115
|
+
function writeText(current, lang, value) {
|
|
116
|
+
const map = { ...wrSetLocalized(current, lang, value.trim()) };
|
|
117
|
+
if (!value.trim())
|
|
118
|
+
delete map[lang];
|
|
119
|
+
return Object.keys(map).length === 0 ? undefined : map;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* `writeText` for a value that is the same for every language until one is written for a language of its own (a name, a picture): while only the default language has it, it is
|
|
123
|
+
* the plain text it always was; once another language has one it is a map.
|
|
124
|
+
*/
|
|
125
|
+
function writeLocalized(current, lang, value) {
|
|
126
|
+
const map = writeText(current, lang, value);
|
|
127
|
+
if (!map)
|
|
128
|
+
return undefined;
|
|
129
|
+
const languages = Object.keys(map);
|
|
130
|
+
return languages.length === 1 && languages[0] === 'default' ? map.default : map;
|
|
131
|
+
}
|
|
132
|
+
/** What a caller gave as JSON-LD (a list, one object, or JSON text of either) as a list of objects; throws when it is not JSON-LD. */
|
|
133
|
+
function jsonLdItems(input, key) {
|
|
134
|
+
let value = input[key];
|
|
135
|
+
if (typeof value === 'string') {
|
|
136
|
+
if (!value.trim())
|
|
137
|
+
return [];
|
|
138
|
+
try {
|
|
139
|
+
value = JSON.parse(value);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw invalid(`${key} is not valid JSON`, 'invalid-json-ld');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const items = Array.isArray(value) ? value : value === null || value === undefined ? [] : [value];
|
|
146
|
+
const problems = wrJsonLdProblems(items);
|
|
147
|
+
if (problems.length > 0)
|
|
148
|
+
throw invalid(`${problems.join('; ')}`, 'invalid-json-ld');
|
|
149
|
+
return items;
|
|
150
|
+
}
|
|
151
|
+
/** The JSON-LD of a page or the site with one language's list written: a list while it is the same for every language, a map once a language has its own. */
|
|
152
|
+
function writeJsonLd(current, lang, items) {
|
|
153
|
+
const map = Array.isArray(current) ? { default: current } : isPlainObject(current) ? { ...current } : {};
|
|
154
|
+
if (items.length === 0)
|
|
155
|
+
delete map[lang];
|
|
156
|
+
else
|
|
157
|
+
map[lang] = items;
|
|
158
|
+
const languages = Object.keys(map);
|
|
159
|
+
if (languages.length === 0)
|
|
160
|
+
return undefined;
|
|
161
|
+
return languages.length === 1 && languages[0] === 'default' ? map.default : map;
|
|
162
|
+
}
|
|
163
|
+
export const seoSet = defineOperation({
|
|
164
|
+
name: 'seo-set',
|
|
165
|
+
summary: 'Sets what a page tells search engines and social networks. Title, description, pictures and the texts for social networks are written in one language (the default one unless `lang` says otherwise); an empty text takes the value away.',
|
|
166
|
+
params: [
|
|
167
|
+
{ name: 'page', type: 'string', positional: true, required: true, help: 'the page' },
|
|
168
|
+
{ name: 'lang', type: 'string', help: 'the language of the texts and pictures (title, description, image, ogTitle, ogDescription, ogImage, meta, jsonLd): `default` (the default one) or a language code of the site' },
|
|
169
|
+
{ name: 'title', type: 'string', help: 'the title of the page (what the browser tab and search results show)' },
|
|
170
|
+
{ name: 'description', type: 'string', help: 'a sentence or two for search results (about 150 characters)' },
|
|
171
|
+
{ name: 'image', type: 'string', help: 'picture shown when the page is shared: a path in the site (/media/x.jpg) or a full address (written in `lang`: a language with none uses the default one\'s)' },
|
|
172
|
+
{ name: 'noindex', type: 'boolean', help: 'keep the page out of search engines and out of the sitemap' },
|
|
173
|
+
{ name: 'canonical', type: 'string', help: 'the page this one is a copy of (a path in the site or a full address)' },
|
|
174
|
+
{ name: 'robots', type: 'string', help: 'the robots directives in full, e.g. "noindex, follow" or "max-snippet:-1"; they replace what noindex says' },
|
|
175
|
+
{ name: 'ogTitle', type: 'string', help: 'the title social networks show, when it must not be the page\'s own (written in `lang`)' },
|
|
176
|
+
{ name: 'ogDescription', type: 'string', help: 'the description social networks show, when it must not be the page\'s own (written in `lang`)' },
|
|
177
|
+
{ name: 'ogImage', type: 'string', help: 'the picture social networks show, when it must not be the page\'s own (written in `lang`)' },
|
|
178
|
+
{ name: 'ogType', type: 'string', help: 'the Open Graph type: website (the default), article, product…' },
|
|
179
|
+
{ name: 'twitterCard', type: 'string', choices: ['summary', 'summary_large_image'], help: 'how the page looks when shared on X/Twitter' },
|
|
180
|
+
{ name: 'twitterSite', type: 'string', help: 'the account of the site on X/Twitter (@acme), for this page' },
|
|
181
|
+
{ name: 'twitterCreator', type: 'string', help: 'the account of the author on X/Twitter (@jane), for this page' },
|
|
182
|
+
{ name: 'meta', type: 'array', help: 'any other <meta> the page needs: a list of { name | property, content }, the whole list; content is written in `lang`' },
|
|
183
|
+
{ name: 'jsonLd', type: 'any', help: 'structured data (JSON-LD) of this page: a list of objects with a "@type" (or JSON text of one), written for `lang`; [] takes it away. It is added to the site\'s' },
|
|
184
|
+
],
|
|
185
|
+
run: (input, ctx) => {
|
|
186
|
+
const page = text(input, 'page', { required: true });
|
|
187
|
+
const json = scopeFilesOf(ctx, page).json;
|
|
188
|
+
const current = wrSeoPageOf(readJsonFile(ctx, json) ?? undefined);
|
|
189
|
+
const next = { ...current };
|
|
190
|
+
const lang = langKeyOf(ctx, text(input, 'lang'));
|
|
191
|
+
const set = (key, value) => { if (value === undefined)
|
|
192
|
+
delete next[key];
|
|
193
|
+
else
|
|
194
|
+
next[key] = value; };
|
|
195
|
+
for (const field of PAGE_FIELDS) {
|
|
196
|
+
const value = text(input, field);
|
|
197
|
+
if (value !== undefined)
|
|
198
|
+
set(field, writeText(current[field], lang, value));
|
|
199
|
+
}
|
|
200
|
+
for (const field of LANGUAGE_FREE_FIELDS) {
|
|
201
|
+
const value = text(input, field);
|
|
202
|
+
if (value === undefined)
|
|
203
|
+
continue;
|
|
204
|
+
if (value.trim())
|
|
205
|
+
next[field] = value.trim();
|
|
206
|
+
else
|
|
207
|
+
delete next[field];
|
|
208
|
+
}
|
|
209
|
+
const image = text(input, 'image');
|
|
210
|
+
if (image !== undefined)
|
|
211
|
+
set('image', writeLocalized(current.image, lang, image));
|
|
212
|
+
const noindex = flag(input, 'noindex');
|
|
213
|
+
if (noindex !== undefined) {
|
|
214
|
+
if (noindex)
|
|
215
|
+
next.noindex = true;
|
|
216
|
+
else
|
|
217
|
+
delete next.noindex;
|
|
218
|
+
}
|
|
219
|
+
const robots = text(input, 'robots');
|
|
220
|
+
if (robots !== undefined)
|
|
221
|
+
set('robots', robots.trim() || undefined);
|
|
222
|
+
// What social networks show: an object that exists only while it has something in it
|
|
223
|
+
const og = { ...(current.og ?? {}) };
|
|
224
|
+
const ogSet = (key, value) => { if (value === undefined)
|
|
225
|
+
delete og[key];
|
|
226
|
+
else
|
|
227
|
+
og[key] = value; };
|
|
228
|
+
for (const [param, key] of [['ogTitle', 'title'], ['ogDescription', 'description']]) {
|
|
229
|
+
const value = text(input, param);
|
|
230
|
+
if (value !== undefined)
|
|
231
|
+
ogSet(key, writeText(og[key], lang, value));
|
|
232
|
+
}
|
|
233
|
+
const ogImage = text(input, 'ogImage');
|
|
234
|
+
if (ogImage !== undefined)
|
|
235
|
+
ogSet('image', writeLocalized(og.image, lang, ogImage));
|
|
236
|
+
const ogType = text(input, 'ogType');
|
|
237
|
+
if (ogType !== undefined)
|
|
238
|
+
ogSet('type', ogType.trim() || undefined);
|
|
239
|
+
if (['ogTitle', 'ogDescription', 'ogImage', 'ogType'].some(param => input[param] !== undefined))
|
|
240
|
+
set('og', Object.keys(og).length > 0 ? og : undefined);
|
|
241
|
+
const twitter = { ...(current.twitter ?? {}) };
|
|
242
|
+
for (const [param, key] of [['twitterCard', 'card'], ['twitterSite', 'site'], ['twitterCreator', 'creator']]) {
|
|
243
|
+
const value = text(input, param);
|
|
244
|
+
if (value === undefined)
|
|
245
|
+
continue;
|
|
246
|
+
if (key === 'card' && value.trim() && !['summary', 'summary_large_image'].includes(value.trim()))
|
|
247
|
+
throw invalid('twitterCard is summary or summary_large_image', 'invalid-value');
|
|
248
|
+
if (value.trim())
|
|
249
|
+
twitter[key] = value.trim();
|
|
250
|
+
else
|
|
251
|
+
delete twitter[key];
|
|
252
|
+
}
|
|
253
|
+
if (['twitterCard', 'twitterSite', 'twitterCreator'].some(param => input[param] !== undefined))
|
|
254
|
+
set('twitter', Object.keys(twitter).length > 0 ? twitter : undefined);
|
|
255
|
+
if (input.meta !== undefined) {
|
|
256
|
+
const given = input.meta;
|
|
257
|
+
if (!Array.isArray(given) || given.some(entry => !isPlainObject(entry) || (typeof entry.name !== 'string' && typeof entry.property !== 'string') || typeof entry.content !== 'string')) {
|
|
258
|
+
throw invalid('meta must be a list of { name or property, content }', 'invalid-value');
|
|
259
|
+
}
|
|
260
|
+
const entries = wrLocalizeItems(current.meta, given, ['content'], lang);
|
|
261
|
+
set('meta', entries.length > 0 ? entries : undefined);
|
|
262
|
+
}
|
|
263
|
+
if (input.jsonLd !== undefined)
|
|
264
|
+
set('jsonLd', writeJsonLd(current.jsonLd, lang, jsonLdItems(input, 'jsonLd')));
|
|
265
|
+
patchJsonFile(ctx, json, { [WR_SEO_KEY]: Object.keys(next).length > 0 ? next : null });
|
|
266
|
+
return { page, seo: next };
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
export const seoSiteSet = defineOperation({
|
|
270
|
+
name: 'seo-site-set',
|
|
271
|
+
summary: 'Sets what is the same for the whole site: its public address (needed for canonical links, hreflang and the sitemaps), name, how page titles are shown, the favicon, the default sharing picture, the Google Search Console verification code, and the sitemaps the site does not write itself. An empty text takes the value away.',
|
|
272
|
+
params: [
|
|
273
|
+
{ name: 'siteUrl', type: 'string', help: 'the public address, no path: https://www.example.com' },
|
|
274
|
+
{ name: 'siteName', type: 'string', help: 'the name of the site (written in `lang`)' },
|
|
275
|
+
{ name: 'titleTemplate', type: 'string', help: 'how a title is shown, %s being the page\'s title: "%s | Acme" (written in `lang`)' },
|
|
276
|
+
{ name: 'favicon', type: 'string', help: 'the icon of the site (browser tab, search results): a path in the site (/favicon.png) or a full address' },
|
|
277
|
+
{ 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
|
+
{ 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
|
+
{ 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' },
|
|
280
|
+
{ name: 'twitterSite', type: 'string', help: 'the account of the site on X/Twitter (@acme), for every page' },
|
|
281
|
+
{ name: 'twitterCreator', type: 'string', help: 'the account of the author on X/Twitter (@jane), for every page' },
|
|
282
|
+
{ name: 'lang', type: 'string', help: 'the language of `siteName`, `titleTemplate`, `image` and `jsonLd`: `default` (the default one) or a language code of the site' },
|
|
283
|
+
{ name: 'jsonLd', type: 'any', help: 'structured data (JSON-LD) of every page — the organisation, the website: a list of objects with a "@type" (or JSON text of one), written for `lang`; [] takes it away' },
|
|
284
|
+
],
|
|
285
|
+
run: (input, ctx) => {
|
|
286
|
+
const patch = {};
|
|
287
|
+
const siteUrl = text(input, 'siteUrl');
|
|
288
|
+
if (siteUrl !== undefined)
|
|
289
|
+
patch.siteUrl = siteUrl.trim() ? siteUrlFrom(siteUrl) : null;
|
|
290
|
+
const siteLang = langKeyOf(ctx, text(input, 'lang'));
|
|
291
|
+
const current = readSeoSite(ctx);
|
|
292
|
+
const favicon = text(input, 'favicon');
|
|
293
|
+
if (favicon !== undefined)
|
|
294
|
+
patch.favicon = favicon.trim() || null;
|
|
295
|
+
for (const field of ['siteName', 'image']) {
|
|
296
|
+
const value = text(input, field);
|
|
297
|
+
if (value !== undefined)
|
|
298
|
+
patch[field] = writeLocalized(current[field], siteLang, value) ?? null;
|
|
299
|
+
}
|
|
300
|
+
const template = text(input, 'titleTemplate');
|
|
301
|
+
if (template !== undefined) {
|
|
302
|
+
if (template.trim() && !template.includes('%s'))
|
|
303
|
+
throw invalid('titleTemplate must contain %s, where the page\'s title goes', 'invalid-title-template');
|
|
304
|
+
patch.titleTemplate = writeLocalized(current.titleTemplate, siteLang, template) ?? null;
|
|
305
|
+
}
|
|
306
|
+
const searchConsole = text(input, 'searchConsole');
|
|
307
|
+
if (searchConsole !== undefined) {
|
|
308
|
+
const code = searchConsole.trim() ? wrSearchConsoleCode(searchConsole) : null;
|
|
309
|
+
if (searchConsole.trim() && !code)
|
|
310
|
+
throw invalid('that is not a Search Console verification code: paste the code, or the <meta name="google-site-verification" …> tag Google gives', 'invalid-search-console');
|
|
311
|
+
patch.searchConsole = code;
|
|
312
|
+
}
|
|
313
|
+
const extra = list(input, 'extraSitemaps');
|
|
314
|
+
if (extra !== undefined) {
|
|
315
|
+
const names = extra.map(name => name.trim()).filter(Boolean);
|
|
316
|
+
if (names.some(name => /\s/.test(name)))
|
|
317
|
+
throw invalid('a sitemap is a file name or an address, without spaces', 'invalid-sitemap');
|
|
318
|
+
patch.extraSitemaps = names.length > 0 ? names : null;
|
|
319
|
+
}
|
|
320
|
+
if (input.twitterSite !== undefined || input.twitterCreator !== undefined) {
|
|
321
|
+
const twitter = { ...(readSeoSite(ctx).twitter ?? {}) };
|
|
322
|
+
for (const [param, key] of [['twitterSite', 'site'], ['twitterCreator', 'creator']]) {
|
|
323
|
+
const value = text(input, param);
|
|
324
|
+
if (value === undefined)
|
|
325
|
+
continue;
|
|
326
|
+
if (value.trim())
|
|
327
|
+
twitter[key] = value.trim();
|
|
328
|
+
else
|
|
329
|
+
delete twitter[key];
|
|
330
|
+
}
|
|
331
|
+
patch.twitter = Object.keys(twitter).length > 0 ? twitter : null;
|
|
332
|
+
}
|
|
333
|
+
if (input.jsonLd !== undefined)
|
|
334
|
+
patch.jsonLd = writeJsonLd(readSeoSite(ctx).jsonLd, langKeyOf(ctx, text(input, 'lang')), jsonLdItems(input, 'jsonLd')) ?? null;
|
|
335
|
+
patchJsonFile(ctx, SEO_FILE, patch);
|
|
336
|
+
const site = readSeoSite(ctx);
|
|
337
|
+
return { site };
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
export const seoSync = defineOperation({
|
|
341
|
+
name: 'seo-sync',
|
|
342
|
+
summary: 'Writes robots.txt, sitemap-index.xml and sitemap-pages.xml in public/ from the pages, the languages and the SEO data. The dev server does it by itself whenever those change: this is for when it is not running. Needs the site\'s public address (seo-site-set).',
|
|
343
|
+
run: (_input, ctx) => {
|
|
344
|
+
const result = writeSeoFiles(ctx);
|
|
345
|
+
if (result.reason === 'no-site-url')
|
|
346
|
+
throw new OperationError(409, 'no-site-url', 'the site does not know its public address yet: set siteUrl with seo-site-set');
|
|
347
|
+
return result;
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
export const seoSnapshotTargets = defineOperation({
|
|
351
|
+
name: 'seo-snapshot-targets',
|
|
352
|
+
readOnly: true,
|
|
353
|
+
summary: 'The pages a build prerenders as HTML for search engines and bots (each page in each language, without those marked noindex), with the address of each and the file it is written to.',
|
|
354
|
+
params: [],
|
|
355
|
+
run: (_input, ctx) => ({ targets: wrSnapshotTargets({ routes: readRoutes(ctx), pages: readPages(ctx), langs: readLangs(ctx) ?? undefined }) }),
|
|
356
|
+
});
|
|
357
|
+
export const SEO_OPERATIONS = [seoGet, seoSuggest, seoSet, seoSiteSet, seoSync, seoSnapshotTargets];
|
|
358
|
+
//# sourceMappingURL=seo.js.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type WrStateId, type WrStyleDescription, type WrStyleOptions } from '@webround/core/style';
|
|
2
|
+
import { type OperationContext, type OperationInput } from '../define.js';
|
|
3
|
+
/**
|
|
4
|
+
* The colors the project's theme names, and how they look: the `--color-*` of its `@theme` (in theme.css, or in index.css for an older site: what makes `text-accent` a class), and
|
|
5
|
+
* the color each one comes to (`--color-accent: var(--wr-color-accent)` → the value of `--wr-color-accent` in theme.css, the light theme's).
|
|
6
|
+
* The starter site's names when the stylesheet says none.
|
|
7
|
+
*/
|
|
8
|
+
export declare function themeColorsOf(ctx: OperationContext): {
|
|
9
|
+
tokens: string[];
|
|
10
|
+
swatches: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
export declare function element(ctx: OperationContext, input: OperationInput): {
|
|
13
|
+
found: {
|
|
14
|
+
node: import("../../vite-plugins/jsx-tree.js").JsxNode;
|
|
15
|
+
full: import("../../vite-plugins/jsx-tree.js").FullJsxNode;
|
|
16
|
+
kind: import("@webround/core/kinds").WrElementKind;
|
|
17
|
+
};
|
|
18
|
+
id: string;
|
|
19
|
+
scope: import("./project.js").ScopeFiles;
|
|
20
|
+
};
|
|
21
|
+
/** The element's classes when they are a plain text (or there are none); an expression (`cn(…)`, a template) is the code's, and is not rewritten. */
|
|
22
|
+
export declare function classesOf(found: ReturnType<typeof element>['found']): {
|
|
23
|
+
className: string;
|
|
24
|
+
} | {
|
|
25
|
+
className: null;
|
|
26
|
+
reason: string;
|
|
27
|
+
};
|
|
28
|
+
export declare const optionsOf: (ctx: OperationContext) => WrStyleOptions;
|
|
29
|
+
/** What a className says for each state (hover, focus, pressed) that it says something for: the states of the properties that can have one. */
|
|
30
|
+
export declare function statesOf(className: string, options: WrStyleOptions): Partial<Record<WrStateId, WrStyleDescription[]>>;
|
|
31
|
+
export declare const styleCatalog: import("../define.js").Operation;
|
|
32
|
+
export declare const styleGet: import("../define.js").Operation;
|
|
33
|
+
/** The shared styles an element's classes use. */
|
|
34
|
+
export declare function setsOf(ctx: OperationContext, className: string): {
|
|
35
|
+
name: string;
|
|
36
|
+
class: string;
|
|
37
|
+
}[];
|
|
38
|
+
export declare const styleSet: import("../define.js").Operation;
|
|
39
|
+
/**
|
|
40
|
+
* The step every change of classes takes: find who is styled (an element, or a shared style by `set`), refuse classes that are code, ask `next` for the text, write it if it changed, say what it is now.
|
|
41
|
+
* What a change IS (a value, a copy between screens) is the table's; this is only reading and writing the file.
|
|
42
|
+
*/
|
|
43
|
+
export declare function changeClasses(ctx: OperationContext, input: OperationInput, next: (className: string, options: WrStyleOptions) => string): {
|
|
44
|
+
set: string;
|
|
45
|
+
changed: boolean;
|
|
46
|
+
previous: string;
|
|
47
|
+
className: string;
|
|
48
|
+
styles: WrStyleDescription[];
|
|
49
|
+
unmanaged: string[];
|
|
50
|
+
states: Partial<Record<"hover" | "focus" | "active", WrStyleDescription[]>>;
|
|
51
|
+
usedBy: number;
|
|
52
|
+
id?: undefined;
|
|
53
|
+
sets?: undefined;
|
|
54
|
+
} | {
|
|
55
|
+
id: string;
|
|
56
|
+
changed: boolean;
|
|
57
|
+
previous: string;
|
|
58
|
+
className: string;
|
|
59
|
+
styles: WrStyleDescription[];
|
|
60
|
+
unmanaged: string[];
|
|
61
|
+
states: Partial<Record<"hover" | "focus" | "active", WrStyleDescription[]>>;
|
|
62
|
+
sets: {
|
|
63
|
+
name: string;
|
|
64
|
+
class: string;
|
|
65
|
+
}[];
|
|
66
|
+
set?: undefined;
|
|
67
|
+
usedBy?: undefined;
|
|
68
|
+
};
|
|
69
|
+
export declare const styleCopy: import("../define.js").Operation;
|
|
70
|
+
/**
|
|
71
|
+
* Puts an element's classes back exactly as they were: what the editor's undo and redo need, because what a change replaced (padding replaces padding-x,
|
|
72
|
+
* a custom width…) cannot be said as another change. Only for the editor's page: an agent has style-set, which says what it means.
|
|
73
|
+
*/
|
|
74
|
+
export declare const styleRestore: import("../define.js").Operation;
|
|
75
|
+
export declare const STYLE_OPERATIONS: import("../define.js").Operation[];
|
|
76
|
+
//# sourceMappingURL=style.d.ts.map
|