@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 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: options.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(", ")}`);