@webround/core 0.1.8 → 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/base-css.d.ts +8 -0
- package/dist/base-css.js +19 -0
- package/dist/components/WrAltcha.d.ts +5 -0
- package/dist/components/WrAltcha.js +7 -0
- package/dist/components/WrButton.js +19 -0
- package/dist/components/WrForm.d.ts +7 -0
- package/dist/components/WrForm.js +15 -0
- package/dist/components/WrFormPrivacy.d.ts +4 -0
- package/dist/components/WrFormPrivacy.js +6 -0
- package/dist/components/WrFormSuccess.d.ts +4 -0
- package/dist/components/WrFormSuccess.js +6 -0
- package/dist/components/WrIframe.d.ts +8 -0
- package/dist/components/WrIframe.js +13 -0
- package/dist/components/WrImage.d.ts +9 -0
- package/dist/components/WrImage.js +13 -0
- package/dist/components/WrInput.d.ts +8 -0
- package/dist/components/WrInput.js +10 -0
- package/dist/components/WrLangSelector.d.ts +17 -0
- package/dist/components/WrLangSelector.js +27 -0
- package/dist/components/WrLangSync.d.ts +6 -0
- package/dist/components/WrLangSync.js +12 -0
- package/dist/components/WrLink.js +23 -0
- package/dist/components/WrNav.d.ts +26 -0
- package/dist/components/WrNav.js +15 -0
- package/dist/{WrSection.d.ts → components/WrSection.d.ts} +3 -3
- package/dist/components/WrShared.d.ts +22 -0
- package/dist/components/WrShared.js +18 -0
- package/dist/components/WrText.d.ts +11 -0
- package/dist/components/WrText.js +14 -0
- package/dist/components/WrThemeToggle.d.ts +9 -0
- package/dist/components/WrThemeToggle.js +43 -0
- package/dist/components/WrVideo.js +61 -0
- package/dist/context/WrAppContext.d.ts +40 -0
- package/dist/context/WrAppContext.js +95 -0
- package/dist/context/WrLayout.d.ts +23 -0
- package/dist/context/WrLayout.js +18 -0
- package/dist/context/WrPageContext.d.ts +10 -0
- package/dist/context/WrPageContext.js +13 -0
- package/dist/form-react.d.ts +82 -0
- package/dist/form-react.js +165 -0
- package/dist/form.d.ts +83 -0
- package/dist/form.js +175 -0
- package/dist/index.css +651 -0
- package/dist/index.d.ts +20 -8
- package/dist/index.js +20 -8
- package/dist/kinds.d.ts +109 -0
- package/dist/kinds.js +35 -0
- package/dist/lang-codes.d.ts +366 -0
- package/dist/lang-codes.js +166 -0
- package/dist/localized.d.ts +36 -0
- package/dist/localized.js +71 -0
- package/dist/not-found.d.ts +9 -0
- package/dist/not-found.js +13 -0
- package/dist/routing.d.ts +122 -0
- package/dist/routing.js +198 -0
- package/dist/seo-react.d.ts +20 -0
- package/dist/seo-react.js +37 -0
- package/dist/seo.d.ts +167 -0
- package/dist/seo.js +221 -0
- package/dist/site-env.d.ts +25 -0
- package/dist/site-env.js +37 -0
- package/dist/style.d.ts +456 -0
- package/dist/style.js +1081 -0
- package/dist/text-react.d.ts +11 -0
- package/dist/text-react.js +5 -0
- package/dist/theme.d.ts +22 -0
- package/dist/theme.js +45 -0
- package/dist/types.d.ts +37 -21
- package/package.json +93 -7
- package/dist/WrButton.d.ts.map +0 -1
- package/dist/WrButton.js +0 -7
- package/dist/WrButton.js.map +0 -1
- package/dist/WrImage.d.ts +0 -8
- package/dist/WrImage.d.ts.map +0 -1
- package/dist/WrImage.js +0 -7
- package/dist/WrImage.js.map +0 -1
- package/dist/WrLink.d.ts.map +0 -1
- package/dist/WrLink.js +0 -15
- package/dist/WrLink.js.map +0 -1
- package/dist/WrPageContext.d.ts +0 -8
- package/dist/WrPageContext.d.ts.map +0 -1
- package/dist/WrPageContext.js +0 -11
- package/dist/WrPageContext.js.map +0 -1
- package/dist/WrSection.d.ts.map +0 -1
- package/dist/WrSection.js.map +0 -1
- package/dist/WrText.d.ts +0 -11
- package/dist/WrText.d.ts.map +0 -1
- package/dist/WrText.js +0 -7
- package/dist/WrText.js.map +0 -1
- package/dist/WrVideo.d.ts.map +0 -1
- package/dist/WrVideo.js +0 -42
- package/dist/WrVideo.js.map +0 -1
- package/dist/bridge.d.ts +0 -2
- package/dist/bridge.d.ts.map +0 -1
- package/dist/bridge.js +0 -110
- package/dist/bridge.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- /package/dist/{WrButton.d.ts → components/WrButton.d.ts} +0 -0
- /package/dist/{WrLink.d.ts → components/WrLink.d.ts} +0 -0
- /package/dist/{WrSection.js → components/WrSection.js} +0 -0
- /package/dist/{WrVideo.d.ts → components/WrVideo.d.ts} +0 -0
package/dist/seo.d.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { type WrLocalized } from './localized.js';
|
|
2
|
+
import { type WrLangConfig, type WrRoutesFile } from './routing.js';
|
|
3
|
+
import type { WrPageConfig } from './types.js';
|
|
4
|
+
/** The key under which a page's JSON holds its SEO data (an element id never starts with an underscore). */
|
|
5
|
+
export declare const WR_SEO_KEY = "_seo";
|
|
6
|
+
/** What is the same for the whole site (`config/seo.json`). */
|
|
7
|
+
export interface WrSeoSite {
|
|
8
|
+
/** Public address of the site, no path: `https://www.example.com`. Without it nothing absolute (canonical, hreflang, sitemaps) can be said. */
|
|
9
|
+
siteUrl?: string;
|
|
10
|
+
/** The name of the site: it can be written per language (`{ default, en }`), like every text. */
|
|
11
|
+
siteName?: WrLocalized;
|
|
12
|
+
/** How a page title is shown, `%s` being the title: `%s | Acme`. Per language when it must be (`{ default, en }`). */
|
|
13
|
+
titleTemplate?: WrLocalized;
|
|
14
|
+
/** The icon of the site in the browser tab and in search results: a path in the site or a full address (png, svg, ico…). */
|
|
15
|
+
favicon?: string;
|
|
16
|
+
/** Picture shown when a page is shared and says none of its own (a path in the site or a full address); per language for a picture with words on it. */
|
|
17
|
+
image?: WrLocalized;
|
|
18
|
+
/** Structured data (JSON-LD) of every page: the organisation, the website… A list of objects, or one list per language. */
|
|
19
|
+
jsonLd?: WrSeoJsonLd;
|
|
20
|
+
/** The accounts shown when a page is shared on X/Twitter (a page can say its own). */
|
|
21
|
+
twitter?: {
|
|
22
|
+
site?: string;
|
|
23
|
+
creator?: string;
|
|
24
|
+
};
|
|
25
|
+
/** Sitemaps the site does not write itself and the index must point to (e.g. the shop's `sitemap-products.xml`): file names or addresses. */
|
|
26
|
+
extraSitemaps?: string[];
|
|
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
|
+
searchConsole?: string;
|
|
29
|
+
}
|
|
30
|
+
/** What one page says (under `_seo` in its JSON). Text can be translated like every other text. */
|
|
31
|
+
export interface WrSeoPage {
|
|
32
|
+
title?: WrLocalized;
|
|
33
|
+
description?: WrLocalized;
|
|
34
|
+
/** The picture shown when the page is shared (a path in the site or a full address); per language for a picture with words on it. */
|
|
35
|
+
image?: WrLocalized;
|
|
36
|
+
/** Keep the page out of search engines (and out of the sitemap). */
|
|
37
|
+
noindex?: boolean;
|
|
38
|
+
/** The page this one is a copy of, if it is not itself (a path in the site or a full address). */
|
|
39
|
+
canonical?: string;
|
|
40
|
+
/** The robots directives, in full (`noindex, follow`, `max-snippet:-1`…): when set they replace what `noindex` would say. */
|
|
41
|
+
robots?: string;
|
|
42
|
+
/** What social networks show, when it must not be the page's own title, description and picture. */
|
|
43
|
+
og?: WrSeoOpenGraph;
|
|
44
|
+
twitter?: {
|
|
45
|
+
card?: 'summary' | 'summary_large_image';
|
|
46
|
+
site?: string;
|
|
47
|
+
creator?: string;
|
|
48
|
+
};
|
|
49
|
+
/** Any other `<meta>` the page needs (`name` or `property`, and its content). */
|
|
50
|
+
meta?: WrSeoMeta[];
|
|
51
|
+
/** Structured data (JSON-LD) of this page, added to the site's. */
|
|
52
|
+
jsonLd?: WrSeoJsonLd;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The verification code of Google Search Console from whatever was pasted: the code itself, or the whole `<meta name="google-site-verification" content="…">` tag Google
|
|
56
|
+
* shows (the way it is given). Null when it is neither: nothing else ends up in a page's head through here.
|
|
57
|
+
*/
|
|
58
|
+
export declare function wrSearchConsoleCode(input: string | undefined | null): string | null;
|
|
59
|
+
export interface WrSeoOpenGraph {
|
|
60
|
+
title?: WrLocalized;
|
|
61
|
+
description?: WrLocalized;
|
|
62
|
+
image?: WrLocalized;
|
|
63
|
+
type?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface WrSeoMeta {
|
|
66
|
+
name?: string;
|
|
67
|
+
property?: string;
|
|
68
|
+
content: WrLocalized;
|
|
69
|
+
}
|
|
70
|
+
/** One JSON-LD object (`{ "@type": "Organization", … }`). */
|
|
71
|
+
export type WrJsonLd = Record<string, unknown>;
|
|
72
|
+
/** JSON-LD of a page or of the site: a list of objects, or `{ default: […], en: […] }` when it is written per language. */
|
|
73
|
+
export type WrSeoJsonLd = WrJsonLd[] | Record<string, WrJsonLd[]>;
|
|
74
|
+
export declare const WR_ROBOTS_FILE = "robots.txt";
|
|
75
|
+
export declare const WR_SITEMAP_INDEX_FILE = "sitemap-index.xml";
|
|
76
|
+
export declare const WR_SITEMAP_PAGES_FILE = "sitemap-pages.xml";
|
|
77
|
+
/** A page's SEO data out of its JSON (an empty object when it says nothing). */
|
|
78
|
+
export declare function wrSeoPageOf(config: WrPageConfig | undefined): WrSeoPage;
|
|
79
|
+
/** The site's address without a trailing slash, or undefined when it is not an http(s) address. */
|
|
80
|
+
export declare function wrSiteUrlOf(site: WrSeoSite | undefined): string | undefined;
|
|
81
|
+
/** The JSON-LD objects for a language: the list as it is, or the language's list (the default one's when it has none). */
|
|
82
|
+
export declare function wrSeoJsonLdOf(value: unknown, lang: string): WrJsonLd[];
|
|
83
|
+
/** What is wrong with a JSON-LD value (empty when it is fine): it is written by hand, in the panel or in VS Code, and a broken one is silently ignored by search engines. */
|
|
84
|
+
export declare function wrJsonLdProblems(value: unknown): string[];
|
|
85
|
+
/** A JSON-LD object as the text of a `<script type="application/ld+json">`: `@context` is schema.org unless said, and nothing in it can end the script. */
|
|
86
|
+
export declare function wrJsonLdText(item: WrJsonLd): string;
|
|
87
|
+
export interface WrSeoTag {
|
|
88
|
+
tag: 'meta' | 'link';
|
|
89
|
+
attrs: Record<string, string>;
|
|
90
|
+
}
|
|
91
|
+
export interface WrSeoHead {
|
|
92
|
+
title?: string;
|
|
93
|
+
tags: WrSeoTag[];
|
|
94
|
+
/** The texts of the `<script type="application/ld+json">` the page has: the site's, then the page's. */
|
|
95
|
+
jsonLd: string[];
|
|
96
|
+
}
|
|
97
|
+
export interface WrSeoContext {
|
|
98
|
+
site?: WrSeoSite;
|
|
99
|
+
page?: WrSeoPage;
|
|
100
|
+
/** The page's path without a language prefix: `/`, `/about`. */
|
|
101
|
+
path: string;
|
|
102
|
+
/** The language shown: `default`, or a code. */
|
|
103
|
+
lang: string;
|
|
104
|
+
langs?: WrLangConfig;
|
|
105
|
+
}
|
|
106
|
+
/** The text of the tab: the page's title in the template, or the site's name when the page has none. */
|
|
107
|
+
export declare function wrSeoTitle({ site, page, lang }: Pick<WrSeoContext, 'site' | 'page' | 'lang'>): string | undefined;
|
|
108
|
+
/** Everything a page puts in `<head>` for search engines and social networks: ONE function, whoever renders or injects it. */
|
|
109
|
+
export declare function wrSeoTags(context: WrSeoContext): WrSeoHead;
|
|
110
|
+
/** A page of the site as a sitemap sees it: its address in every language. */
|
|
111
|
+
export interface WrSeoEntry {
|
|
112
|
+
pageName: string;
|
|
113
|
+
path: string;
|
|
114
|
+
noindex: boolean;
|
|
115
|
+
urls: {
|
|
116
|
+
lang: string;
|
|
117
|
+
url: string;
|
|
118
|
+
}[];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 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).
|
|
122
|
+
* Pages marked noindex are kept in the list (the checks and the editor show them) and left out by `wrSitemapPagesXml`.
|
|
123
|
+
*/
|
|
124
|
+
export declare function wrSeoEntries(input: {
|
|
125
|
+
routes: WrRoutesFile;
|
|
126
|
+
pages: Record<string, WrPageConfig | undefined>;
|
|
127
|
+
langs?: WrLangConfig;
|
|
128
|
+
site?: WrSeoSite;
|
|
129
|
+
}): WrSeoEntry[];
|
|
130
|
+
/** A page in a language that a build prerenders for crawlers, and the file of `dist/` its HTML is written to. */
|
|
131
|
+
export interface WrSnapshotTarget {
|
|
132
|
+
pageName: string;
|
|
133
|
+
lang: string;
|
|
134
|
+
/** The address: `/`, `/about`, `/en`, `/en/about`. */
|
|
135
|
+
path: string;
|
|
136
|
+
file: string;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Where the prerendered HTML of an address goes in `dist/`: the home of a language is `<lang>/index.html` (the default language has no prefix:
|
|
140
|
+
* its home is `index.html`), any other address is `<address>.html` (`/en/about` → `en/about.html`, `/about` → `about.html`).
|
|
141
|
+
*/
|
|
142
|
+
export declare function wrSnapshotFile(path: string, langs?: WrLangConfig): string;
|
|
143
|
+
/**
|
|
144
|
+
* The pages a build prerenders: every page with a fixed address, in every language, but those marked noindex (a crawler is told to leave them
|
|
145
|
+
* alone). The same list the sitemap is made from (`wrSeoEntries`), so there is no second list of pages.
|
|
146
|
+
*/
|
|
147
|
+
export declare function wrSnapshotTargets(input: {
|
|
148
|
+
routes: WrRoutesFile;
|
|
149
|
+
pages: Record<string, WrPageConfig | undefined>;
|
|
150
|
+
langs?: WrLangConfig;
|
|
151
|
+
}): WrSnapshotTarget[];
|
|
152
|
+
/** The sitemap of the site's own pages: one `<url>` per language version, each with all its alternates. */
|
|
153
|
+
export declare function wrSitemapPagesXml(entries: readonly WrSeoEntry[], langs?: WrLangConfig): string;
|
|
154
|
+
/** 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). */
|
|
155
|
+
export declare function wrSitemapIndexXml(siteUrl: string, files: readonly string[]): string;
|
|
156
|
+
export declare function wrRobotsTxt(siteUrl: string): string;
|
|
157
|
+
/**
|
|
158
|
+
* 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.
|
|
159
|
+
* `null` while the site does not know its public address: there is nothing true to write.
|
|
160
|
+
*/
|
|
161
|
+
export declare function wrSeoFiles(input: {
|
|
162
|
+
routes: WrRoutesFile;
|
|
163
|
+
pages: Record<string, WrPageConfig | undefined>;
|
|
164
|
+
langs?: WrLangConfig;
|
|
165
|
+
site?: WrSeoSite;
|
|
166
|
+
}): Record<string, string> | null;
|
|
167
|
+
//# sourceMappingURL=seo.d.ts.map
|
package/dist/seo.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// What a search engine and a social network read from a page. ONE place says what the data is (`WrSeoSite`, `WrSeoPage`), ONE function
|
|
2
|
+
// turns it into tags (`wrSeoTags`: the page renders it, and whoever injects metadata into a static snapshot calls the same one) and ONE
|
|
3
|
+
// function turns the whole site into its files (`wrSeoFiles`: robots.txt and the sitemaps). Pure: no React, no Node, no I/O.
|
|
4
|
+
// `.js` on purpose: the operations and the dev server run this in Node, which wants the extension (the bundlers resolve it to the .ts)
|
|
5
|
+
import { wrLocalized } from './localized.js';
|
|
6
|
+
import { wrBuildLangPath, wrRoutePattern } from './routing.js';
|
|
7
|
+
/** The key under which a page's JSON holds its SEO data (an element id never starts with an underscore). */
|
|
8
|
+
export const WR_SEO_KEY = '_seo';
|
|
9
|
+
/**
|
|
10
|
+
* The verification code of Google Search Console from whatever was pasted: the code itself, or the whole `<meta name="google-site-verification" content="…">` tag Google
|
|
11
|
+
* shows (the way it is given). Null when it is neither: nothing else ends up in a page's head through here.
|
|
12
|
+
*/
|
|
13
|
+
export function wrSearchConsoleCode(input) {
|
|
14
|
+
const text = (input ?? '').trim();
|
|
15
|
+
const tag = /^<meta\s+[^>]*\bname\s*=\s*["']google-site-verification["'][^>]*\bcontent\s*=\s*["']([^"']*)["'][^>]*\/?>$/i.exec(text)
|
|
16
|
+
?? /^<meta\s+[^>]*\bcontent\s*=\s*["']([^"']*)["'][^>]*\bname\s*=\s*["']google-site-verification["'][^>]*\/?>$/i.exec(text);
|
|
17
|
+
const code = (tag ? tag[1] : text).trim();
|
|
18
|
+
return /^[A-Za-z0-9_-]{10,200}$/.test(code) ? code : null;
|
|
19
|
+
}
|
|
20
|
+
export const WR_ROBOTS_FILE = 'robots.txt';
|
|
21
|
+
export const WR_SITEMAP_INDEX_FILE = 'sitemap-index.xml';
|
|
22
|
+
export const WR_SITEMAP_PAGES_FILE = 'sitemap-pages.xml';
|
|
23
|
+
/** A page's SEO data out of its JSON (an empty object when it says nothing). */
|
|
24
|
+
export function wrSeoPageOf(config) {
|
|
25
|
+
const raw = config?.[WR_SEO_KEY];
|
|
26
|
+
return raw && typeof raw === 'object' ? raw : {};
|
|
27
|
+
}
|
|
28
|
+
/** The site's address without a trailing slash, or undefined when it is not an http(s) address. */
|
|
29
|
+
export function wrSiteUrlOf(site) {
|
|
30
|
+
const raw = site?.siteUrl?.trim();
|
|
31
|
+
if (!raw || !/^https?:\/\/[^\s/]+/i.test(raw))
|
|
32
|
+
return undefined;
|
|
33
|
+
return raw.replace(/\/+$/, '');
|
|
34
|
+
}
|
|
35
|
+
const isAbsolute = (value) => /^https?:\/\//i.test(value);
|
|
36
|
+
const absolute = (siteUrl, value) => !value ? undefined : isAbsolute(value) ? value : siteUrl ? `${siteUrl}${value.startsWith('/') ? '' : '/'}${value}` : undefined;
|
|
37
|
+
/** A page's path in a language (`/about`, `/en/about`): the default language has no prefix. `langKey` is `default` or a code. */
|
|
38
|
+
function pathIn(path, langKey, langs) {
|
|
39
|
+
return langs ? wrBuildLangPath(langKey, path, langs) : path;
|
|
40
|
+
}
|
|
41
|
+
const ICON_TYPES = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' };
|
|
42
|
+
/** The `type` of an icon from its extension (a hint to the browser: it picks what it can draw); nothing when it cannot be told. */
|
|
43
|
+
const iconType = (href) => {
|
|
44
|
+
const type = ICON_TYPES[/\.([a-z0-9]+)(?:[?#].*)?$/i.exec(href)?.[1]?.toLowerCase() ?? ''];
|
|
45
|
+
return type ? { type } : {};
|
|
46
|
+
};
|
|
47
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
48
|
+
/** The JSON-LD objects for a language: the list as it is, or the language's list (the default one's when it has none). */
|
|
49
|
+
export function wrSeoJsonLdOf(value, lang) {
|
|
50
|
+
const list = Array.isArray(value) ? value : isObject(value) ? (value[lang] ?? value.default) : [];
|
|
51
|
+
return (Array.isArray(list) ? list : []).filter(isObject);
|
|
52
|
+
}
|
|
53
|
+
/** What is wrong with a JSON-LD value (empty when it is fine): it is written by hand, in the panel or in VS Code, and a broken one is silently ignored by search engines. */
|
|
54
|
+
export function wrJsonLdProblems(value) {
|
|
55
|
+
const problems = [];
|
|
56
|
+
const lists = Array.isArray(value) ? [['', value]] : isObject(value) ? Object.entries(value) : [];
|
|
57
|
+
if (value !== undefined && lists.length === 0)
|
|
58
|
+
return ['JSON-LD must be a list of objects, or one list per language'];
|
|
59
|
+
for (const [lang, list] of lists) {
|
|
60
|
+
const where = lang ? ` (${lang})` : '';
|
|
61
|
+
if (!Array.isArray(list)) {
|
|
62
|
+
problems.push(`the JSON-LD${where} must be a list of objects`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
list.forEach((item, i) => {
|
|
66
|
+
if (!isObject(item))
|
|
67
|
+
problems.push(`JSON-LD item ${i + 1}${where} must be an object`);
|
|
68
|
+
else if (!('@type' in item) && !('@graph' in item))
|
|
69
|
+
problems.push(`JSON-LD item ${i + 1}${where} has no "@type"`);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return problems;
|
|
73
|
+
}
|
|
74
|
+
/** A JSON-LD object as the text of a `<script type="application/ld+json">`: `@context` is schema.org unless said, and nothing in it can end the script. */
|
|
75
|
+
export function wrJsonLdText(item) {
|
|
76
|
+
return JSON.stringify('@context' in item ? item : { '@context': 'https://schema.org', ...item })
|
|
77
|
+
.replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
78
|
+
}
|
|
79
|
+
/** The text of the tab: the page's title in the template, or the site's name when the page has none. */
|
|
80
|
+
export function wrSeoTitle({ site, page, lang }) {
|
|
81
|
+
const own = wrLocalized(page?.title, lang)?.trim();
|
|
82
|
+
if (!own)
|
|
83
|
+
return wrLocalized(site?.siteName, lang)?.trim() || undefined;
|
|
84
|
+
const template = wrLocalized(site?.titleTemplate, lang);
|
|
85
|
+
return template && template.includes('%s') ? template.replace('%s', own) : own;
|
|
86
|
+
}
|
|
87
|
+
/** Everything a page puts in `<head>` for search engines and social networks: ONE function, whoever renders or injects it. */
|
|
88
|
+
export function wrSeoTags(context) {
|
|
89
|
+
const { site, page, path, lang, langs } = context;
|
|
90
|
+
const siteUrl = wrSiteUrlOf(site);
|
|
91
|
+
const title = wrSeoTitle(context);
|
|
92
|
+
const description = wrLocalized(page?.description, lang)?.trim();
|
|
93
|
+
const tags = [];
|
|
94
|
+
const icon = site?.favicon?.trim();
|
|
95
|
+
if (icon)
|
|
96
|
+
tags.push({ tag: 'link', attrs: { rel: 'icon', href: icon, ...iconType(icon) } });
|
|
97
|
+
const meta = (key, name, content) => { if (content)
|
|
98
|
+
tags.push({ tag: 'meta', attrs: { [key]: name, content } }); };
|
|
99
|
+
meta('name', 'description', description);
|
|
100
|
+
// `robots` says it all when it is set; otherwise `noindex` is `noindex, nofollow`. A page kept out of search engines says nothing that invites them in.
|
|
101
|
+
const robots = page?.robots?.trim() || (page?.noindex ? 'noindex, nofollow' : undefined);
|
|
102
|
+
const indexable = !/\bnoindex\b/i.test(robots ?? '');
|
|
103
|
+
meta('name', 'robots', robots);
|
|
104
|
+
if (indexable && siteUrl) {
|
|
105
|
+
const canonical = absolute(siteUrl, page?.canonical) ?? `${siteUrl}${pathIn(path, lang, langs)}`;
|
|
106
|
+
tags.push({ tag: 'link', attrs: { rel: 'canonical', href: canonical } });
|
|
107
|
+
if (langs && langs.available.length > 1) {
|
|
108
|
+
for (const code of langs.available) {
|
|
109
|
+
const key = code === langs.default ? 'default' : code;
|
|
110
|
+
tags.push({ tag: 'link', attrs: { rel: 'alternate', hreflang: code, href: `${siteUrl}${pathIn(path, key, langs)}` } });
|
|
111
|
+
}
|
|
112
|
+
tags.push({ tag: 'link', attrs: { rel: 'alternate', hreflang: 'x-default', href: `${siteUrl}${pathIn(path, 'default', langs)}` } });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const og = page?.og;
|
|
116
|
+
const image = absolute(siteUrl, wrLocalized(og?.image, lang)?.trim() || wrLocalized(page?.image, lang)?.trim() || wrLocalized(site?.image, lang)?.trim());
|
|
117
|
+
meta('property', 'og:type', og?.type?.trim() || 'website');
|
|
118
|
+
meta('property', 'og:title', wrLocalized(og?.title, lang)?.trim() || title);
|
|
119
|
+
meta('property', 'og:description', wrLocalized(og?.description, lang)?.trim() || description);
|
|
120
|
+
meta('property', 'og:site_name', wrLocalized(site?.siteName, lang)?.trim());
|
|
121
|
+
meta('property', 'og:image', image);
|
|
122
|
+
meta('property', 'og:url', siteUrl && indexable ? `${siteUrl}${pathIn(path, lang, langs)}` : undefined);
|
|
123
|
+
meta('name', 'twitter:card', page?.twitter?.card ?? (image ? 'summary_large_image' : 'summary'));
|
|
124
|
+
meta('name', 'twitter:site', page?.twitter?.site?.trim() || site?.twitter?.site?.trim());
|
|
125
|
+
meta('name', 'twitter:creator', page?.twitter?.creator?.trim() || site?.twitter?.creator?.trim());
|
|
126
|
+
// Whatever else the page needs
|
|
127
|
+
for (const extra of page?.meta ?? []) {
|
|
128
|
+
const content = wrLocalized(extra.content, lang)?.trim();
|
|
129
|
+
if (extra.property?.trim())
|
|
130
|
+
meta('property', extra.property.trim(), content);
|
|
131
|
+
else if (extra.name?.trim())
|
|
132
|
+
meta('name', extra.name.trim(), content);
|
|
133
|
+
}
|
|
134
|
+
const jsonLd = [...wrSeoJsonLdOf(site?.jsonLd, lang), ...wrSeoJsonLdOf(page?.jsonLd, lang)].map(wrJsonLdText);
|
|
135
|
+
return { title, tags, jsonLd };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* 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).
|
|
139
|
+
* Pages marked noindex are kept in the list (the checks and the editor show them) and left out by `wrSitemapPagesXml`.
|
|
140
|
+
*/
|
|
141
|
+
export function wrSeoEntries(input) {
|
|
142
|
+
const { routes, pages, langs, site } = input;
|
|
143
|
+
const siteUrl = wrSiteUrlOf(site) ?? '';
|
|
144
|
+
return Object.keys(routes)
|
|
145
|
+
.filter(name => !/\[[^\]]+\]/.test(name))
|
|
146
|
+
.map((pageName) => {
|
|
147
|
+
const path = wrRoutePattern(pageName);
|
|
148
|
+
const codes = langs && langs.available.length > 0 ? langs.available : [undefined];
|
|
149
|
+
const urls = codes.map((code) => {
|
|
150
|
+
const key = code === undefined || code === langs?.default ? 'default' : code;
|
|
151
|
+
return { lang: code ?? 'default', url: `${siteUrl}${pathIn(path, key, langs)}` };
|
|
152
|
+
});
|
|
153
|
+
const own = wrSeoPageOf(pages[pageName]);
|
|
154
|
+
return { pageName, path, noindex: own.noindex === true || /\bnoindex\b/i.test(own.robots ?? ''), urls };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Where the prerendered HTML of an address goes in `dist/`: the home of a language is `<lang>/index.html` (the default language has no prefix:
|
|
159
|
+
* its home is `index.html`), any other address is `<address>.html` (`/en/about` → `en/about.html`, `/about` → `about.html`).
|
|
160
|
+
*/
|
|
161
|
+
export function wrSnapshotFile(path, langs) {
|
|
162
|
+
const segments = path.split('/').filter(Boolean);
|
|
163
|
+
if (segments.length === 0)
|
|
164
|
+
return 'index.html';
|
|
165
|
+
if (segments.length === 1 && langs?.available.includes(segments[0]))
|
|
166
|
+
return `${segments[0]}/index.html`;
|
|
167
|
+
return `${segments.join('/')}.html`;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The pages a build prerenders: every page with a fixed address, in every language, but those marked noindex (a crawler is told to leave them
|
|
171
|
+
* alone). The same list the sitemap is made from (`wrSeoEntries`), so there is no second list of pages.
|
|
172
|
+
*/
|
|
173
|
+
export function wrSnapshotTargets(input) {
|
|
174
|
+
// Without the site's public address the addresses are the paths
|
|
175
|
+
return wrSeoEntries({ ...input, site: undefined })
|
|
176
|
+
.filter(entry => !entry.noindex)
|
|
177
|
+
.flatMap(entry => entry.urls.map(({ lang, url }) => ({ pageName: entry.pageName, lang, path: url, file: wrSnapshotFile(url, input.langs) })));
|
|
178
|
+
}
|
|
179
|
+
const xml = (text) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
180
|
+
const XML_HEAD = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
181
|
+
/** The sitemap of the site's own pages: one `<url>` per language version, each with all its alternates. */
|
|
182
|
+
export function wrSitemapPagesXml(entries, langs) {
|
|
183
|
+
const rows = [];
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
if (entry.noindex)
|
|
186
|
+
continue;
|
|
187
|
+
const home = entry.urls.find(u => u.lang === langs?.default) ?? entry.urls[0];
|
|
188
|
+
const alternates = entry.urls.length > 1
|
|
189
|
+
? [...entry.urls.map(u => ` <xhtml:link rel="alternate" hreflang="${xml(u.lang)}" href="${xml(u.url)}"/>`),
|
|
190
|
+
` <xhtml:link rel="alternate" hreflang="x-default" href="${xml(home.url)}"/>`]
|
|
191
|
+
: [];
|
|
192
|
+
for (const { url } of entry.urls)
|
|
193
|
+
rows.push([' <url>', ` <loc>${xml(url)}</loc>`, ...alternates, ' </url>'].join('\n'));
|
|
194
|
+
}
|
|
195
|
+
return `${XML_HEAD}<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n${rows.join('\n')}\n</urlset>\n`;
|
|
196
|
+
}
|
|
197
|
+
/** 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). */
|
|
198
|
+
export function wrSitemapIndexXml(siteUrl, files) {
|
|
199
|
+
const rows = files.map(file => ` <sitemap>\n <loc>${xml(isAbsolute(file) ? file : `${siteUrl}/${file.replace(/^\/+/, '')}`)}</loc>\n </sitemap>`);
|
|
200
|
+
return `${XML_HEAD}<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join('\n')}\n</sitemapindex>\n`;
|
|
201
|
+
}
|
|
202
|
+
export function wrRobotsTxt(siteUrl) {
|
|
203
|
+
return `User-agent: *\nAllow: /\n\nSitemap: ${siteUrl}/${WR_SITEMAP_INDEX_FILE}\n`;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 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.
|
|
207
|
+
* `null` while the site does not know its public address: there is nothing true to write.
|
|
208
|
+
*/
|
|
209
|
+
export function wrSeoFiles(input) {
|
|
210
|
+
const siteUrl = wrSiteUrlOf(input.site);
|
|
211
|
+
if (!siteUrl)
|
|
212
|
+
return null;
|
|
213
|
+
const entries = wrSeoEntries(input);
|
|
214
|
+
const extra = (input.site?.extraSitemaps ?? []).filter(name => typeof name === 'string' && name.trim() && name !== WR_SITEMAP_PAGES_FILE);
|
|
215
|
+
return {
|
|
216
|
+
[WR_ROBOTS_FILE]: wrRobotsTxt(siteUrl),
|
|
217
|
+
[WR_SITEMAP_INDEX_FILE]: wrSitemapIndexXml(siteUrl, [WR_SITEMAP_PAGES_FILE, ...extra]),
|
|
218
|
+
[WR_SITEMAP_PAGES_FILE]: wrSitemapPagesXml(entries, input.langs),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=seo.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface WrSiteEnv {
|
|
2
|
+
/** The draft this site is: what the APIs call the store. */
|
|
3
|
+
draftId?: string;
|
|
4
|
+
/** The Webround core API, without a trailing slash. */
|
|
5
|
+
coreApiUrl?: string;
|
|
6
|
+
/** The store's API (customers, cart, checkout), without a trailing slash. */
|
|
7
|
+
commerceApiUrl?: string;
|
|
8
|
+
/** The store's catalog API (products, collections, filters), without a trailing slash. */
|
|
9
|
+
catalogApiUrl?: string;
|
|
10
|
+
/** The checkout API (it starts the payment of a cart), without a trailing slash. */
|
|
11
|
+
checkoutApiUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
/** The names a site's build reads: the contract with whoever starts it (the Worker). */
|
|
14
|
+
export declare const WR_SITE_ENV_VARS: {
|
|
15
|
+
readonly draftId: "VITE_WR_DRAFT_ID";
|
|
16
|
+
readonly coreApiUrl: "VITE_WR_CORE_API_URL";
|
|
17
|
+
readonly commerceApiUrl: "VITE_WR_COMMERCE_API_URL";
|
|
18
|
+
readonly catalogApiUrl: "VITE_WR_CATALOG_API_URL";
|
|
19
|
+
readonly checkoutApiUrl: "VITE_WR_CHECKOUT_API_URL";
|
|
20
|
+
};
|
|
21
|
+
/** What is written down, with the empty ones left out and no `/` at the end of a URL. Pure: it reads the object it is given. */
|
|
22
|
+
export declare function wrSiteEnvFrom(env: Record<string, unknown> | undefined): WrSiteEnv;
|
|
23
|
+
/** What this site was built with. `import.meta.env` exists where Vite built the page; anywhere else there is nothing, and so nothing is said. */
|
|
24
|
+
export declare function wrSiteEnv(): WrSiteEnv;
|
|
25
|
+
//# sourceMappingURL=site-env.d.ts.map
|
package/dist/site-env.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// What a site knows about where it lives: which draft it is and where the Webround APIs are. ONE place reads it, so the form today and
|
|
2
|
+
// the shop and the catalog tomorrow ask the same thing the same way.
|
|
3
|
+
//
|
|
4
|
+
// The values come from the process the site is built or served by (`VITE_WR_*`): the Worker that runs the draft's container sets them, and the
|
|
5
|
+
// site's own `.env` files are not involved (Vite lets the process win only for the same name; every other variable of the owner stays theirs).
|
|
6
|
+
/** The names a site's build reads: the contract with whoever starts it (the Worker). */
|
|
7
|
+
export const WR_SITE_ENV_VARS = {
|
|
8
|
+
draftId: 'VITE_WR_DRAFT_ID',
|
|
9
|
+
coreApiUrl: 'VITE_WR_CORE_API_URL',
|
|
10
|
+
commerceApiUrl: 'VITE_WR_COMMERCE_API_URL',
|
|
11
|
+
catalogApiUrl: 'VITE_WR_CATALOG_API_URL',
|
|
12
|
+
checkoutApiUrl: 'VITE_WR_CHECKOUT_API_URL',
|
|
13
|
+
};
|
|
14
|
+
/** What is written down, with the empty ones left out and no `/` at the end of a URL. Pure: it reads the object it is given. */
|
|
15
|
+
export function wrSiteEnvFrom(env) {
|
|
16
|
+
const text = (name) => {
|
|
17
|
+
const value = env?.[name];
|
|
18
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
19
|
+
};
|
|
20
|
+
const url = (name) => text(name)?.replace(/\/+$/, '') || undefined;
|
|
21
|
+
const coreApiUrl = url(WR_SITE_ENV_VARS.coreApiUrl);
|
|
22
|
+
const commerceApiUrl = url(WR_SITE_ENV_VARS.commerceApiUrl);
|
|
23
|
+
const catalogApiUrl = url(WR_SITE_ENV_VARS.catalogApiUrl);
|
|
24
|
+
const checkoutApiUrl = url(WR_SITE_ENV_VARS.checkoutApiUrl);
|
|
25
|
+
return {
|
|
26
|
+
draftId: text(WR_SITE_ENV_VARS.draftId),
|
|
27
|
+
...(coreApiUrl ? { coreApiUrl } : {}),
|
|
28
|
+
...(commerceApiUrl ? { commerceApiUrl } : {}),
|
|
29
|
+
...(catalogApiUrl ? { catalogApiUrl } : {}),
|
|
30
|
+
...(checkoutApiUrl ? { checkoutApiUrl } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** What this site was built with. `import.meta.env` exists where Vite built the page; anywhere else there is nothing, and so nothing is said. */
|
|
34
|
+
export function wrSiteEnv() {
|
|
35
|
+
return wrSiteEnvFrom(import.meta.env);
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=site-env.js.map
|