@verbaly/compiler 0.13.1 → 0.14.5
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/README.md +2 -0
- package/dist/cli.js +86 -13
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +18 -3
- package/dist/index.js +73 -12
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -53,6 +53,8 @@ render: { links: { docs: { href: '/docs', target: '_blank', rel: 'noopener' } }
|
|
|
53
53
|
|
|
54
54
|
Per-element `data-verbaly-links='{"repo":"https://…"}'` merges over the config map.
|
|
55
55
|
|
|
56
|
+
**Multi-locale SEO** — set `render.baseUrl` (or `--base-url`) and every page gets reciprocal `<link rel="alternate" hreflang>` (plus `x-default`) for the whole locale set; `--sitemap` writes a locale-aware `sitemap-i18n.xml`. `--clean` drops stale `dist/<locale>/` pages before mirroring. Injection is idempotent.
|
|
57
|
+
|
|
56
58
|
## 🔍 Pseudo-localization
|
|
57
59
|
|
|
58
60
|
`verbaly pseudo` fills a QA catalog (`en-XA` by default, `--locale <id>` to change) from the source: accented letters, `⟦…⟧` markers and ~33% length padding reveal hardcoded strings, clipped layouts and concatenation bugs. Params, variant blocks and tags survive verbatim — the same structural validation as `translate`.
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { RICH_TAGS, createVerbaly, parse, parseTags, safeHref } from "verbaly";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
@@ -879,6 +879,8 @@ function pseudoCatalogs(cfg, catalogs, locale = PSEUDO_LOCALE) {
|
|
|
879
879
|
}
|
|
880
880
|
//#endregion
|
|
881
881
|
//#region src/render.ts
|
|
882
|
+
const HREFLANG_OPEN = "<!--verbaly:hreflang-->";
|
|
883
|
+
const HREFLANG_CLOSE = "<!--/verbaly:hreflang-->";
|
|
882
884
|
const VOID_TAGS = /* @__PURE__ */ new Set([
|
|
883
885
|
"area",
|
|
884
886
|
"base",
|
|
@@ -906,22 +908,19 @@ function renderHtml(html, options) {
|
|
|
906
908
|
const richTags = new Set(options.richTags ?? RICH_TAGS);
|
|
907
909
|
const globalLinks = options.richLinks;
|
|
908
910
|
const sourceLocale = options.sourceLocale ?? "en";
|
|
909
|
-
const messages = {};
|
|
910
|
-
for (const [locale, catalog] of Object.entries(options.catalogs)) {
|
|
911
|
-
const clean = {};
|
|
912
|
-
for (const [key, msg] of Object.entries(catalog)) if (msg) clean[key] = msg;
|
|
913
|
-
messages[locale] = clean;
|
|
914
|
-
}
|
|
915
911
|
const v = createVerbaly({
|
|
916
912
|
locale: options.locale,
|
|
917
913
|
fallback: sourceLocale,
|
|
918
|
-
messages
|
|
914
|
+
messages: options.catalogs
|
|
919
915
|
});
|
|
920
916
|
const t = v.t;
|
|
921
917
|
const ms = new MagicString(html);
|
|
922
918
|
const missing = /* @__PURE__ */ new Set();
|
|
923
919
|
const skip = protectedRanges(html);
|
|
924
920
|
const inSkip = (index) => skip.some(([from, to]) => index >= from && index < to);
|
|
921
|
+
const selfUrl = options.alternates?.find((a) => a.hreflang === options.locale)?.href;
|
|
922
|
+
const sourceUrl = options.alternates?.find((a) => a.hreflang === "x-default")?.href;
|
|
923
|
+
const rewriteUrl = selfUrl !== void 0 && selfUrl !== sourceUrl;
|
|
925
924
|
START_TAG.lastIndex = 0;
|
|
926
925
|
let m;
|
|
927
926
|
while ((m = START_TAG.exec(html)) !== null) {
|
|
@@ -935,6 +934,8 @@ function renderHtml(html, options) {
|
|
|
935
934
|
continue;
|
|
936
935
|
}
|
|
937
936
|
const attrs = parseAttrs(attrChunk);
|
|
937
|
+
if (rewriteUrl && tagName === "link" && attrs.get("rel")?.toLowerCase() === "canonical" && decodeEntities(attrs.get("href") ?? "") === sourceUrl) setAttribute(ms, html, chunkStart, openEnd, attrChunk, "href", selfUrl);
|
|
938
|
+
if (rewriteUrl && tagName === "meta" && attrs.get("property") === "og:url" && decodeEntities(attrs.get("content") ?? "") === sourceUrl) setAttribute(ms, html, chunkStart, openEnd, attrChunk, "content", selfUrl);
|
|
938
939
|
const key = attrs.get(attr);
|
|
939
940
|
const attrMapRaw = attrs.get(attrsAttr);
|
|
940
941
|
if (key === void 0 && attrMapRaw === void 0) continue;
|
|
@@ -968,32 +969,66 @@ function renderHtml(html, options) {
|
|
|
968
969
|
}
|
|
969
970
|
}
|
|
970
971
|
}
|
|
972
|
+
if (options.alternates?.length) injectAlternates(ms, html, options.alternates);
|
|
971
973
|
return {
|
|
972
974
|
html: ms.toString(),
|
|
973
975
|
missing: [...missing]
|
|
974
976
|
};
|
|
975
977
|
}
|
|
978
|
+
function injectAlternates(ms, html, alternates) {
|
|
979
|
+
const links = alternates.map((a) => `<link rel="alternate" hreflang="${escapeAttr(a.hreflang)}" href="${escapeAttr(a.href)}">`).join("");
|
|
980
|
+
const block = `${HREFLANG_OPEN}${links}${HREFLANG_CLOSE}`;
|
|
981
|
+
const from = html.indexOf(HREFLANG_OPEN);
|
|
982
|
+
if (from !== -1) {
|
|
983
|
+
const to = html.indexOf(HREFLANG_CLOSE, from);
|
|
984
|
+
if (to !== -1) {
|
|
985
|
+
const end = to + 24;
|
|
986
|
+
if (html.slice(from, end) !== block) ms.overwrite(from, end, block);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
const head = /<\/head\s*>/i.exec(html);
|
|
991
|
+
if (head) ms.appendLeft(head.index, block);
|
|
992
|
+
}
|
|
976
993
|
async function renderSite(cfg, options = {}) {
|
|
977
|
-
const site = join(cfg.root, options.site ?? "dist");
|
|
994
|
+
const site = join(cfg.root, options.site ?? cfg.render.site ?? "dist");
|
|
978
995
|
const locales = options.locales ?? cfg.locales;
|
|
996
|
+
const attribute = options.attribute ?? cfg.render.attribute;
|
|
997
|
+
const baseUrl = (options.baseUrl ?? cfg.render.baseUrl)?.replace(/\/+$/, "");
|
|
998
|
+
const wantHreflang = (options.hreflang ?? cfg.render.hreflang ?? true) && baseUrl !== void 0;
|
|
999
|
+
const wantSitemap = (options.sitemap ?? cfg.render.sitemap ?? false) && baseUrl !== void 0;
|
|
1000
|
+
const clean = options.clean ?? cfg.render.clean ?? false;
|
|
979
1001
|
const catalogs = loadCatalogs(cfg);
|
|
1002
|
+
if (clean) {
|
|
1003
|
+
for (const locale of locales) if (locale !== cfg.sourceLocale) rmSync(join(site, locale), {
|
|
1004
|
+
recursive: true,
|
|
1005
|
+
force: true
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
980
1008
|
const files = await glob("**/*.html", {
|
|
981
1009
|
cwd: site,
|
|
982
1010
|
absolute: true,
|
|
983
1011
|
ignore: locales.map((locale) => `${locale}/**`)
|
|
984
1012
|
});
|
|
985
1013
|
const missing = {};
|
|
1014
|
+
const urls = [];
|
|
986
1015
|
for (const file of files) {
|
|
987
1016
|
const html = readFileSync(file, "utf8");
|
|
988
|
-
const rel = relative(site, file);
|
|
1017
|
+
const rel = relative(site, file).replace(/\\/g, "/");
|
|
1018
|
+
const alternates = wantHreflang ? pageAlternates(baseUrl, rel, locales, cfg.sourceLocale) : [];
|
|
1019
|
+
if (wantHreflang) urls.push({
|
|
1020
|
+
rel,
|
|
1021
|
+
alternates
|
|
1022
|
+
});
|
|
989
1023
|
for (const locale of locales) {
|
|
990
1024
|
const result = renderHtml(html, {
|
|
991
1025
|
locale,
|
|
992
1026
|
catalogs,
|
|
993
1027
|
sourceLocale: cfg.sourceLocale,
|
|
994
|
-
attribute
|
|
1028
|
+
attribute,
|
|
995
1029
|
richTags: options.richTags,
|
|
996
|
-
richLinks: options.richLinks ?? cfg.render.links
|
|
1030
|
+
richLinks: options.richLinks ?? cfg.render.links,
|
|
1031
|
+
alternates
|
|
997
1032
|
});
|
|
998
1033
|
for (const key of result.missing) {
|
|
999
1034
|
const list = missing[locale] ??= [];
|
|
@@ -1004,12 +1039,38 @@ async function renderSite(cfg, options = {}) {
|
|
|
1004
1039
|
writeFileSync(out, result.html);
|
|
1005
1040
|
}
|
|
1006
1041
|
}
|
|
1042
|
+
if (wantSitemap && urls.length) writeFileSync(join(site, typeof wantSitemap === "string" ? wantSitemap : "sitemap-i18n.xml"), buildSitemap(urls));
|
|
1007
1043
|
return {
|
|
1008
1044
|
files: files.length,
|
|
1009
1045
|
locales: [...locales],
|
|
1010
1046
|
missing
|
|
1011
1047
|
};
|
|
1012
1048
|
}
|
|
1049
|
+
function pageUrl(baseUrl, prefix, rel) {
|
|
1050
|
+
const path = rel.replace(/(^|\/)index\.html$/, "$1");
|
|
1051
|
+
return `${baseUrl}${prefix}${path ? `/${path}` : "/"}`;
|
|
1052
|
+
}
|
|
1053
|
+
function pageAlternates(baseUrl, rel, locales, sourceLocale) {
|
|
1054
|
+
const alternates = [];
|
|
1055
|
+
for (const locale of locales) {
|
|
1056
|
+
const prefix = locale === sourceLocale ? "" : `/${locale}`;
|
|
1057
|
+
alternates.push({
|
|
1058
|
+
hreflang: locale,
|
|
1059
|
+
href: pageUrl(baseUrl, prefix, rel)
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
alternates.push({
|
|
1063
|
+
hreflang: "x-default",
|
|
1064
|
+
href: pageUrl(baseUrl, "", rel)
|
|
1065
|
+
});
|
|
1066
|
+
return alternates;
|
|
1067
|
+
}
|
|
1068
|
+
function buildSitemap(urls) {
|
|
1069
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls.flatMap(({ alternates }) => {
|
|
1070
|
+
const alts = alternates.map((a) => `<xhtml:link rel="alternate" hreflang="${a.hreflang}" href="${escapeAttr(a.href)}"/>`).join("");
|
|
1071
|
+
return alternates.filter((a) => a.hreflang !== "x-default").map((self) => `<url><loc>${escapeAttr(self.href)}</loc>${alts}</url>`);
|
|
1072
|
+
}).join("")}</urlset>\n`;
|
|
1073
|
+
}
|
|
1013
1074
|
function parseAttrs(chunk) {
|
|
1014
1075
|
const attrs = /* @__PURE__ */ new Map();
|
|
1015
1076
|
ATTR.lastIndex = 0;
|
|
@@ -1166,6 +1227,10 @@ Options:
|
|
|
1166
1227
|
--dry-run list what would be translated, write nothing (translate)
|
|
1167
1228
|
--locale <id> pseudo-locale id (pseudo, default: en-XA)
|
|
1168
1229
|
--site <path> built site directory (render, default: dist)
|
|
1230
|
+
--attribute <name> base data attribute (render, default: data-verbaly)
|
|
1231
|
+
--base-url <url> site origin — enables hreflang alternates (render)
|
|
1232
|
+
--sitemap emit sitemap-i18n.xml with per-locale alternates (render)
|
|
1233
|
+
--clean remove existing locale dirs before mirroring (render)
|
|
1169
1234
|
|
|
1170
1235
|
Config file: verbaly.config.{js,mjs,ts,mts,json} at root (flags win).
|
|
1171
1236
|
The claude provider needs @anthropic-ai/sdk installed and ANTHROPIC_API_KEY set.
|
|
@@ -1182,6 +1247,10 @@ async function main() {
|
|
|
1182
1247
|
model: { type: "string" },
|
|
1183
1248
|
locale: { type: "string" },
|
|
1184
1249
|
site: { type: "string" },
|
|
1250
|
+
attribute: { type: "string" },
|
|
1251
|
+
"base-url": { type: "string" },
|
|
1252
|
+
sitemap: { type: "boolean" },
|
|
1253
|
+
clean: { type: "boolean" },
|
|
1185
1254
|
"dry-run": { type: "boolean" },
|
|
1186
1255
|
help: {
|
|
1187
1256
|
type: "boolean",
|
|
@@ -1288,7 +1357,11 @@ async function main() {
|
|
|
1288
1357
|
if (command === "render") {
|
|
1289
1358
|
const result = await renderSite(cfg, {
|
|
1290
1359
|
site: values.site,
|
|
1291
|
-
locales: values.locales?.split(",")
|
|
1360
|
+
locales: values.locales?.split(","),
|
|
1361
|
+
attribute: values.attribute,
|
|
1362
|
+
baseUrl: values["base-url"],
|
|
1363
|
+
sitemap: values.sitemap,
|
|
1364
|
+
clean: values.clean
|
|
1292
1365
|
});
|
|
1293
1366
|
console.log(`[verbaly] ${result.files} pages × ${result.locales.length} locales (${result.locales.join(", ")})`);
|
|
1294
1367
|
for (const [locale, keys] of Object.entries(result.missing)) console.warn(` ${locale}: ${keys.length} keys not pre-filled — ${keys.join(", ")}`);
|