@verbaly/compiler 0.13.0 → 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 CHANGED
@@ -15,9 +15,9 @@ The compiler behind [Verbaly](https://github.com/AronSoto/verbaly): AST extracti
15
15
 
16
16
  > Most projects don't install this directly — [`@verbaly/vite`](https://www.npmjs.com/package/@verbaly/vite) wraps it with zero config. Reach for it when scripting extraction/checks yourself.
17
17
 
18
- ## CLI
18
+ ## 🧰 CLI
19
19
 
20
- ```bash
20
+ ```
21
21
  npx verbaly init # scaffold config + locale catalogs (detects your bundler)
22
22
  npx verbaly doctor # diagnose the setup (config, catalogs, plugin, types, keys)
23
23
  npx verbaly extract # sync catalogs + types
@@ -30,30 +30,32 @@ npx verbaly render # pre-fill data-verbaly HTML per locale (SSG — kill
30
30
 
31
31
  Reads `verbaly.config.{js,mjs,ts,mts,json}` (TS configs need `esbuild` installed). Generates `locales/<locale>.json` (flat, portable — no proprietary format) and `verbaly.d.ts` with params typed per key.
32
32
 
33
- ## Machine translation
33
+ ## 🤖 Machine translation
34
34
 
35
35
  `verbaly translate` fills the `""` holes `check` reports. The default provider uses Claude via the official SDK — install it as a dev dependency (translation is a build-time step, not an app runtime dependency): `pnpm add -D @anthropic-ai/sdk` (or `npm i -D`), plus `ANTHROPIC_API_KEY`. Default model is `claude-sonnet-5` (balanced quality/cost); override with `translate.model` in config or `--model <id>`. Placeholders, variants and tags are validated after translation — anything not preserved verbatim stays `""` so `check` keeps failing. Plug your own provider in `verbaly.config.ts`:
36
36
 
37
- ```ts
37
+ ```
38
38
  translate: {
39
39
  provider: async ({ sourceLocale, targetLocale, messages }) => ({ ...translated });
40
40
  }
41
41
  ```
42
42
 
43
- ## Static rendering (SSG)
43
+ ## 📄 Static rendering (SSG)
44
44
 
45
45
  `verbaly render` walks your built site (`dist/` by default, `--site <path>` to change) and pre-fills every `data-verbaly` element **per locale** using the real runtime — plurals, `Intl` formatting, `data-verbaly-args`, attribute translation and `data-verbaly-rich` (same whitelist, XSS-safe). The source locale is filled in place; every other locale is mirrored to `dist/<locale>/…` with `<html lang>` set. Static HTML ships already translated — **no flash of untranslated content** — and the runtime attributes stay put, so client-side locale switching keeps working.
46
46
 
47
47
  Named links in rich messages render as real `<a>` elements — hrefs come from config or markup, never from messages (`javascript:` blocked):
48
48
 
49
- ```ts
49
+ ```
50
50
  // verbaly.config.ts
51
51
  render: { links: { docs: { href: '/docs', target: '_blank', rel: 'noopener' } } }
52
52
  ```
53
53
 
54
54
  Per-element `data-verbaly-links='{"repo":"https://…"}'` merges over the config map.
55
55
 
56
- ## Pseudo-localization
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
+
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`.
59
61
 
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: options.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(", ")}`);