@verbaly/compiler 0.13.1 → 0.14.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/README.md +2 -0
- package/dist/cli.js +80 -6
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +67 -5
- 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",
|
|
@@ -968,32 +970,66 @@ function renderHtml(html, options) {
|
|
|
968
970
|
}
|
|
969
971
|
}
|
|
970
972
|
}
|
|
973
|
+
if (options.alternates?.length) injectAlternates(ms, html, options.alternates);
|
|
971
974
|
return {
|
|
972
975
|
html: ms.toString(),
|
|
973
976
|
missing: [...missing]
|
|
974
977
|
};
|
|
975
978
|
}
|
|
979
|
+
function injectAlternates(ms, html, alternates) {
|
|
980
|
+
const links = alternates.map((a) => `<link rel="alternate" hreflang="${escapeAttr(a.hreflang)}" href="${escapeAttr(a.href)}">`).join("");
|
|
981
|
+
const block = `${HREFLANG_OPEN}${links}${HREFLANG_CLOSE}`;
|
|
982
|
+
const from = html.indexOf(HREFLANG_OPEN);
|
|
983
|
+
if (from !== -1) {
|
|
984
|
+
const to = html.indexOf(HREFLANG_CLOSE, from);
|
|
985
|
+
if (to !== -1) {
|
|
986
|
+
const end = to + 24;
|
|
987
|
+
if (html.slice(from, end) !== block) ms.overwrite(from, end, block);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
const head = /<\/head\s*>/i.exec(html);
|
|
992
|
+
if (head) ms.appendLeft(head.index, block);
|
|
993
|
+
}
|
|
976
994
|
async function renderSite(cfg, options = {}) {
|
|
977
|
-
const site = join(cfg.root, options.site ?? "dist");
|
|
995
|
+
const site = join(cfg.root, options.site ?? cfg.render.site ?? "dist");
|
|
978
996
|
const locales = options.locales ?? cfg.locales;
|
|
997
|
+
const attribute = options.attribute ?? cfg.render.attribute;
|
|
998
|
+
const baseUrl = (options.baseUrl ?? cfg.render.baseUrl)?.replace(/\/+$/, "");
|
|
999
|
+
const wantHreflang = (options.hreflang ?? cfg.render.hreflang ?? true) && baseUrl !== void 0;
|
|
1000
|
+
const wantSitemap = (options.sitemap ?? cfg.render.sitemap ?? false) && baseUrl !== void 0;
|
|
1001
|
+
const clean = options.clean ?? cfg.render.clean ?? false;
|
|
979
1002
|
const catalogs = loadCatalogs(cfg);
|
|
1003
|
+
if (clean) {
|
|
1004
|
+
for (const locale of locales) if (locale !== cfg.sourceLocale) rmSync(join(site, locale), {
|
|
1005
|
+
recursive: true,
|
|
1006
|
+
force: true
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
980
1009
|
const files = await glob("**/*.html", {
|
|
981
1010
|
cwd: site,
|
|
982
1011
|
absolute: true,
|
|
983
1012
|
ignore: locales.map((locale) => `${locale}/**`)
|
|
984
1013
|
});
|
|
985
1014
|
const missing = {};
|
|
1015
|
+
const urls = [];
|
|
986
1016
|
for (const file of files) {
|
|
987
1017
|
const html = readFileSync(file, "utf8");
|
|
988
|
-
const rel = relative(site, file);
|
|
1018
|
+
const rel = relative(site, file).replace(/\\/g, "/");
|
|
1019
|
+
const alternates = wantHreflang ? pageAlternates(baseUrl, rel, locales, cfg.sourceLocale) : [];
|
|
1020
|
+
if (wantHreflang) urls.push({
|
|
1021
|
+
rel,
|
|
1022
|
+
alternates
|
|
1023
|
+
});
|
|
989
1024
|
for (const locale of locales) {
|
|
990
1025
|
const result = renderHtml(html, {
|
|
991
1026
|
locale,
|
|
992
1027
|
catalogs,
|
|
993
1028
|
sourceLocale: cfg.sourceLocale,
|
|
994
|
-
attribute
|
|
1029
|
+
attribute,
|
|
995
1030
|
richTags: options.richTags,
|
|
996
|
-
richLinks: options.richLinks ?? cfg.render.links
|
|
1031
|
+
richLinks: options.richLinks ?? cfg.render.links,
|
|
1032
|
+
alternates
|
|
997
1033
|
});
|
|
998
1034
|
for (const key of result.missing) {
|
|
999
1035
|
const list = missing[locale] ??= [];
|
|
@@ -1004,12 +1040,38 @@ async function renderSite(cfg, options = {}) {
|
|
|
1004
1040
|
writeFileSync(out, result.html);
|
|
1005
1041
|
}
|
|
1006
1042
|
}
|
|
1043
|
+
if (wantSitemap && urls.length) writeFileSync(join(site, typeof wantSitemap === "string" ? wantSitemap : "sitemap-i18n.xml"), buildSitemap(urls));
|
|
1007
1044
|
return {
|
|
1008
1045
|
files: files.length,
|
|
1009
1046
|
locales: [...locales],
|
|
1010
1047
|
missing
|
|
1011
1048
|
};
|
|
1012
1049
|
}
|
|
1050
|
+
function pageUrl(baseUrl, prefix, rel) {
|
|
1051
|
+
const path = rel.replace(/(^|\/)index\.html$/, "$1");
|
|
1052
|
+
return `${baseUrl}${prefix}${path ? `/${path}` : "/"}`;
|
|
1053
|
+
}
|
|
1054
|
+
function pageAlternates(baseUrl, rel, locales, sourceLocale) {
|
|
1055
|
+
const alternates = [];
|
|
1056
|
+
for (const locale of locales) {
|
|
1057
|
+
const prefix = locale === sourceLocale ? "" : `/${locale}`;
|
|
1058
|
+
alternates.push({
|
|
1059
|
+
hreflang: locale,
|
|
1060
|
+
href: pageUrl(baseUrl, prefix, rel)
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
alternates.push({
|
|
1064
|
+
hreflang: "x-default",
|
|
1065
|
+
href: pageUrl(baseUrl, "", rel)
|
|
1066
|
+
});
|
|
1067
|
+
return alternates;
|
|
1068
|
+
}
|
|
1069
|
+
function buildSitemap(urls) {
|
|
1070
|
+
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 }) => {
|
|
1071
|
+
const alts = alternates.map((a) => `<xhtml:link rel="alternate" hreflang="${a.hreflang}" href="${escapeAttr(a.href)}"/>`).join("");
|
|
1072
|
+
return alternates.filter((a) => a.hreflang !== "x-default").map((self) => `<url><loc>${escapeAttr(self.href)}</loc>${alts}</url>`);
|
|
1073
|
+
}).join("")}</urlset>\n`;
|
|
1074
|
+
}
|
|
1013
1075
|
function parseAttrs(chunk) {
|
|
1014
1076
|
const attrs = /* @__PURE__ */ new Map();
|
|
1015
1077
|
ATTR.lastIndex = 0;
|
|
@@ -1166,6 +1228,10 @@ Options:
|
|
|
1166
1228
|
--dry-run list what would be translated, write nothing (translate)
|
|
1167
1229
|
--locale <id> pseudo-locale id (pseudo, default: en-XA)
|
|
1168
1230
|
--site <path> built site directory (render, default: dist)
|
|
1231
|
+
--attribute <name> base data attribute (render, default: data-verbaly)
|
|
1232
|
+
--base-url <url> site origin — enables hreflang alternates (render)
|
|
1233
|
+
--sitemap emit sitemap-i18n.xml with per-locale alternates (render)
|
|
1234
|
+
--clean remove existing locale dirs before mirroring (render)
|
|
1169
1235
|
|
|
1170
1236
|
Config file: verbaly.config.{js,mjs,ts,mts,json} at root (flags win).
|
|
1171
1237
|
The claude provider needs @anthropic-ai/sdk installed and ANTHROPIC_API_KEY set.
|
|
@@ -1182,6 +1248,10 @@ async function main() {
|
|
|
1182
1248
|
model: { type: "string" },
|
|
1183
1249
|
locale: { type: "string" },
|
|
1184
1250
|
site: { type: "string" },
|
|
1251
|
+
attribute: { type: "string" },
|
|
1252
|
+
"base-url": { type: "string" },
|
|
1253
|
+
sitemap: { type: "boolean" },
|
|
1254
|
+
clean: { type: "boolean" },
|
|
1185
1255
|
"dry-run": { type: "boolean" },
|
|
1186
1256
|
help: {
|
|
1187
1257
|
type: "boolean",
|
|
@@ -1288,7 +1358,11 @@ async function main() {
|
|
|
1288
1358
|
if (command === "render") {
|
|
1289
1359
|
const result = await renderSite(cfg, {
|
|
1290
1360
|
site: values.site,
|
|
1291
|
-
locales: values.locales?.split(",")
|
|
1361
|
+
locales: values.locales?.split(","),
|
|
1362
|
+
attribute: values.attribute,
|
|
1363
|
+
baseUrl: values["base-url"],
|
|
1364
|
+
sitemap: values.sitemap,
|
|
1365
|
+
clean: values.clean
|
|
1292
1366
|
});
|
|
1293
1367
|
console.log(`[verbaly] ${result.files} pages × ${result.locales.length} locales (${result.locales.join(", ")})`);
|
|
1294
1368
|
for (const [locale, keys] of Object.entries(result.missing)) console.warn(` ${locale}: ${keys.length} keys not pre-filled — ${keys.join(", ")}`);
|