@maccesar/aiskills 1.12.0 → 1.16.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.
Files changed (68) hide show
  1. package/README.md +89 -9
  2. package/lib/cleanup.js +29 -0
  3. package/lib/commands/skills.js +110 -9
  4. package/lib/config.js +18 -9
  5. package/lib/installer.js +5 -3
  6. package/lib/symlink.js +45 -3
  7. package/lib/utils.js +41 -0
  8. package/package.json +2 -2
  9. package/skills/audit-codebase/SKILL.md +70 -0
  10. package/skills/audit-codebase/agents/openai.yaml +4 -0
  11. package/skills/audit-codebase/references/comprehensive-audit.md +220 -0
  12. package/skills/audit-codebase/references/report-format.md +119 -0
  13. package/skills/humaniza/SKILL.md +55 -4
  14. package/skills/humaniza/references/ai-patterns-es.md +40 -0
  15. package/skills/humaniza/references/checklist.md +9 -0
  16. package/skills/humaniza/references/examples.md +16 -0
  17. package/skills/humaniza/references/lexicon-es-mx.md +18 -0
  18. package/skills/humaniza/references/structures-es.md +132 -0
  19. package/skills/humaniza/scripts/check_ai_patterns.py +216 -0
  20. package/skills/refactoring-ui/SKILL.md +65 -29
  21. package/skills/refactoring-ui/references/05-motion.md +124 -0
  22. package/skills/refactoring-ui/references/06-dark-mode.md +117 -0
  23. package/skills/refactoring-ui/references/07-component-patterns.md +181 -0
  24. package/skills/session-log/SKILL.md +524 -0
  25. package/skills/session-log/evals/README.md +79 -0
  26. package/skills/session-log/evals/ab-ronda-1.md +115 -0
  27. package/skills/session-log/evals/ab-ronda-2.md +174 -0
  28. package/skills/session-log/evals/defecto-experimento.md +12 -0
  29. package/skills/session-log/evals/evals.json +147 -0
  30. package/skills/session-log/evals/trigger-eval.json +23 -0
  31. package/skills/session-log/references/file-layout.md +336 -0
  32. package/skills/session-log/references/verification.md +173 -0
  33. package/skills/stitch-showcase/SKILL.md +24 -232
  34. package/skills/stitch-showcase/references/07-theme-system.md +12 -0
  35. package/skills/stitch-showcase/references/08-type-detection.md +9 -1
  36. package/skills/stitch-showcase/references/10-component-standardization.md +25 -0
  37. package/skills/stitch-showcase/references/12-video-embedding.md +113 -0
  38. package/skills/stitch-showcase/references/13-language-detection.md +82 -0
  39. package/skills/stitch-showcase/references/14-troubleshooting-known-issues.md +122 -0
  40. package/skills/stitch-showcase/references/15-build-flags.md +71 -0
  41. package/skills/stitch-showcase/references/16-design-md-format.md +107 -0
  42. package/skills/stitch-showcase/references/index.html +25 -19
  43. package/skills/stitch-showcase/references/viewer.html +24 -12
  44. package/skills/stitch-showcase/scripts/__pycache__/build_showcase.cpython-314.pyc +0 -0
  45. package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-314.pyc +0 -0
  46. package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-314.pyc +0 -0
  47. package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-314.pyc +0 -0
  48. package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-314.pyc +0 -0
  49. package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-314.pyc +0 -0
  50. package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-314.pyc +0 -0
  51. package/skills/stitch-showcase/scripts/__pycache__/slug_demangle.cpython-314.pyc +0 -0
  52. package/skills/stitch-showcase/scripts/build_showcase.py +150 -10
  53. package/skills/stitch-showcase/scripts/parse_design_md.py +145 -12
  54. package/skills/stitch-showcase/scripts/slug_demangle.py +209 -0
  55. package/skills/vscode-extension-dev/SKILL.md +90 -41
  56. package/skills/vscode-extension-dev/references/api-additional.md +168 -0
  57. package/skills/vscode-extension-dev/references/api-progress.md +55 -0
  58. package/skills/vscode-extension-dev/references/api-quickpick.md +75 -0
  59. package/skills/vscode-extension-dev/references/api-secretstorage.md +57 -0
  60. package/skills/vscode-extension-dev/references/api-statusbar.md +38 -0
  61. package/skills/vscode-extension-dev/references/api-treeview.md +78 -0
  62. package/skills/vscode-extension-dev/references/api-webview.md +149 -0
  63. package/skills/vscode-extension-dev/references/architecture.md +67 -0
  64. package/skills/vscode-extension-dev/references/debugger.md +179 -0
  65. package/skills/vscode-extension-dev/references/lsp.md +175 -0
  66. package/skills/vscode-extension-dev/references/notebooks.md +208 -0
  67. package/skills/vscode-extension-dev/references/testing.md +208 -0
  68. package/skills/vscode-extension-dev/references/api-patterns.md +0 -625
@@ -40,6 +40,7 @@ import parse_design_md
40
40
  import extract_text
41
41
  import detect_components
42
42
  import extract_catalog
43
+ from slug_demangle import slug_to_title
43
44
 
44
45
 
45
46
  def _extract_source_zip(zip_path: Path) -> Path:
@@ -150,6 +151,9 @@ def build(source_dir: str, project_type: str = None, project_name: str = None) -
150
151
  # Generate catalog.html (always included)
151
152
  _generate_catalog(output_dir, assets_dir, metadata)
152
153
 
154
+ # Sanity-check the generated output — warnings only, never aborts.
155
+ _validate_showcase(output_dir, screens)
156
+
153
157
  print(f"\n🎉 Showcase ready: {output_dir}/index.html\n")
154
158
  return output_dir
155
159
 
@@ -344,6 +348,11 @@ def _prepare_source(source_dir: str, project_type: str, project_name: str) -> tu
344
348
  # Defer type detection — will resolve after screen extraction via _detect_type_from_screens()
345
349
  metadata["_type_pending"] = True
346
350
 
351
+ # Language: showcase.json wins over DESIGN.md if both present, else fall through.
352
+ if config.get("lang"):
353
+ metadata["lang"] = str(config["lang"])
354
+ metadata.setdefault("lang", "en")
355
+
347
356
  ptype = metadata.get("type", "unknown")
348
357
  if ptype != "unknown":
349
358
  print(f"📱 Type: {ptype} | Project: {metadata['project_name']}")
@@ -518,16 +527,21 @@ def _enrich_screens(screens_raw: list, metadata: dict, source: Path) -> list:
518
527
  for raw in screens_raw:
519
528
  slug = raw["slug"]
520
529
  design = design_screens.get(slug, {})
530
+ html_path = raw.get("html_path", "")
521
531
 
522
- # Title
523
- title = design.get("title") or _slug_to_title(slug)
532
+ # Title resolution order: explicit DESIGN.md → <title> HTML → demangled slug
533
+ title = (
534
+ design.get("title")
535
+ or _read_html_title_tag(html_path)
536
+ or slug_to_title(slug)
537
+ )
524
538
 
525
539
  # Description priority: DESIGN.md → individual .md → meta/h1/title/visible body text → ""
526
540
  desc = design.get("description") or ""
527
541
  if not desc:
528
542
  desc = _read_md_desc(slug, source)
529
543
  if not desc:
530
- desc = _read_html_title(raw.get("html_path", ""))
544
+ desc = _read_html_title(html_path)
531
545
 
532
546
  enriched.append({
533
547
  "slug": slug,
@@ -540,6 +554,50 @@ def _enrich_screens(screens_raw: list, metadata: dict, source: Path) -> list:
540
554
  return enriched
541
555
 
542
556
 
557
+ def _read_html_title_tag(html_path: str) -> str:
558
+ """
559
+ Read the <title> tag of an HTML file and return a usable screen title.
560
+
561
+ Skips generic placeholders (Untitled, index, etc.). Strips a leading
562
+ "Brand - " or "Brand | " prefix when the title contains a separator,
563
+ so "Lumiere Dining - Rústico & Artesanal" becomes "Rústico & Artesanal".
564
+ Decodes basic HTML entities (&amp;, &nbsp;…).
565
+ """
566
+ if not html_path:
567
+ return ""
568
+ try:
569
+ text = Path(html_path).read_text(encoding="utf-8", errors="ignore")
570
+ except OSError:
571
+ return ""
572
+
573
+ m = re.search(r"<title[^>]*>(.+?)</title>", text, re.IGNORECASE | re.DOTALL)
574
+ if not m:
575
+ return ""
576
+
577
+ val = re.sub(r"<[^>]+>", "", m.group(1)).strip()
578
+ for entity, char in [
579
+ ("&amp;", "&"), ("&lt;", "<"), ("&gt;", ">"),
580
+ ("&nbsp;", " "), ("&#39;", "'"), ("&quot;", '"'),
581
+ ]:
582
+ val = val.replace(entity, char)
583
+ val = re.sub(r"\s+", " ", val).strip()
584
+
585
+ if not val:
586
+ return ""
587
+ if re.match(r"^(untitled|index|screen|page|document|\d+)$", val, re.IGNORECASE):
588
+ return ""
589
+
590
+ # Strip a "Brand - " / "Brand | " / "Brand: " prefix and return the last segment.
591
+ for sep in (" - ", " — ", " | ", ": "):
592
+ if sep in val:
593
+ tail = val.split(sep)[-1].strip()
594
+ if tail:
595
+ val = tail
596
+ break
597
+
598
+ return val
599
+
600
+
543
601
  def _read_md_desc(slug: str, source: Path) -> str:
544
602
  """Look for an individual .md file with the screen description."""
545
603
  for md_file in source.glob("*.md"):
@@ -718,6 +776,23 @@ def _generate_viewer(output_dir: Path, ptype: str, metadata: dict, screens: list
718
776
 
719
777
  template = template_path.read_text(encoding="utf-8")
720
778
 
779
+ # Reorder screens to follow DESIGN.md section order (mirrors _build_sections_html)
780
+ # so prev/next navigation matches the visual order in index.html.
781
+ sections = metadata.get("sections", [])
782
+ if sections:
783
+ slug_to_screen = {s["slug"]: s for s in screens}
784
+ ordered = []
785
+ seen = set()
786
+ for section in sections:
787
+ for slug in section.get("slugs", []):
788
+ if slug in slug_to_screen and slug not in seen:
789
+ ordered.append(slug_to_screen[slug])
790
+ seen.add(slug)
791
+ for s in screens:
792
+ if s["slug"] not in seen:
793
+ ordered.append(s)
794
+ screens = ordered
795
+
721
796
  # Build screens JSON for prev/next navigation
722
797
  screens_data = [
723
798
  {"title": s["title"], "desc": s["description"], "html_file": s["html_file"]}
@@ -738,6 +813,7 @@ def _generate_viewer(output_dir: Path, ptype: str, metadata: dict, screens: list
738
813
  font_family = font or "system-ui"
739
814
 
740
815
  html = template
816
+ html = html.replace("{{HTML_LANG}}", metadata.get("lang", "en"))
741
817
  html = html.replace("{{PROJECT_NAME}}", escape(metadata["project_name"]))
742
818
  html = html.replace("{{SCREENS_JSON}}", json.dumps(screens_data, ensure_ascii=False))
743
819
  html = html.replace("{{DEFAULT_THEME}}", metadata.get("default_theme", "light"))
@@ -796,12 +872,13 @@ def _generate_index(output_dir: Path, ptype: str, metadata: dict, screens: list)
796
872
  )
797
873
 
798
874
  # New template variables
799
- type_labels = {"mobile": "Mobile App", "web": "Web App"}
875
+ type_labels = {"mobile": "App Móvil", "web": "App Web"}
800
876
  type_label = type_labels.get(metadata.get("type", ""), "Showcase")
801
877
  project_desc = _project_description_html(metadata, filtered_screens)
802
878
  screens_intro = _screens_intro_text(metadata)
803
879
 
804
880
  html = template
881
+ html = html.replace("{{HTML_LANG}}", metadata.get("lang", "en"))
805
882
  html = html.replace("{{PROJECT_NAME}}", escape(metadata["project_name"]))
806
883
  html = html.replace("{{SCREEN_COUNT}}", str(len(filtered_screens)))
807
884
  html = html.replace("{{SCREENS_HTML}}", sections_html)
@@ -925,7 +1002,7 @@ def _design_system_html(ds_screen: dict | None, metadata: dict) -> str:
925
1002
  f'style="font-family:\'{escape(font)}\',sans-serif">Aa</span>'
926
1003
  f'<div>'
927
1004
  f'<span class="text-sm font-medium text-gray-700 dark:text-gray-200">{escape(font)}</span>'
928
- f'<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">Primary typeface</span>'
1005
+ f'<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">Tipografía principal</span>'
929
1006
  f'</div>'
930
1007
  f'</div>'
931
1008
  )
@@ -1292,10 +1369,73 @@ def _card_html(screen: dict, ptype: str) -> str:
1292
1369
  </a>"""
1293
1370
 
1294
1371
 
1295
- def _slug_to_title(slug: str) -> str:
1296
- """01_splash_screen → 'Splash Screen' (strips numeric prefix)."""
1297
- s = re.sub(r"^[\d_]+", "", slug)
1298
- return s.replace("_", " ").replace("-", " ").title().strip() or slug.title()
1372
+ # ─── Post-build validation ───────────────────────────────────────────────────
1373
+
1374
+ def _validate_showcase(output_dir: Path, screens: list) -> None:
1375
+ """
1376
+ Sanity-check the generated showcase. Prints warnings only — never aborts.
1377
+
1378
+ Checks:
1379
+ 1. JSON embedded in viewer.html (`var SCREENS = [...]`) parses.
1380
+ 2. Each screen has both `assets/<slug>.html` and `assets/<slug>.png`.
1381
+ 3. Local `src=` references (img / video / source) inside each screen
1382
+ resolve on disk (URLs starting with http/https/data: are skipped).
1383
+ """
1384
+ assets_dir = output_dir / "assets"
1385
+ issues: list[str] = []
1386
+
1387
+ # 1) Embedded SCREENS JSON in viewer.html
1388
+ viewer_path = output_dir / "viewer.html"
1389
+ if viewer_path.exists():
1390
+ viewer_text = viewer_path.read_text(encoding="utf-8")
1391
+ m = re.search(r"var\s+SCREENS\s*=\s*(\[.*?\]);", viewer_text, re.DOTALL)
1392
+ if not m:
1393
+ issues.append("viewer.html: no encontré `var SCREENS = [...]`")
1394
+ else:
1395
+ try:
1396
+ json.loads(m.group(1))
1397
+ except json.JSONDecodeError as e:
1398
+ issues.append(f"viewer.html: SCREENS JSON inválido — {e}")
1399
+
1400
+ # 2) Per-screen assets
1401
+ for s in screens:
1402
+ slug = s["slug"]
1403
+ if not (assets_dir / f"{slug}.html").exists():
1404
+ issues.append(f"{slug}: falta assets/{slug}.html")
1405
+ if not (assets_dir / f"{slug}.png").exists():
1406
+ issues.append(f"{slug}: falta assets/{slug}.png")
1407
+
1408
+ # 3) Local img / video / source references inside each screen HTML
1409
+ src_re = re.compile(
1410
+ r"""<(?:img|video|source)\s[^>]*src=["']([^"']+)["']""",
1411
+ re.IGNORECASE,
1412
+ )
1413
+ for s in screens:
1414
+ slug = s["slug"]
1415
+ html_path = assets_dir / f"{slug}.html"
1416
+ if not html_path.exists():
1417
+ continue
1418
+ try:
1419
+ text = html_path.read_text(encoding="utf-8", errors="ignore")
1420
+ except OSError:
1421
+ continue
1422
+ for src in src_re.findall(text):
1423
+ if src.startswith(("http://", "https://", "data:", "//")):
1424
+ continue
1425
+ ref = (html_path.parent / src).resolve()
1426
+ if not ref.exists():
1427
+ issues.append(f"{slug}: src inexistente '{src}'")
1428
+
1429
+ # 4) Report
1430
+ print()
1431
+ if not issues:
1432
+ print(f"✅ {len(screens)}/{len(screens)} screens válidos")
1433
+ else:
1434
+ print(f"⚠ {len(issues)} problema(s) detectado(s):")
1435
+ for issue in issues[:20]:
1436
+ print(f" - {issue}")
1437
+ if len(issues) > 20:
1438
+ print(f" ...({len(issues) - 20} más)")
1299
1439
 
1300
1440
 
1301
1441
  def _project_slug(name: str) -> str:
@@ -1422,7 +1562,7 @@ def _init(source_dir: str, project_type: str, project_name: str) -> None:
1422
1562
  sys.exit(1)
1423
1563
 
1424
1564
  # Auto-group slugs for skeleton
1425
- fake_screens = [{"slug": s, "title": _slug_to_title(s), "description": ""} for s in slugs]
1565
+ fake_screens = [{"slug": s, "title": slug_to_title(s), "description": ""} for s in slugs]
1426
1566
  groups = _auto_group_screens(fake_screens)
1427
1567
 
1428
1568
  pname = project_name or source.name.replace("_", " ").replace("-", " ").title()
@@ -3,13 +3,26 @@ parse_design_md.py — Extracts metadata from DESIGN.md for stitch-showcase.
3
3
 
4
4
  Usage:
5
5
  python parse_design_md.py /path/to/DESIGN.md
6
- → prints JSON with project_name, type, colors, color_tokens, default_theme, font_family, screens, sections
6
+ → prints JSON with project_name, type, lang, colors, color_tokens, default_theme, font_family, screens, sections
7
7
  """
8
8
  import re
9
9
  import sys
10
10
  import json
11
11
  from pathlib import Path
12
12
 
13
+ # Ensure sibling modules resolve when this file is invoked directly.
14
+ sys.path.insert(0, str(Path(__file__).parent))
15
+ from slug_demangle import slug_to_title
16
+
17
+
18
+ SPANISH_STOPWORDS = {
19
+ "de", "el", "la", "los", "las", "para", "con", "por", "una", "uno",
20
+ "del", "que", "es", "se", "su", "sus", "como", "más", "muy", "este",
21
+ "esta", "estos", "estas", "pantalla", "pantallas", "aplicación",
22
+ "aplicacion", "usuario", "usuarios", "diseño", "diseno", "móvil",
23
+ "movil", "perfil", "configuración", "configuracion",
24
+ }
25
+
13
26
 
14
27
  def parse(design_md_path: str) -> dict:
15
28
  path = Path(design_md_path)
@@ -17,6 +30,7 @@ def parse(design_md_path: str) -> dict:
17
30
  return {
18
31
  "project_name": "",
19
32
  "type": "unknown",
33
+ "lang": "en",
20
34
  "colors": {},
21
35
  "color_tokens": {},
22
36
  "default_theme": "light",
@@ -26,9 +40,17 @@ def parse(design_md_path: str) -> dict:
26
40
  }
27
41
 
28
42
  text = path.read_text(encoding="utf-8")
43
+ frontmatter = _parse_yaml_frontmatter(text)
44
+ lang = _extract_lang_override(text) or _detect_lang(text)
29
45
  colors = _extract_colors(text)
30
46
  color_tokens = _extract_color_tokens(text)
31
47
 
48
+ # Frontmatter fills in fields the Markdown body doesn't define.
49
+ if not colors and frontmatter.get("colors"):
50
+ colors = dict(frontmatter["colors"])
51
+ if not color_tokens and frontmatter.get("colors"):
52
+ color_tokens = _semantic_tokens_from_dict(frontmatter["colors"])
53
+
32
54
  # Determine default_theme from surface token
33
55
  surface_hex = color_tokens.get("surface") or color_tokens.get("background")
34
56
  if not surface_hex:
@@ -58,7 +80,7 @@ def parse(design_md_path: str) -> dict:
58
80
  # Apply titles/descriptions to screens already in the list
59
81
  for s in screens:
60
82
  slug = s["slug"]
61
- if not s.get("title") or s["title"] == _slug_to_title(slug):
83
+ if not s.get("title") or s["title"] == slug_to_title(slug):
62
84
  if slug in section_titles:
63
85
  s["title"] = section_titles[slug]
64
86
  if not s.get("description") and slug in section_descs:
@@ -69,29 +91,145 @@ def parse(design_md_path: str) -> dict:
69
91
  if slug not in existing_slugs:
70
92
  screens.append({
71
93
  "slug": slug,
72
- "title": section_titles.get(slug) or _slug_to_title(slug),
94
+ "title": section_titles.get(slug) or slug_to_title(slug),
73
95
  "description": section_descs.get(slug, ""),
74
96
  })
75
97
  existing_slugs.add(slug)
76
98
 
99
+ project_name = _extract_project_name(text) or frontmatter.get("name", "")
100
+ font_family = _extract_typography(text) or frontmatter.get("font_family")
101
+
77
102
  return {
78
- "project_name": _extract_project_name(text),
103
+ "project_name": project_name,
79
104
  "type": _detect_type(text),
105
+ "lang": lang,
80
106
  "colors": colors,
81
107
  "color_tokens": color_tokens,
82
108
  "default_theme": default_theme,
83
- "font_family": _extract_typography(text),
109
+ "font_family": font_family,
84
110
  "screens": screens,
85
111
  "sections": sections,
86
112
  }
87
113
 
88
114
 
115
+ def _parse_yaml_frontmatter(text: str) -> dict:
116
+ """
117
+ Parse a Stitch-style YAML frontmatter at the top of DESIGN.md.
118
+
119
+ Recognizes (everything else is ignored):
120
+ - top-level ``name:`` → ``"name"``
121
+ - ``colors:`` block with hex values → ``"colors"`` dict
122
+ - ``typography:`` block — returns the first ``fontFamily`` → ``"font_family"``
123
+
124
+ Returns an empty dict if no frontmatter delimiter is present.
125
+ """
126
+ m = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
127
+ if not m:
128
+ return {}
129
+ fm = m.group(1)
130
+ result: dict = {}
131
+
132
+ # name: <value>
133
+ name_m = re.search(r"^name:\s*(.+?)\s*$", fm, re.MULTILINE)
134
+ if name_m:
135
+ val = name_m.group(1).strip().strip("'\"")
136
+ if val:
137
+ result["name"] = val
138
+
139
+ # colors: block — collect indented "key: '#hex'" pairs until indentation drops.
140
+ colors_m = re.search(r"^colors:\s*\n((?:[ \t]+\S.*\n?)+)", fm, re.MULTILINE)
141
+ if colors_m:
142
+ colors: dict = {}
143
+ for line in colors_m.group(1).splitlines():
144
+ cm = re.match(
145
+ r"^\s+([a-z][a-z0-9\-]*):\s*['\"]?(#[0-9A-Fa-f]{3,8})['\"]?\s*$",
146
+ line,
147
+ )
148
+ if cm:
149
+ colors[cm.group(1).lower()] = "#" + cm.group(2).lstrip("#").upper()
150
+ if colors:
151
+ result["colors"] = colors
152
+
153
+ # typography: pick the first fontFamily declared anywhere in the block.
154
+ typo_m = re.search(r"fontFamily:\s*['\"]?([^'\"\n,]+)['\"]?", fm)
155
+ if typo_m:
156
+ font = typo_m.group(1).strip().rstrip(",;")
157
+ if font:
158
+ result["font_family"] = font
159
+
160
+ return result
161
+
162
+
163
+ def _semantic_tokens_from_dict(colors: dict) -> dict:
164
+ """
165
+ Compute `accent` / `surface` semantic tokens from a flat color dict.
166
+
167
+ Mirrors the rules used by :func:`_extract_color_tokens`:
168
+ - ``accent`` → first key containing ``primary`` (not ``on-primary``)
169
+ - ``surface`` → first key named ``surface`` or containing ``background``/``bg``
170
+ """
171
+ tokens = dict(colors)
172
+ for name, val in colors.items():
173
+ if "primary" in name and not name.startswith("on-"):
174
+ tokens["accent"] = val
175
+ break
176
+ for name, val in colors.items():
177
+ if name == "surface" or "background" in name or name == "bg":
178
+ tokens["surface"] = val
179
+ break
180
+ return tokens
181
+
182
+
89
183
  def _extract_project_name(text: str) -> str:
90
184
  """First H1 heading in the document."""
91
185
  m = re.search(r"^#\s+(.+)$", text, re.MULTILINE)
92
186
  return m.group(1).strip() if m else ""
93
187
 
94
188
 
189
+ def _extract_lang_override(text: str) -> str | None:
190
+ """
191
+ Explicit override via ``## Lang`` (or ``## Language``) section in DESIGN.md.
192
+
193
+ Expected format:
194
+ ## Lang
195
+ es
196
+
197
+ Returns a BCP-47-ish code (``es``, ``en``, ``pt-BR``…) or None if absent.
198
+ """
199
+ m = re.search(
200
+ r"^##\s+Lang(?:uage)?\s*\n\s*([A-Za-z]{2}(?:-[A-Za-z]{2,4})?)\s*$",
201
+ text, re.IGNORECASE | re.MULTILINE
202
+ )
203
+ if not m:
204
+ return None
205
+ raw = m.group(1)
206
+ # Normalize: language subtag lowercase, region subtag uppercase (BCP-47).
207
+ if "-" in raw:
208
+ lang, region = raw.split("-", 1)
209
+ return f"{lang.lower()}-{region.upper()}"
210
+ return raw.lower()
211
+
212
+
213
+ def _detect_lang(text: str) -> str:
214
+ """
215
+ Heuristic language detection. Distinguishes Spanish from everything else.
216
+
217
+ Signals:
218
+ - Spanish-only accented characters (á é í ó ú ñ ü).
219
+ - Common Spanish stop words from SPANISH_STOPWORDS.
220
+
221
+ Returns 'es' when either signal is present, else 'en'.
222
+ """
223
+ if not text:
224
+ return "en"
225
+ lower = text.lower()
226
+ if re.search(r"[áéíóúñü]", lower):
227
+ return "es"
228
+ words = re.findall(r"[a-záéíóúñü]+", lower)
229
+ stop_hits = sum(1 for w in words if w in SPANISH_STOPWORDS)
230
+ return "es" if stop_hits >= 3 else "en"
231
+
232
+
95
233
  def _detect_type(text: str) -> str:
96
234
  """Returns 'mobile', 'web', or 'unknown'.
97
235
 
@@ -292,7 +430,7 @@ def _parse_table(text: str) -> list:
292
430
  cols = [c.strip() for c in line.split("|") if c.strip()]
293
431
  if len(cols) >= 2:
294
432
  slug = _to_slug(cols[0])
295
- title = cols[1] if len(cols) > 1 else _slug_to_title(slug)
433
+ title = cols[1] if len(cols) > 1 else slug_to_title(slug)
296
434
  desc = cols[2] if len(cols) > 2 else ""
297
435
  rows.append({"slug": slug, "title": title, "description": desc})
298
436
 
@@ -324,7 +462,7 @@ def _parse_screen_list(text: str) -> list:
324
462
  title = title_part.strip()
325
463
  desc = desc_part.strip()
326
464
  else:
327
- title = _slug_to_title(slug)
465
+ title = slug_to_title(slug)
328
466
  desc = raw
329
467
  screens.append({"slug": slug, "title": title, "description": desc})
330
468
 
@@ -384,11 +522,6 @@ def _to_slug(s: str) -> str:
384
522
  return s
385
523
 
386
524
 
387
- def _slug_to_title(slug: str) -> str:
388
- """splash_screen → 'Splash Screen'"""
389
- return slug.replace("_", " ").replace("-", " ").title()
390
-
391
-
392
525
  if __name__ == "__main__":
393
526
  if len(sys.argv) < 2:
394
527
  print("Usage: python parse_design_md.py /path/to/DESIGN.md", file=sys.stderr)