@riebeckite/plugin-l10n 0.0.27 → 0.0.29

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
@@ -47,6 +47,12 @@ No fallback pages are generated. `getLocalization(manifest, slug)` returns `avai
47
47
 
48
48
  `l10n(...)` publishes the built-in, styled LanguageSwitcher in the standard article layout. A standard Site consumer renders that, so no l10n-specific article integration is needed. It is omitted when a page has fewer than two real translations.
49
49
 
50
+ ## CSS hooks
51
+
52
+ The LanguageSwitcher uses `rr-l10n-switcher`,
53
+ `rr-l10n-switcher__label`, `rr-l10n-switcher__list`, and
54
+ `rr-l10n-switcher__link`. Default CSS targets these hooks.
55
+
50
56
  ## Custom detector
51
57
 
52
58
  For another convention, provide `detect`. Its language is considered after frontmatter and before filename; its `translationId` is explicit.
@@ -62,7 +68,7 @@ l10n({
62
68
 
63
69
  ## Links and SEO
64
70
 
65
- Before the existing Content Graph is built, WikiLink graph targets are switched to the source note's language when that translation exists; otherwise their original target remains. The rendered article rewrites both Obsidian WikiLinks (after `@riebeckite/plugin-obsidian-markdown` resolves them) and Markdown links to an existing translation in the current page's language, preserving query strings and fragments. When no translation exists, the original/default target remains linked. External, fragment-only, unknown, asset, and other non-content URLs remain unchanged. The Content Graph and rendered links use the same resolution policy; no second graph is created.
71
+ Before the existing Content Graph is built, WikiLink graph targets are switched to the source note's language when that translation exists; otherwise their original target remains. The rendered article rewrites both Obsidian WikiLinks (after `@riebeckite/plugin-obsidian-markdown` resolves them) and Markdown links to an existing translation in the current page's language, preserving query strings and fragments. When no translation exists, the original/default target remains linked. External, fragment-only, unknown, asset, and other non-content URLs remain unchanged. Link dependency tracking uses the same parsed Markdown URLs as rendering, including HTML character references. The Content Graph and rendered links use the same resolution policy; no second graph is created.
66
72
 
67
73
  Each translated entry receives one `<link rel="alternate" hreflang="…">` per existing translation through Core's `headTags` extension point. The site shell remains responsible for rendering those tags and for selecting `<html lang>` for a request.
68
74
 
package/README_ja.md CHANGED
@@ -46,7 +46,12 @@ English/getting-started.md lang: en, translation: getting-started
46
46
 
47
47
  `l10n(...)` は標準記事レイアウトへ組み込みの言語切替 UI を登録します。標準 Site consumer がこの UI を描画するため、l10n 専用の記事レイアウト実装は不要です。実在する翻訳が 2 つ未満のページには表示されません。
48
48
 
49
- 各ページには Core の `headTags` 拡張を通じ、実在する翻訳だけを対象に `hreflang` の alternate link を追加します。サイトシェルはこれらのタグの描画と、リクエストに応じた `<html lang>` の選択を担当します。WikiLink の Content Graph は同言語の翻訳を優先し、なければ元のリンク先を使います。本文では Obsidian WikiLink を `@riebeckite/plugin-obsidian-markdown` が解決した後、WikiLink と Markdown の内部リンクを同じ規則で処理します。現在のページと同じ言語の翻訳があればその URL に切り替え、なければ original/default のリンク先を維持します。クエリ文字列とフラグメントは維持し、外部 URL、フラグメントのみのリンク、不明な URL、asset などコンテンツ以外の URL は変更しません。Content Graph と本文リンクは同じ解決ポリシーを使います。
49
+ ## CSS Hook
50
+
51
+ 言語切替 UI には `rr-l10n-switcher`、`rr-l10n-switcher__label`、
52
+ `rr-l10n-switcher__list`、`rr-l10n-switcher__link` を使用します。default CSS はこれらの Hook を対象にします。
53
+
54
+ 各ページには Core の `headTags` 拡張を通じ、実在する翻訳だけを対象に `hreflang` の alternate link を追加します。サイトシェルはこれらのタグの描画と、リクエストに応じた `<html lang>` の選択を担当します。WikiLink の Content Graph は同言語の翻訳を優先し、なければ元のリンク先を使います。本文では Obsidian WikiLink を `@riebeckite/plugin-obsidian-markdown` が解決した後、WikiLink と Markdown の内部リンクを同じ規則で処理します。現在のページと同じ言語の翻訳があればその URL に切り替え、なければ original/default のリンク先を維持します。クエリ文字列とフラグメントは維持し、外部 URL、フラグメントのみのリンク、不明な URL、asset などコンテンツ以外の URL は変更しません。リンク依存関係の追跡でも描画と同じ解析済み Markdown URL を使用するため、HTML 文字参照も同じように扱います。Content Graph と本文リンクは同じ解決ポリシーを使います。
50
55
 
51
56
  ## 独自検出
52
57
 
@@ -5,7 +5,10 @@ import { useArticleContext, useSiteContext } from "@riebeckite/ui";
5
5
  import {
6
6
  attachErrorPath,
7
7
  createStyleAsset,
8
- definePlugin
8
+ definePlugin,
9
+ escapeRegExp,
10
+ extractContentLinks,
11
+ extractMarkdownLinkUrls
9
12
  } from "@riebeckite/core";
10
13
  import { parse } from "yaml";
11
14
  var TRANSLATION_METADATA_KEY = "l10n.translationId";
@@ -58,15 +61,15 @@ var LanguageSwitcher = ((props) => {
58
61
  const article = useArticleContext();
59
62
  const localization = props.localization ?? (site && article?.entry ? getLocalization(site.view, article.entry.slug) : null);
60
63
  if (!localization || localization.availableLanguages.length < 2) return null;
61
- return /* @__PURE__ */ jsxs("nav", { class: "l10n-switcher", "aria-label": "Language", children: [
62
- /* @__PURE__ */ jsx("span", { class: "l10n-switcher__label", children: "Language" }),
63
- /* @__PURE__ */ jsx("ul", { class: "l10n-switcher__list", children: localization.availableLanguages.map((lang) => {
64
+ return /* @__PURE__ */ jsxs("nav", { class: "rr-l10n-switcher", "aria-label": "Language", children: [
65
+ /* @__PURE__ */ jsx("span", { class: "rr-l10n-switcher__label", children: "Language" }),
66
+ /* @__PURE__ */ jsx("ul", { class: "rr-l10n-switcher__list", children: localization.availableLanguages.map((lang) => {
64
67
  const href = localization.translations[lang];
65
68
  if (!href) return null;
66
69
  return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
67
70
  "a",
68
71
  {
69
- class: "l10n-switcher__link",
72
+ class: "rr-l10n-switcher__link",
70
73
  href,
71
74
  hreflang: lang,
72
75
  lang,
package/dist/index.js CHANGED
@@ -5,7 +5,10 @@ import { useArticleContext, useSiteContext } from "@riebeckite/ui";
5
5
  import {
6
6
  attachErrorPath,
7
7
  createStyleAsset,
8
- definePlugin
8
+ definePlugin,
9
+ escapeRegExp,
10
+ extractContentLinks,
11
+ extractMarkdownLinkUrls
9
12
  } from "@riebeckite/core";
10
13
  import { parse } from "yaml";
11
14
  var TRANSLATION_METADATA_KEY = "l10n.translationId";
@@ -15,15 +18,15 @@ function l10n(options) {
15
18
  return definePlugin({
16
19
  name: "l10n",
17
20
  processedContentCache: {
18
- version: "l10n-global-content-locations-v1",
19
- dependencyMode: "global"
21
+ version: "l10n-content-locations-v2",
22
+ dependencyMode: "tracked",
23
+ contentDependencyFingerprint: ({ slug }) => state.signatures.get(slug)
20
24
  },
21
25
  provides: ["content.localization"],
22
- contentOutputDependencies: [{ type: "global" }],
23
26
  options,
24
27
  assets: [createStyleAsset("l10n", "@riebeckite/plugin-l10n/style.css")],
25
28
  validateOptions: () => validateOptions(options).map((message) => ({ path: "l10n", message })),
26
- extendContentLocations: ({ entries, locations }) => {
29
+ extendContentLocations: ({ entries, locations, contentIndex }) => {
27
30
  state = {
28
31
  ...detectContents(entries, resolved),
29
32
  locations,
@@ -38,10 +41,16 @@ function l10n(options) {
38
41
  withLocalization(location, content, resolved)
39
42
  );
40
43
  }
44
+ const slugsByPermalink = new Map(
45
+ [...locations].map(([slug, location]) => [location.permalink, slug])
46
+ );
41
47
  state = {
42
48
  ...state,
43
- slugsByPermalink: new Map(
44
- [...locations].map(([slug, location]) => [location.permalink, slug])
49
+ slugsByPermalink,
50
+ signatures: computeLocalizationSignatures(
51
+ { ...state, slugsByPermalink },
52
+ entries,
53
+ contentIndex
45
54
  )
46
55
  };
47
56
  },
@@ -273,7 +282,8 @@ function detectContents(entries, options) {
273
282
  diagnostics,
274
283
  conflictedGroups,
275
284
  locations: null,
276
- slugsByPermalink: /* @__PURE__ */ new Map()
285
+ slugsByPermalink: /* @__PURE__ */ new Map(),
286
+ signatures: /* @__PURE__ */ new Map()
277
287
  };
278
288
  }
279
289
  function parseFrontmatter(entry) {
@@ -357,9 +367,6 @@ function prefixLanguage(permalink, language) {
357
367
  const suffix = permalink === "/" ? "" : permalink;
358
368
  return `/${language}${suffix}`;
359
369
  }
360
- function escapeRegExp(value) {
361
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
362
- }
363
370
  function findTranslation(state, translationId, lang) {
364
371
  const key = `${translationId}\0${lang}`;
365
372
  if (state.conflictedGroups.has(key)) return null;
@@ -370,6 +377,100 @@ function resolveLocalizedTarget(state, source, targetSlug) {
370
377
  if (!target) return null;
371
378
  return findTranslation(state, target.translationId, source.lang) ?? target;
372
379
  }
380
+ var PUBLICATION_KEYS = [
381
+ "visibility",
382
+ "publishAt",
383
+ "publish",
384
+ "private",
385
+ "draft"
386
+ ];
387
+ function signalValue(value) {
388
+ if (value === void 0 || value === null) return "";
389
+ if (value instanceof Date) return value.toISOString();
390
+ if (typeof value === "boolean") return value ? "true" : "false";
391
+ return String(value);
392
+ }
393
+ function publicationSignal(frontmatter) {
394
+ return PUBLICATION_KEYS.map((key) => signalValue(frontmatter[key])).join(
395
+ ""
396
+ );
397
+ }
398
+ function groupMemberToken(state, member) {
399
+ return [
400
+ member.lang,
401
+ member.slug,
402
+ state.locations?.get(member.slug)?.permalink ?? "",
403
+ publicationSignal(member.frontmatter)
404
+ ].join("");
405
+ }
406
+ function localizedResolutionToken(state, source, targetSlug) {
407
+ const target = resolveLocalizedTarget(state, source, targetSlug);
408
+ if (!target) return "";
409
+ return `${target.slug}${state.locations?.get(target.slug)?.permalink ?? ""}`;
410
+ }
411
+ function referencedPermalinks(markdown, slugsByPermalink) {
412
+ if (slugsByPermalink.size === 0) return [];
413
+ const found = /* @__PURE__ */ new Set();
414
+ for (const url of extractMarkdownLinkUrls(markdown)) {
415
+ const { permalink } = splitLinkUrl(url);
416
+ if (slugsByPermalink.has(permalink)) found.add(permalink);
417
+ }
418
+ return [...found].sort();
419
+ }
420
+ function computeLocalizationSignatures(state, entries, contentIndex) {
421
+ const markdownBySlug = new Map(
422
+ entries.map((entry) => [entry.slug, entry.markdown])
423
+ );
424
+ const membersByTranslation = /* @__PURE__ */ new Map();
425
+ for (const member of state.contents.values()) {
426
+ const members = membersByTranslation.get(member.translationId) ?? [];
427
+ members.push(groupMemberToken(state, member));
428
+ membersByTranslation.set(member.translationId, members);
429
+ }
430
+ for (const members of membersByTranslation.values()) members.sort();
431
+ const collectParts = (content, trail) => {
432
+ const members = membersByTranslation.get(content.translationId) ?? [];
433
+ const parts = [
434
+ `own:${content.lang}${content.translationId}`,
435
+ ...members.map((member) => `group:${member}`)
436
+ ];
437
+ const markdown = markdownBySlug.get(content.slug) ?? "";
438
+ const links = extractContentLinks(markdown, contentIndex);
439
+ for (const link of links) {
440
+ if (link.kind === "image" || link.kind === "attachment") continue;
441
+ parts.push(
442
+ `wiki:${link.raw.toLowerCase()}=${link.slug ? localizedResolutionToken(state, content, link.slug) : ""}`
443
+ );
444
+ }
445
+ for (const permalink of referencedPermalinks(
446
+ markdown,
447
+ state.slugsByPermalink
448
+ )) {
449
+ const targetSlug = state.slugsByPermalink.get(permalink);
450
+ parts.push(
451
+ `url:${permalink}=${targetSlug ? localizedResolutionToken(state, content, targetSlug) : ""}`
452
+ );
453
+ }
454
+ if (trail.size < 3) {
455
+ for (const link of links) {
456
+ if (!link.embed || !link.slug || trail.has(link.slug)) continue;
457
+ const embedded = state.contents.get(link.slug);
458
+ if (!embedded) continue;
459
+ parts.push(
460
+ ...collectParts(embedded, /* @__PURE__ */ new Set([...trail, embedded.slug]))
461
+ );
462
+ }
463
+ }
464
+ return parts;
465
+ };
466
+ const signatures = /* @__PURE__ */ new Map();
467
+ for (const content of state.contents.values()) {
468
+ const parts = collectParts(content, /* @__PURE__ */ new Set());
469
+ parts.sort();
470
+ signatures.set(content.slug, parts.join("\n"));
471
+ }
472
+ return signatures;
473
+ }
373
474
  function addLocalizationHeadTags(manifest, state) {
374
475
  const translationsById = collectDiscoverableTranslations(manifest);
375
476
  for (const entry of manifest.discoverableEntries) {
@@ -486,7 +587,8 @@ function emptyState() {
486
587
  diagnostics: [],
487
588
  conflictedGroups: /* @__PURE__ */ new Set(),
488
589
  locations: null,
489
- slugsByPermalink: /* @__PURE__ */ new Map()
590
+ slugsByPermalink: /* @__PURE__ */ new Map(),
591
+ signatures: /* @__PURE__ */ new Map()
490
592
  };
491
593
  }
492
594
 
@@ -497,15 +599,15 @@ var LanguageSwitcher = ((props) => {
497
599
  const article = useArticleContext();
498
600
  const localization = props.localization ?? (site && article?.entry ? getLocalization(site.view, article.entry.slug) : null);
499
601
  if (!localization || localization.availableLanguages.length < 2) return null;
500
- return /* @__PURE__ */ jsxs("nav", { class: "l10n-switcher", "aria-label": "Language", children: [
501
- /* @__PURE__ */ jsx("span", { class: "l10n-switcher__label", children: "Language" }),
502
- /* @__PURE__ */ jsx("ul", { class: "l10n-switcher__list", children: localization.availableLanguages.map((lang) => {
602
+ return /* @__PURE__ */ jsxs("nav", { class: "rr-l10n-switcher", "aria-label": "Language", children: [
603
+ /* @__PURE__ */ jsx("span", { class: "rr-l10n-switcher__label", children: "Language" }),
604
+ /* @__PURE__ */ jsx("ul", { class: "rr-l10n-switcher__list", children: localization.availableLanguages.map((lang) => {
503
605
  const href = localization.translations[lang];
504
606
  if (!href) return null;
505
607
  return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
506
608
  "a",
507
609
  {
508
- class: "l10n-switcher__link",
610
+ class: "rr-l10n-switcher__link",
509
611
  href,
510
612
  hreflang: lang,
511
613
  lang,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riebeckite/plugin-l10n",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -23,12 +23,12 @@
23
23
  "dependencies": {
24
24
  "hono": "^4.12.25",
25
25
  "yaml": "^2.9.0",
26
- "@riebeckite/core": "0.0.27",
27
- "@riebeckite/ui": "0.0.27"
26
+ "@riebeckite/core": "0.0.29",
27
+ "@riebeckite/ui": "0.0.29"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsx": "^4.22.4",
31
- "@riebeckite/plugin-obsidian-markdown": "0.0.27"
31
+ "@riebeckite/plugin-obsidian-markdown": "0.0.29"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"
package/style.css CHANGED
@@ -1,4 +1,4 @@
1
- .l10n-switcher {
1
+ .rr-l10n-switcher {
2
2
  display: flex;
3
3
  flex-wrap: wrap;
4
4
  align-items: baseline;
@@ -7,7 +7,7 @@
7
7
  font-size: 0.8125rem;
8
8
  }
9
9
 
10
- .l10n-switcher__label {
10
+ .rr-l10n-switcher__label {
11
11
  font-size: 0.6875rem;
12
12
  font-weight: 700;
13
13
  letter-spacing: 0.12em;
@@ -18,7 +18,7 @@
18
18
  text-transform: uppercase;
19
19
  }
20
20
 
21
- .l10n-switcher__list {
21
+ .rr-l10n-switcher__list {
22
22
  display: flex;
23
23
  flex-wrap: wrap;
24
24
  gap: 0.75rem;
@@ -27,19 +27,19 @@
27
27
  list-style: none;
28
28
  }
29
29
 
30
- .l10n-switcher__link {
30
+ .rr-l10n-switcher__link {
31
31
  color: inherit;
32
32
  text-decoration: none;
33
33
  text-underline-offset: 0.2em;
34
34
  }
35
35
 
36
- .l10n-switcher__link:hover,
37
- .l10n-switcher__link:focus-visible {
36
+ .rr-l10n-switcher__link:hover,
37
+ .rr-l10n-switcher__link:focus-visible {
38
38
  color: var(--rb-color-accent, currentColor);
39
39
  text-decoration: underline;
40
40
  }
41
41
 
42
- .l10n-switcher__link[aria-current="page"] {
42
+ .rr-l10n-switcher__link[aria-current="page"] {
43
43
  font-weight: 700;
44
44
  text-decoration: underline;
45
45
  }