Skip to content
Merged
45 changes: 34 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,12 @@ swa start _site
2. **Generates language manifest** - Creates `metadata/languages.json` for runtime language switching
3. **Syncs content** - Copies English source to `localizedContent/en/`. For other languages, only shared directories (assets, api) are synced by default since Crowdin manages translations. Use `--sync` to enable full English fallback for missing/outdated translations (useful for local development).
4. **Normalizes DocFX alerts** - Runs `normalize-localized-alerts.py` on each non-English language to repair Crowdin-collapsed Note/Tip/etc. alerts before building (see [DocFX Alerts and Translations](#docfx-alerts-and-translations))
5. **Builds documentation** - Runs DocFX for each requested language
6. **Fixes API docs** - Patches xref links in generated API documentation
7. **Copies API docs** - Shares English API docs with localized sites
8. **Injects SEO tags** - Adds hreflang and canonical tags to HTML files
9. **Generates SWA config** - Creates `staticwebapp.config.json` for Azure Static Web Apps routing
5. **Stabilizes heading anchors** - Runs `normalize-localized-heading-anchors.py` on each non-English language to inject English-slug bookmark anchors before translated headings, so `#anchor` cross-references resolve even when the heading text is translated (see [Bookmark Links and Translations](#bookmark-links-and-translations))
6. **Builds documentation** - Runs DocFX for each requested language
7. **Fixes API docs** - Patches xref links in generated API documentation
8. **Copies API docs** - Shares English API docs with localized sites
9. **Injects SEO tags** - Adds hreflang and canonical tags to HTML files
10. **Generates SWA config** - Creates `staticwebapp.config.json` for Azure Static Web Apps routing

# Project Structure

Expand All @@ -75,7 +76,8 @@ TEDoc/
│ ├── gen_staticwebapp_config.py
│ ├── inject_seo_tags.py
│ ├── sync-localized-content.py
│ └── normalize-localized-alerts.py # Repairs Crowdin-collapsed DocFX alerts
│ ├── normalize-localized-alerts.py # Repairs Crowdin-collapsed DocFX alerts
│ └── normalize-localized-heading-anchors.py # Injects English-slug bookmark anchors into translations
├── content/ # English source content (tracked in git)
│ └── _ui-strings.json # English UI strings (header, footer, banners)
├── localizedContent/ # Build directories for all languages
Expand Down Expand Up @@ -108,16 +110,37 @@ TEDoc/

# Bookmark Links and Translations

When linking to a specific heading within a page (e.g., `#my-heading`), the anchor ID is auto-generated from the heading text. When headings are translated by Crowdin, the anchor changes, breaking bookmark links.
When linking to a specific heading within a page (e.g., `#my-heading`), DocFX auto-generates the anchor ID from the heading **text**. Because Crowdin translates that text, the generated anchor changes per language (`#model-io` becomes `#es-del-modelo`, etc.), so a hardcoded English `#anchor` link breaks in every translated page and DocFX logs an `InvalidBookmark` warning. English builds stay clean because the anchors match there.

To prevent this, add an `<a name="..."></a>` tag above any heading that is referenced by a bookmark link:
## Automatic anchor stabilization (the build handles this)

`build_scripts/normalize-localized-heading-anchors.py` neutralizes this whole class of warning automatically. For each localized page it reads the matching English source, computes each heading's English slug, and injects a hidden bookmark anchor carrying that slug immediately before the corresponding translated heading:

```markdown
<a name="my-heading"></a>
## My Heading
<a id="model-io" data-loc-xref></a>
## E/S del modelo
```

DocFX accepts the injected `id` as a valid bookmark, so `#model-io` resolves and the link lands on the right section while the heading keeps its translated text. Headings are aligned to the English source positionally (Crowdin preserves heading structure); if the heading counts differ, the file is skipped and reported rather than risk a misaligned anchor. The script is idempotent (it strips its own `data-loc-xref` anchors before recomputing) and never modifies English.

The build runs it automatically for each non-English language before DocFX (step 5 of [What the Build Script Does](#what-the-build-script-does)). You can also run it manually after a Crowdin pull:

```bash
python build_scripts/normalize-localized-heading-anchors.py # all languages
python build_scripts/normalize-localized-heading-anchors.py --dry-run # preview without writing
python build_scripts/normalize-localized-heading-anchors.py --check # exit 1 if changes are needed (CI)
python build_scripts/normalize-localized-heading-anchors.py es # a single language
```

Crowdin does not translate HTML `name` attributes, so the anchor remains stable across all languages. Only add these to headings that are actually linked to — there is no need to add them to every heading.
## Authoring guidance

- **Prefer the bracketed link form** `[text](xref:uid#anchor)` over the bare `@uid#anchor` autolink. The closing `)` delimits the anchor, so trailing punctuation in any language can never leak into it.
- **For a rename-proof anchor**, add an explicit `<a name="..."></a>` tag above the heading. Crowdin does not translate HTML `name` attributes, so the anchor stays stable across all languages *and* survives English heading renames — unlike an auto-generated slug. Only add these to headings actually linked to; there is no need to add them everywhere.

```markdown
<a name="my-heading"></a>
## My Heading
```

# DocFX Alerts and Translations

Expand Down
12 changes: 11 additions & 1 deletion build-docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,20 @@ def prepare_localized_content(lang: str, sync: bool = False) -> int:

# Repair Crowdin-collapsed DocFX alerts (e.g. "> [!NOTE]> text") before docfx
# builds this language, so alerts render as styled boxes instead of plain quotes.
return run_command(
result = run_command(
[sys.executable, "build_scripts/normalize-localized-alerts.py", lang],
f"Normalizing DocFX alerts for {lang}"
)
if result != 0:
return result

# Stabilize heading anchors: inject English-slug bookmark anchors before
# translated headings so cross-reference links (#anchor) resolve even though
# the heading text is translated. Prevents InvalidBookmark warnings.
return run_command(
[sys.executable, "build_scripts/normalize-localized-heading-anchors.py", lang],
f"Stabilizing heading anchors for {lang}"
)


def build_language(lang: str, sync: bool = False) -> int:
Expand Down
263 changes: 263 additions & 0 deletions build_scripts/normalize-localized-heading-anchors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Stabilize DocFX heading anchors across translated content.

DocFX derives a heading's anchor (its HTML id) from the heading *text*. Cross
references hardcode the English anchor, e.g. `[..](xref:te-cli-commands#model-io)`
or `<a href="...#object-paths">`. When a heading is translated the generated
slug changes ("Model I/O" -> "model-io" becomes "E/S del modelo" ->
"es-del-modelo"), so every English `#anchor` link breaks in es/zh and DocFX emits
an `InvalidBookmark` warning. English builds stay clean because the anchors match
there.

This script makes the *English* anchor resolvable in every translated page. For
each localized page it reads the matching English source file, computes each
heading's English DocFX slug, and injects a hidden bookmark anchor carrying that
slug immediately before the corresponding translated heading:

<a id="model-io" data-loc-xref></a>
## E/S del modelo

DocFX accepts the injected `id` as a valid bookmark, so `#model-io` resolves and
the link lands on the right section, while the heading keeps its translated text.
This neutralizes the whole class of warning for current and future pages without
touching translations.

Headings are aligned to the English source positionally (Crowdin preserves
heading structure). If the heading count differs (a translation added/removed a
heading, or is stale), the file is skipped and reported rather than risk a
misaligned anchor. Frontmatter and fenced code blocks are skipped. Injected
anchors are tagged `data-loc-xref` so the script is idempotent: it strips its own
prior anchors before recomputing.

English (`en`) is never modified - it is the source of the slugs.

Usage:
python normalize-localized-heading-anchors.py # all languages (except en)
python normalize-localized-heading-anchors.py --dry-run # preview only
python normalize-localized-heading-anchors.py --check # exit 1 if changes needed (CI)
python normalize-localized-heading-anchors.py es # only Spanish
"""

import argparse
import re
import sys
from pathlib import Path

TEDOC_ROOT = Path(__file__).resolve().parent.parent
LOCALIZED_DIR = TEDOC_ROOT / "localizedContent"

FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})(.*)$")
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
# A bookmark anchor this script previously injected.
INJECTED_RE = re.compile(r'^<a id="[^"]*" data-loc-xref></a>$')
# Inline markdown to strip from heading text before slugifying.
MD_LINK_RE = re.compile(r"\[([^\]]*)\]\([^)]*\)")


def slugify(text: str) -> str:
"""Replicate DocFX/markdig heading-id generation.

Verified against DocFX 2.78 output: lowercase; drop every character that is
not a Unicode letter/digit/hyphen/underscore; turn each space into a single
hyphen (consecutive spaces are NOT collapsed, e.g. "Connection & Auth" ->
"connection--auth"); Unicode letters (incl. CJK) are preserved verbatim.
"""
# Strip inline markdown so the text matches what DocFX renders.
text = MD_LINK_RE.sub(r"\1", text)
text = text.replace("`", "")
text = re.sub(r"(\*\*|__|\*|~~)", "", text)
text = text.strip().lower()
out = []
for ch in text:
if ch == " " or ch == "\t":
out.append("-")
elif ch.isalnum() or ch in "-_":
out.append(ch)
# everything else (punctuation, symbols) is dropped
return "".join(out)


def parse_headings(text: str):
"""Return (heading_texts, heading_line_indices, heading_levels) for ATX
headings, skipping YAML frontmatter and fenced code blocks. Indices refer to
`text.split(nl)`; levels are the heading depth (1 for '#', 2 for '##', ...)."""
lines = text.split("\n")
texts, indices, levels = [], [], []
fence_char, fence_len = "", 0
in_frontmatter = False
for i, raw in enumerate(lines):
line = raw.rstrip("\r")
# YAML frontmatter delimited by leading '---' on the very first line.
if i == 0 and line.strip() == "---":
in_frontmatter = True
continue
if in_frontmatter:
if line.strip() in ("---", "..."):
in_frontmatter = False
continue
fence = FENCE_RE.match(line)
if fence:
run, tail = fence.group(1), fence.group(2)
char, length = run[0], len(run)
if not fence_char:
fence_char, fence_len = char, length
elif char == fence_char and length >= fence_len and not tail.strip():
fence_char, fence_len = "", 0
continue
if fence_char:
continue
m = HEADING_RE.match(line)
if m:
texts.append(m.group(2))
indices.append(i)
levels.append(len(m.group(1)))
return texts, indices, levels


def dedup_slugs(texts):
"""Compute slugs with DocFX-style collision suffixes (-1, -2, ...)."""
seen = {}
slugs = []
for t in texts:
s = slugify(t)
if s in seen:
seen[s] += 1
s = f"{s}-{seen[s]}"
else:
seen[s] = 0
slugs.append(s)
return slugs


def english_source_for(loc_path: Path, lang: str) -> Path:
"""Map localizedContent/<lang>/content/.../x.md -> content/.../x.md."""
rel = loc_path.relative_to(LOCALIZED_DIR / lang) # e.g. content/features/x.md
return TEDOC_ROOT / rel


def process_file(loc_path: Path, lang: str, dry_run: bool):
"""Inject English-slug anchors before translated headings. Returns one of:
('ok', n_injected) | ('skip-no-source', 0) | ('skip-mismatch', (en, loc))."""
with open(loc_path, "r", encoding="utf-8", newline="") as f:
text = f.read()
newline = "\r\n" if "\r\n" in text else "\n"
norm = text.replace("\r\n", "\n")
had_bom = norm.startswith("\ufeff")
if had_bom:
norm = norm[1:]

# Idempotency: drop previously injected anchors before recomputing.
cleaned_lines = [ln for ln in norm.split("\n") if not INJECTED_RE.match(ln)]

en_path = english_source_for(loc_path, lang)
if not en_path.exists():
return ("skip-no-source", 0)

with open(en_path, "r", encoding="utf-8-sig", newline="") as f:
en_text = f.read().replace("\r\n", "\n")
en_texts, _, _ = parse_headings(en_text)
loc_texts, loc_indices, loc_levels = parse_headings("\n".join(cleaned_lines))

if len(en_texts) != len(loc_texts):
# Still write back the cleaned file (stale anchors removed) so we never
# leave a misaligned anchor behind, but inject nothing.
rebuilt = newline.join(cleaned_lines)
if had_bom:
rebuilt = "\ufeff" + rebuilt
if rebuilt != text and not dry_run:
Comment thread
Copilot marked this conversation as resolved.
with open(loc_path, "w", encoding="utf-8", newline="") as f:
f.write(rebuilt)
return ("skip-mismatch", (len(en_texts), len(loc_texts)))

en_slugs = dedup_slugs(en_texts)
# Skip level-1 headings: DocFX lifts the first <h1> into the page title
# (rawTitle) only when the <h1> is the leading element. An injected anchor
# renders as a <p> ahead of the <h1>, so DocFX no longer treats it as the
# title and the heading drops below the metadata block. The page title's own
# anchor is never cross-referenced (callers use the uid), so dropping it is
# safe. Positional slug alignment is unaffected; we only omit the injection.
inject_at = {
idx: slug
for idx, slug, level in zip(loc_indices, en_slugs, loc_levels)
if level != 1
}

out = []
for i, ln in enumerate(cleaned_lines):
if i in inject_at:
out.append(f'<a id="{inject_at[i]}" data-loc-xref></a>')
out.append(ln)
rebuilt = newline.join(out)
if had_bom:
rebuilt = "\ufeff" + rebuilt

changed = rebuilt != text
if changed and not dry_run:
with open(loc_path, "w", encoding="utf-8", newline="") as f:
f.write(rebuilt)
return ("ok", len(inject_at) if changed else 0)


def iter_languages(lang):
if lang:
return [lang] if lang != "en" else []
if not LOCALIZED_DIR.exists():
return []
return sorted(
d.name for d in LOCALIZED_DIR.iterdir()
if d.is_dir() and d.name != "en"
)


def main() -> int:
ap = argparse.ArgumentParser(
description="Inject English heading-slug bookmark anchors into translated pages."
)
ap.add_argument("lang", nargs="?", help="Language code (default: all except en)")
ap.add_argument("--dry-run", action="store_true", help="Preview without writing")
ap.add_argument("--check", action="store_true",
help="Exit 1 if any file needs changes (implies --dry-run). For CI.")
args = ap.parse_args()
dry_run = args.dry_run or args.check

if not LOCALIZED_DIR.exists():
print(f"Error: {LOCALIZED_DIR} not found.")
return 1

total_files = total_anchors = changed_files = mismatches = 0
for lang in iter_languages(args.lang):
base = LOCALIZED_DIR / lang
if not base.exists():
continue
for path in sorted(base.rglob("*.md")):
if path.name == "toc.md":
continue # navigation files use '## @uid' lines, not real headings
status, info = process_file(path, lang, dry_run)
total_files += 1
rel = path.relative_to(LOCALIZED_DIR)
if status == "skip-mismatch":
mismatches += 1
en_n, loc_n = info
print(f" SKIP (heading count {en_n} en vs {loc_n} loc): {rel}")
elif status == "ok" and info:
changed_files += 1
total_anchors += info

verb = "Would inject" if dry_run else "Injected"
print(f"\n{verb} {total_anchors} heading anchor(s) across {changed_files} file(s); "
f"{mismatches} file(s) skipped on heading-count mismatch; "
f"{total_files} file(s) scanned.")

if args.check and (changed_files or mismatches):
return 1
return 0


if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.")
sys.exit(1)
2 changes: 2 additions & 0 deletions content/features/Semantic-Model/direct-query-over-as.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ applies_to:
full: true
---

# Direct Query over Analysis Services

## Overview

Tabular Editor 3 can **connect** to composite models that leverage **DirectQuery over Analysis Services (DQ‑over‑AS)**, but full modeling support is **not yet available**. Most authoring tasks work as expected; however, operations that rely on synchronising metadata with the remote semantic model—such as *Update table schema*—are currently limited.
Expand Down
2 changes: 1 addition & 1 deletion content/features/Workspace-Database.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ applies_to:
- edition: Enterprise
full: true
---
## Introducing Workspace Databases
# Introducing Workspace Databases
Tabular Editor 3.0 supports editing model metadata loaded from disk with a simultaneous connection to a database deployed to an instance of Analysis Services. We call this database the _workspace database_. Going forward, this is the recommended approach to tabular modeling within Tabular Editor.

This makes the development workflow a lot simpler, since you only need to hit Save (Ctrl+S) once, to simultaneously save your changes to the disk **and** update the metadata in the workspace database. This also has the advantage, that any error messages returned from Analysis Services, are immediately visible in Tabular Editor upon hitting Save. In a sense, this is similar to the way SSDT / Visual Studio or Power BI Desktop does, except that you are in control of when the workspace database is updated.
Expand Down
1 change: 0 additions & 1 deletion content/features/code-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ In the screenshot below, for example, the **Prefix variable with '_'** action ca

![Code Action All Occurrences](~/content/assets/images/features/code-action-all-occurrences.png)

<a name="list-of-code-actions"></a>
## List of Code Actions

The table below lists all currently available Code Actions. You can toggle off Code Actions in the **Tools > Preferences** dialog under **Text Editors > DAX Editor > Code Actions** (a future update will let you toggle individual actions for a more customized experience). Some Code Actions also have additional configuration options, such as which prefix to use for variable names.
Expand Down
Loading
Loading