create-zudo-doc 5.19.0 → 5.20.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/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ All notable changes to `create-zudo-doc` are documented in this file.
4
4
 
5
5
  The format is based on Keep a Changelog, and release notes are generated from the changelog MDX pages.
6
6
 
7
+ ## [Unreleased]
8
+
9
+ No unreleased changes.
10
+
11
+ ## [5.20.0] - 2026-09-09
12
+
13
+ ### Bug Fixes
14
+
15
+ - Made the generated documentation-skill setup script work on stock macOS Bash 3.2 and corrected its locale/config handling, including checking that the documentation symlink exists before emitting locale guidance. (`85b603e33`, `f6b699fe0`)
16
+ - Fixed the generated link checker to recognize IDs and links when quoted attributes contain `>`, and reduced repeated scanning by parsing each page once. (`0e52271b9`, `7c0144e51`)
17
+
18
+ ### Other Changes
19
+
20
+ - Updated the Design Token Panel feature's emitted `@takazudo/zdtp` pin to `0.7.0`. Fresh scaffolds use the compatible panel integration without a migration. (`e0fc27fbd`, `9554dbbea`)
21
+
22
+ ## [5.19.1] - 2026-09-08
23
+
24
+ ### Other Changes
25
+
26
+ - A generated project's `package.json` now pins `@takazudo/zfb`, `@takazudo/zfb-runtime`, and `@takazudo/zfb-md-wasm` at `2.16.0`, and `@takazudo/zdtp` at `0.5.2` when the Design Token Panel feature is enabled (`47c149385`). Both upstream releases are drop-in for a fresh scaffold — zfb 2.16.0 fixes an `ETXTBSY` crash when first executing a just-written esbuild binary, zdtp 0.5.2 is additive — so no public API, export, config default, or engine requirement moves and a fresh scaffold needs no migration.
27
+
7
28
  ## [5.19.0] - 2026-09-07
8
29
 
9
30
  ### Features
@@ -31,5 +31,5 @@ export declare function deriveDocSkillName(projectName: string): string;
31
31
  *
32
32
  * Bumped in lockstep by scripts/release-create-zudo-doc.sh.
33
33
  */
34
- export declare const ZUDO_DOC_PIN = "^5.19.0";
34
+ export declare const ZUDO_DOC_PIN = "^5.20.0";
35
35
  export declare function scaffold(choices: UserChoices): Promise<void>;
package/dist/scaffold.js CHANGED
@@ -47,7 +47,7 @@ export function deriveDocSkillName(projectName) {
47
47
  *
48
48
  * Bumped in lockstep by scripts/release-create-zudo-doc.sh.
49
49
  */
50
- export const ZUDO_DOC_PIN = "^5.19.0";
50
+ export const ZUDO_DOC_PIN = "^5.20.0";
51
51
  /**
52
52
  * Files in `templates/base/**` that must not be copied by the unconditional
53
53
  * base mirror. Each entry is matched against the path relative to
@@ -759,9 +759,22 @@ function generatePackageJson(choices, localePlan) {
759
759
  // four shipped wasm binaries are byte-size identical to 2.15.0 (rebuild
760
760
  // noise only), and no public API, export, config default, or engine
761
761
  // requirement moves, so a fresh scaffold needs no migration.
762
- "@takazudo/zfb": "2.15.1",
763
- "@takazudo/zfb-runtime": "2.15.1",
764
- "@takazudo/zfb-md-wasm": "2.15.1",
762
+ // 2.16.0: one upstream bug fix, plus a documentation-only md-wasm change.
763
+ // zfb's esbuild version-gate spawn (`ensure_binary_verified`) now retries on
764
+ // `ETXTBSY` through zfb-build's existing retry loop instead of a bare
765
+ // `.output()`, so a build no longer dies with `Text file busy (os error 26)`
766
+ // on the first exec of a binary a packaged zfb has only just written to a
767
+ // tempdir. The fix ships in the five platform binary packages, which roll to
768
+ // 2.16.0 with the wrapper. zfb-runtime and zfb-adapter-cloudflare have no
769
+ // package-specific change; md-wasm changes only its README (a digest
770
+ // disclaimer beside the shipped-size table), keeps every artifact at its
771
+ // 2.15.1 byte size, and moves all four wasm SHA-256 digests as every release
772
+ // does — pin by semver, as here, and there is nothing to re-pin. No public
773
+ // API, export, config default, or engine requirement moves, so a fresh
774
+ // scaffold needs no migration.
775
+ "@takazudo/zfb": "2.16.0",
776
+ "@takazudo/zfb-runtime": "2.16.0",
777
+ "@takazudo/zfb-md-wasm": "2.16.0",
765
778
  // @takazudo/zudo-doc — published from this monorepo via
766
779
  // .github/workflows/publish-zudo-doc.yml. The pin here is bumped in
767
780
  // lockstep by scripts/release-create-zudo-doc.sh whenever zudo-doc's
@@ -869,7 +882,7 @@ function generatePackageJson(choices, localePlan) {
869
882
  // `@takazudo/zdtp/styles.css` (see features/design-token-panel.ts). Both are
870
883
  // no-ops with the feature off, so an OFF project must not carry the dep
871
884
  // (#4009 / #4018 — it was unconditional until then, see the `deps` block).
872
- deps["@takazudo/zdtp"] = "0.5.1";
885
+ deps["@takazudo/zdtp"] = "0.7.0";
873
886
  }
874
887
  if (choices.features.includes("docHistory") ||
875
888
  choices.features.includes("assetViewer")) {
@@ -892,7 +905,7 @@ function generatePackageJson(choices, localePlan) {
892
905
  // `/exclude` at module scope from the always-bundled chrome graph; #3110
893
906
  // moved compileExclude into @takazudo/zudo-doc, so projects with both
894
907
  // docHistory and assetViewer off no longer need the package at all.
895
- deps["@takazudo/zudo-doc-history-server"] = "^5.19.0";
908
+ deps["@takazudo/zudo-doc-history-server"] = "^5.20.0";
896
909
  // tsx is no longer needed here: the relocated package plugin imports the
897
910
  // runner directly (no `tsx -e` spawn) since the package ships compiled
898
911
  // dist/ — package-first migration #2321 (#2337).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zudo-doc",
3
- "version": "5.19.0",
3
+ "version": "5.20.0",
4
4
  "description": "Create a new zudo-doc documentation site",
5
5
  "license": "MIT",
6
6
  "author": "Takeshi Takatsudo",
@@ -445,13 +445,26 @@ function decodeHtmlAttributeValue(value) {
445
445
  );
446
446
  }
447
447
 
448
- // Single shared anchor scan. `extractHtmlLinks` and
449
- // `extractProtocolRelativeHtmlLinks` classify the SAME set of `<a href>`
450
- // matches into disjoint buckets, so the grammar and the incremental line
451
- // counting live here once a fix to the anchor regex must never reach only
452
- // one of the two callers.
448
+ // Attribute-scan grammar shared by the anchor and id scans below. A quoted
449
+ // attribute value may legally contain ">" (title="a > b"), so bounding an
450
+ // attribute scan with [^>] silently drops the whole tag: a lost id is a noisy
451
+ // false STRICT FAIL, while a lost href means the link is never checked at all
452
+ // (#4046). This run crosses ">" only inside quotes, and a decoy `href=`/`id=`
453
+ // written as text inside another attribute's value stays unreachable because a
454
+ // quoted span is consumed whole. Each alternative starts with a distinct
455
+ // character, so the repetition backtracks linearly.
456
+ const HTML_ATTRIBUTE_RUN = /(?:"[^"]*"|'[^']*'|[^>"'])/.source;
457
+ const HTML_ATTRIBUTE_VALUE = /(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`\\]+))/.source;
458
+
459
+ // Single shared anchor scan. Its one consumer, `classifyHtmlAnchorHrefs`,
460
+ // splits these matches into disjoint buckets, so the grammar and the
461
+ // incremental line counting live here once and every bucket sees a fix to the
462
+ // anchor regex.
453
463
  function* iterateHtmlAnchorHrefs(html) {
454
- const regex = /<a(?=\s)[^>]*?\shref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`\\]+))[^>]*>/gi;
464
+ const regex = new RegExp(
465
+ `<a(?=\\s)${HTML_ATTRIBUTE_RUN}*?\\shref\\s*=\\s*${HTML_ATTRIBUTE_VALUE}${HTML_ATTRIBUTE_RUN}*>`,
466
+ "gi",
467
+ );
455
468
  let match;
456
469
  let lastIndex = 0;
457
470
  let line = 1;
@@ -462,30 +475,39 @@ function* iterateHtmlAnchorHrefs(html) {
462
475
  }
463
476
  }
464
477
 
465
- export function extractHtmlLinks(html) {
478
+ // One anchor pass, two disjoint buckets: internal links to resolve, and the
479
+ // protocol-relative hrefs that are external for resolution purposes but worth
480
+ // reporting informationally (see #3921/#3930). The dist walk calls this once
481
+ // per page; `extractHtmlLinks` / `extractProtocolRelativeHtmlLinks` below are
482
+ // single-bucket views for callers that want only one of the two.
483
+ export function classifyHtmlAnchorHrefs(html) {
466
484
  const links = [];
485
+ const protocolRelative = [];
467
486
  for (const { href, line } of iterateHtmlAnchorHrefs(html)) {
468
- if (/^(?:https?:|\/\/|mailto:|javascript:|data:|tel:)/i.test(href)) continue;
487
+ if (/^\/\//.test(href)) {
488
+ protocolRelative.push({ href, line });
489
+ continue;
490
+ }
491
+ if (/^(?:https?:|mailto:|javascript:|data:|tel:)/i.test(href)) continue;
469
492
  links.push({ href, line });
470
493
  }
471
- return links;
494
+ return { links, protocolRelative };
495
+ }
496
+
497
+ export function extractHtmlLinks(html) {
498
+ return classifyHtmlAnchorHrefs(html).links;
472
499
  }
473
500
 
474
- // Informational counterpart to extractHtmlLinks: same scan, but keeps only the
475
- // protocol-relative hrefs that extractHtmlLinks classifies as external and
476
- // skips (see #3921/#3930).
477
501
  export function extractProtocolRelativeHtmlLinks(html) {
478
- const links = [];
479
- for (const { href, line } of iterateHtmlAnchorHrefs(html)) {
480
- if (!/^\/\//.test(href)) continue;
481
- links.push({ href, line });
482
- }
483
- return links;
502
+ return classifyHtmlAnchorHrefs(html).protocolRelative;
484
503
  }
485
504
 
486
505
  export function extractHtmlIds(html) {
487
506
  const ids = [];
488
- const regex = /<[A-Za-z][^>]*?\sid\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`\\]+))[^>]*>/gi;
507
+ const regex = new RegExp(
508
+ `<[A-Za-z]${HTML_ATTRIBUTE_RUN}*?\\sid\\s*=\\s*${HTML_ATTRIBUTE_VALUE}${HTML_ATTRIBUTE_RUN}*>`,
509
+ "gi",
510
+ );
489
511
  let match;
490
512
  while ((match = regex.exec(html)) !== null) {
491
513
  ids.push(decodeHtmlAttributeValue(match[1] ?? match[2] ?? match[3]));
@@ -636,7 +658,15 @@ function extractStaticMdxIds(body) {
636
658
  visibleLines.push(codeFenceOpener === null ? stripInlineCode(line) : "");
637
659
  }
638
660
  const elements = visibleLines.join("\n");
639
- const regex = /<[A-Za-z][^>]*\bid\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>/gs;
661
+ // Same tokenising run as the built-HTML scans: a ">" inside a quoted MDX/JSX
662
+ // attribute value (title="a > b") must not truncate the tag and lose the id
663
+ // (#4048). The rest of this pattern keeps its own semantics — `\bid` also
664
+ // accepts `data-id`, and only non-empty quoted values count, unlike the
665
+ // built-HTML id scan.
666
+ const regex = new RegExp(
667
+ `<[A-Za-z]${HTML_ATTRIBUTE_RUN}*?\\bid\\s*=\\s*(?:"([^"]+)"|'([^']+)')${HTML_ATTRIBUTE_RUN}*>`,
668
+ "gs",
669
+ );
640
670
  let match;
641
671
  while ((match = regex.exec(elements)) !== null) ids.add(match[1] ?? match[2]);
642
672
  return ids;
@@ -743,6 +773,12 @@ export async function resolveLink(href, distDir, basePath = "/", fileDir = "") {
743
773
  return (await resolveDistTarget(href, distDir, basePath, fileDir)).type !== "missing";
744
774
  }
745
775
 
776
+ /**
777
+ * Scan budget: exactly ONE anchor pass per page, and at most ONE id extraction
778
+ * per HTML target some fragment actually references. Ids are deliberately not
779
+ * extracted eagerly — most pages are never the target of a fragment link, and
780
+ * scanning them costs a full regex pass for a set nothing reads.
781
+ */
746
782
  export async function checkHtmlLinksAndTrailing(
747
783
  distDir,
748
784
  rootDir,
@@ -756,25 +792,25 @@ export async function checkHtmlLinksAndTrailing(
756
792
  const protocolRelative = [];
757
793
  const idCache = new Map();
758
794
  const cache = new Map();
759
- const pages = [];
760
795
  const scanned = { links: 0, ids: 0 };
761
796
  for (const file of await collectFiles(distDir, [".html"])) {
762
797
  const content = await readFile(file, "utf-8");
763
- const links = extractHtmlLinks(content);
764
- const ids = extractHtmlIds(content);
798
+ const relFile = relative(rootDir, file);
799
+ // One anchor pass per page: internal links and the informational
800
+ // protocol-relative notices are disjoint buckets of the same match set.
801
+ const { links, protocolRelative: pageProtocolRelative } = classifyHtmlAnchorHrefs(content);
765
802
  scanned.links += links.length;
766
- scanned.ids += ids.length;
767
- idCache.set(file, new Set(ids));
768
- pages.push({ file, links });
769
803
 
770
- // Informational-only: classified from the content already in memoryno
771
- // second read of the file.
772
- const relFile = relative(rootDir, file);
773
- for (const { href, line } of extractProtocolRelativeHtmlLinks(content)) {
804
+ // Filtered on the HREF, exactly like every other category below an
805
+ // `excludePatterns` entry suppresses `//host/v/1.2/x` because the href
806
+ // matches, not because the page the href sits on is itself versioned.
807
+ // Without this the section had no off switch and consumers stopped
808
+ // reading it.
809
+ for (const { href, line } of pageProtocolRelative) {
810
+ if (excludePatterns.some((pattern) => pattern.test(href))) continue;
774
811
  protocolRelative.push({ file: relFile, line, href });
775
812
  }
776
- }
777
- for (const { file, links } of pages) {
813
+
778
814
  for (const { href, line } of links) {
779
815
  if (excludePatterns.some((pattern) => pattern.test(href))) continue;
780
816
  const cacheKey = href.startsWith("/") ? href : `${file}:${href}`;
@@ -783,13 +819,16 @@ export async function checkHtmlLinksAndTrailing(
783
819
  detail = await resolveDistTarget(href, distDir, basePath, dirname(file), file);
784
820
  cache.set(cacheKey, detail);
785
821
  }
786
- if (detail.type === "missing") broken.push({ file: relative(rootDir, file), line, href });
822
+ if (detail.type === "missing") broken.push({ file: relFile, line, href });
787
823
  if (detail.fragment !== null) {
788
824
  let reason = detail.fragmentError;
789
825
  if (reason === null && detail.type !== "missing" && detail.targetFile !== null && extname(detail.targetFile) === ".html") {
790
826
  let ids = idCache.get(detail.targetFile);
791
827
  if (ids === undefined) {
792
- const targetHtml = await readFile(detail.targetFile, "utf-8");
828
+ // The page being walked is already in memory; any other target is
829
+ // re-read here rather than retained, so the walk never holds more
830
+ // than one page's HTML no matter how large dist/ is.
831
+ const targetHtml = detail.targetFile === file ? content : await readFile(detail.targetFile, "utf-8");
793
832
  const targetIds = extractHtmlIds(targetHtml);
794
833
  scanned.ids += targetIds.length;
795
834
  ids = new Set(targetIds);
@@ -797,12 +836,12 @@ export async function checkHtmlLinksAndTrailing(
797
836
  }
798
837
  if (!ids.has(detail.fragment)) reason = "missing target id";
799
838
  }
800
- if (reason !== null) anchors.push({ file: relative(rootDir, file), line, href, fragment: detail.fragment, reason });
839
+ if (reason !== null) anchors.push({ file: relFile, line, href, fragment: detail.fragment, reason });
801
840
  }
802
841
  if (checkTrailing) {
803
842
  const pathPart = href.split("#")[0].split("?")[0];
804
843
  if (pathPart && pathPart !== "/" && pathPart !== "." && pathPart !== "./" && !pathPart.endsWith("/") && !extname(pathPart) && detail.type === "directoryIndex") {
805
- trailingSlash.push({ file: relative(rootDir, file), line, href });
844
+ trailingSlash.push({ file: relFile, line, href });
806
845
  }
807
846
  }
808
847
  }
@@ -914,9 +953,16 @@ async function main() {
914
953
  const realAbsolute = filter(mdxWarnings);
915
954
  const realAnchors = filter(anchorWarnings);
916
955
  const realTrailing = filter(trailingSlash);
917
- console.log(formatReport(broken, mdxWarnings, trailingSlash, anchorWarnings, protocolRelative));
956
+ // The one category filtered BEFORE printing. It has no strict gate, so the
957
+ // allowlist is a consumer's only way to quiet a known-good entry, and
958
+ // quieting has to reach the section and the count line alike or the section
959
+ // keeps nagging and stops being read.
960
+ const shownProtocolRelative = filter(protocolRelative);
961
+ console.log(formatReport(broken, mdxWarnings, trailingSlash, anchorWarnings, shownProtocolRelative));
918
962
  if (hasDist) console.log(`\nBuilt HTML scan: ${scanned.links} internal link${scanned.links === 1 ? "" : "s"} and ${scanned.ids} ID attribute${scanned.ids === 1 ? "" : "s"} inspected.`);
919
- if (protocolRelative.length > 0) console.log(`Protocol-relative links: ${protocolRelative.length} found (informational only — see "Protocol-Relative Links" section above; not counted as issues).`);
963
+ if (shownProtocolRelative.length > 0) console.log(`Protocol-relative links: ${shownProtocolRelative.length} found (informational only — see "Protocol-Relative Links" section above; not counted as issues).`);
964
+ // Protocol-relative suppressions are deliberately absent from this tally:
965
+ // the sentence is about strict-mode counts, and that category has none.
920
966
  const skipped = broken.length - realBroken.length + mdxWarnings.length - realAbsolute.length + anchorWarnings.length - realAnchors.length + trailingSlash.length - realTrailing.length;
921
967
  if (skipped > 0) console.log(`\nAllowlist: ${skipped} known exception${skipped === 1 ? "" : "s"} excluded from strict-mode counts (${allowlistPath}).`);
922
968
  let failed = false;
@@ -190,7 +190,10 @@ REPO_ROOT="$(git -C "$ROOT_DIR" worktree list | head -1 | awk '{print $1}')"
190
190
  PROJECT_PREFIX="$(git -C "$ROOT_DIR" rev-parse --show-prefix)"
191
191
  MAIN_PROJECT_DIR="$REPO_ROOT/${PROJECT_PREFIX}"
192
192
  MAIN_PROJECT_DIR="${MAIN_PROJECT_DIR%/}"
193
- REPO_DOCS_DIR="$REPO_ROOT/${PROJECT_PREFIX}src/content/docs"
193
+ # REPO_DOCS_DIR is set below, once the config parse resolves the real
194
+ # (possibly custom) docsDir -- no hardcoded fallback is defined here so a
195
+ # short-circuited parse fails loudly instead of silently reverting to
196
+ # src/content/docs (#4047).
194
197
 
195
198
  # Read the current locale map from the project's config. The generated config
196
199
  # puts `defaultLocale`, `docsDir`, and `locales` directly in `zudoDoc({...})`.
@@ -200,7 +203,16 @@ REPO_DOCS_DIR="$REPO_ROOT/${PROJECT_PREFIX}src/content/docs"
200
203
  # This deliberately does NOT walk `src/content/docs-*`: that naming convention
201
204
  # also matches version snapshots such as `docs-v1-ja`, which are not current
202
205
  # locale roots. The config map is the only source of truth.
203
- CONFIG_LOCALE_DATA="$(node - "$ROOT_DIR" <<'NODE'
206
+ #
207
+ # The node call is wrapped in a function rather than inlined into the
208
+ # assignment's `$( ... )`: bash 3.2 (stock macOS /bin/bash) does not honour a
209
+ # heredoc opened inside a command substitution -- it scans the heredoc body as
210
+ # shell text, and the JavaScript below contains an odd number of backticks
211
+ # (template literals), so the whole FILE fails to parse. Keeping the heredoc at
212
+ # statement level sidesteps that parser bug while preserving stdin execution,
213
+ # argument indexing, and node's exit status through the assignment.
214
+ read_config_locale_data() {
215
+ node - "$ROOT_DIR" <<'NODE'
204
216
  const fs = require("node:fs");
205
217
  const path = require("node:path");
206
218
 
@@ -445,7 +457,8 @@ if (localeObject) {
445
457
  }
446
458
  }
447
459
  NODE
448
- )"
460
+ }
461
+ CONFIG_LOCALE_DATA="$(read_config_locale_data)"
449
462
 
450
463
  DEFAULT_LOCALE="en"
451
464
  DOCS_DIR_REL="src/content/docs"
@@ -461,9 +474,13 @@ while IFS=$'\t' read -r record_type record_value record_extra; do
461
474
  [ -n "$record_value" ] || continue
462
475
  [ "$record_value" = "$DEFAULT_LOCALE" ] && continue
463
476
  duplicate="false"
464
- for existing_code in "${LOCALE_CODES[@]}"; do
465
- [ "$existing_code" = "$record_value" ] && duplicate="true"
466
- done
477
+ # bash 3.2 under `set -u` treats "${arr[@]}" on an EMPTY array as an
478
+ # unbound variable, so the length guard is required, not cosmetic.
479
+ if [ "${#LOCALE_CODES[@]}" -gt 0 ]; then
480
+ for existing_code in "${LOCALE_CODES[@]}"; do
481
+ [ "$existing_code" = "$record_value" ] && duplicate="true"
482
+ done
483
+ fi
467
484
  [ "$duplicate" = "true" ] && continue
468
485
  LOCALE_CODES+=("$record_value")
469
486
  LOCALE_DIR_RELS+=("$record_extra")
@@ -578,12 +595,16 @@ locale_link_name() {
578
595
  suffix=2
579
596
  while :; do
580
597
  collision="false"
581
- for existing in "${LOCALE_LINK_NAMES[@]}"; do
582
- if [ "$existing" = "$name" ]; then
583
- collision="true"
584
- break
585
- fi
586
- done
598
+ # Length guard: see the LOCALE_CODES loop above -- bash 3.2 under `set -u`
599
+ # errors on "${arr[@]}" when the array is empty.
600
+ if [ "${#LOCALE_LINK_NAMES[@]}" -gt 0 ]; then
601
+ for existing in "${LOCALE_LINK_NAMES[@]}"; do
602
+ if [ "$existing" = "$name" ]; then
603
+ collision="true"
604
+ break
605
+ fi
606
+ done
607
+ fi
587
608
  [ "$collision" = "false" ] && break
588
609
  name="docs-$code-$suffix"
589
610
  suffix=$((suffix + 1))
@@ -606,13 +627,17 @@ locale_starter_note() {
606
627
  esac
607
628
  }
608
629
 
609
- # Build exact locale-map guidance for the generated skill. These lines come
610
- # only from configured `locales` entries, never from directory discovery.
630
+ # Build exact locale-map guidance for the generated skill. Entries come from
631
+ # configured `locales`, but only when the directory exists on disk -- gated
632
+ # by the SAME existence check generate_skill uses below before symlinking,
633
+ # so the guidance can never point at a locale link that was never created
634
+ # (#4047).
611
635
  LOCALE_GUIDANCE="- \`$DEFAULT_LOCALE\` (default): \`${DOCS_DIR_REL%/}/\` — \`/docs/...\` ($(locale_starter_note "$DEFAULT_LOCALE"))"
612
636
  LOCALE_GUIDANCE+=$'\n'
613
637
  for locale_index in "${!LOCALE_CODES[@]}"; do
614
638
  locale_code="${LOCALE_CODES[$locale_index]}"
615
639
  locale_dir="${LOCALE_DIR_RELS[$locale_index]}"
640
+ [ -d "$ROOT_DIR/$locale_dir" ] || continue
616
641
  locale_link="${LOCALE_LINK_NAMES[$locale_index]}"
617
642
  locale_note="$(locale_starter_note "$locale_code")"
618
643
  LOCALE_GUIDANCE+="- \`$locale_code\`: \`${locale_dir%/}/\` — \`/$locale_code/docs/...\` (${locale_note}); lookup link: \`$locale_link/\`"
@@ -627,6 +652,10 @@ if [ "$DEFAULT_LOCALE" = "ja" ]; then
627
652
  else
628
653
  for locale_index in "${!LOCALE_CODES[@]}"; do
629
654
  if [ "${LOCALE_CODES[$locale_index]}" = "ja" ]; then
655
+ locale_dir="${LOCALE_DIR_RELS[$locale_index]}"
656
+ # Same existence check as the LOCALE_GUIDANCE loop above and
657
+ # generate_skill's symlink step below (#4047).
658
+ [ -d "$ROOT_DIR/$locale_dir" ] || break
630
659
  JA_DOCS_LINK="${LOCALE_LINK_NAMES[$locale_index]}"
631
660
  JA_DOCS_PATH="${LOCALE_DIR_RELS[$locale_index]}"
632
661
  break