diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e4edfd72..c3c42bc4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,40 +10,74 @@ on: - main jobs: - build_and_deploy_job: + build_job: if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') - runs-on: ubuntu-latest - name: Build and Deploy Job + runs-on: windows-latest + name: Build Documentation steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: submodules: true - - name: Generate redirects - run: | - chmod +x gen_redirects.py - ./gen_redirects.py - - name: Build Documentation - uses: nunit/docfx-action@v4.1.0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Set up .NET + uses: actions/setup-dotnet@v4 with: - args: docfx.json --warningsAsErrors - - name: Replace Shared Xref in API HTML + dotnet-version: '8.0.x' + - name: Cache DocFX + uses: actions/cache@v4 + with: + path: ~/.dotnet/tools + key: docfx-${{ runner.os }} + - name: Install DocFX + run: dotnet tool install -g docfx --ignore-failed-sources + - name: Generate configs + run: | + python build_scripts/gen_redirects.py + python build_scripts/gen_languages.py + - name: Generate API metadata + run: docfx metadata localizedContent/en/docfx.json + - name: Verify API files generated run: | - sudo chmod -R u+w _site/api - sudo find _site/api -type f -name "*.html" -exec sed -i 's|Shared|Shared|g' {} + - shell: bash - - name: Build And Deploy + if (!(Test-Path "content/api/toc.yml")) { + Write-Error "API metadata generation failed - toc.yml not found" + exit 1 + } + Write-Host "API files generated: $((Get-ChildItem content/api/*.yml).Count) YAML files" + shell: pwsh + - name: Build all documentation + run: python build-docs.py --all --skip-gen + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: site + path: _site + retention-days: 7 + + deploy_job: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + needs: build_job + runs-on: ubuntu-latest + name: Deploy to Azure + steps: + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: site + path: _site + - name: Deploy to Azure Static Web Apps id: builddeploy uses: Azure/static-web-apps-deploy@v1 with: azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_DELIGHTFUL_MUD_081AFFE03 }} - repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) + repo_token: ${{ secrets.GITHUB_TOKEN }} action: "upload" - ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### - # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig - app_location: "/_site" # App source code path - api_location: "" # Api source code path - optional - output_location: "/_site" # Built app content directory - optional - ###### End of Repository/Build Configurations ###### + app_location: "_site" + api_location: "" + output_location: "" + skip_app_build: true close_pull_request_job: if: github.event_name == 'pull_request' && github.event.action == 'closed' diff --git a/.gitignore b/.gitignore index 4acf97de..da3d4147 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -/docfx.json +/metadata/languages.json + ############### # folder # @@ -17,8 +18,13 @@ .NuGet/ artifacts/ target/ -/_site/* -!/_site/staticwebapp.config.json +/_site/ + +# Localized content +/localizedContent/en/ +/localizedContent/*/docfx.json +!/localizedContent/*/.translation-status.json + .vscode/ .vs/ diff --git a/README.md b/README.md index 28e43402..f45849c0 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,140 @@ # TabularEditorDocs + This is the GitHub repository for the Tabular Editor documentation site, https://docs.tabulareditor.com. The repository contains documentation articles for both the open-source Tabular Editor 2.x as well as the commercial Tabular Editor 3, including articles for common features and C# scripting documentation. # Technical details -The site uses [DocFX](https://dotnet.github.io/docfx/) and GitHub flavoured markdown for all articles. + +The site uses [DocFX](https://dotnet.github.io/docfx/) and GitHub flavoured markdown for all articles. Multi-language support is provided through the `localizedContent/` directory. # How to contribute + All contributions are welcome. We will review all pull requests submitted. To test your changes locally: -- Make sure [DocFX](https://dotnet.github.io/docfx/) is installed. -- Run `gen_redirects.py` in the root of the project. -- Run `docfx --serve` in the root of the project. +- Make sure [DocFX](https://dotnet.github.io/docfx/) and Python 3.11+ are installed. +- Run `python build-docs.py --serve` in the root of the project. + +# Build Script Usage + +The `build-docs.py` script handles all documentation building tasks including multi-language support. + +## Quick Start + +```bash +# Build and serve locally (English only, for development) +python build-docs.py --serve + +# Or build all languages and serve with Azure Static Web Apps CLI +python build-docs.py --all +swa start _site +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `python build-docs.py` | Build all languages (default) | +| `python build-docs.py --all` | Build all languages | +| `python build-docs.py --lang en` | Build English only | +| `python build-docs.py --lang es zh` | Build specific languages | +| `python build-docs.py --list` | List available languages | +| `python build-docs.py --serve` | Build English and serve locally | + +## Options + +| Option | Description | +|--------|-------------| +| `--all` | Build all available languages | +| `--lang LANGS` | Build specific language(s), space-separated | +| `--list` | List available languages and exit | +| `--serve` | Build and serve locally (English only, for development) | +| `--skip-gen` | Skip running gen_redirects.py (use existing configs) | +| `--no-api-copy` | Skip copying API docs to localized sites | + +## What the Build Script Does + +1. **Generates DocFX configurations** - Runs `gen_redirects.py` to create `docfx.json` for each language +2. **Generates language manifest** - Creates `metadata/languages.json` for runtime language switching +3. **Syncs content** - Copies English source content; uses English as fallback for missing translations. Readds the english file if the file is modified in content or deleted in translation. +4. **Builds documentation** - Runs DocFX for each requested language +5. **Fixes API docs** - Patches xref links in generated API documentation +6. **Copies API docs** - Shares English API docs with localized sites +7. **Injects SEO tags** - Adds hreflang and canonical tags to HTML files +8. **Generates SWA config** - Creates `staticwebapp.config.json` for Azure Static Web Apps routing + +# Project Structure + +``` +TEDoc/ +├── build-docs.py # Main build script +├── build_scripts/ # Helper scripts +│ ├── gen_redirects.py # Generates docfx.json configs +│ ├── gen_languages.py # Generates language manifest +│ ├── gen_staticwebapp_config.py +│ ├── inject_seo_tags.py +│ └── sync-localized-content.py +├── content/ # English source content (tracked in git) +│ └── _ui-strings.json # English UI strings (header, footer, banners) +├── localizedContent/ # Build directories for all languages +│ ├── en/ # English build (generated, gitignored) +│ └── {lang}/ # Translated content +│ ├── content/ # Translated markdown and UI strings (tracked) +│ │ └── _ui-strings.json # Translated UI strings for this language +│ └── docfx.json # Generated config (gitignored) +├── metadata/ +│ ├── languages.json # Language manifest (generated) +│ ├── language-metadata.json # Language display names and RTL flags +│ └── redirects.json # URL redirects (server 301s and client meta-refresh) +├── docfx-template.json # Base DocFX configuration template +├── templates/ # DocFX templates +└── _site/ # Generated output + ├── en/ + ├── es/ + └── ... +``` + +# Adding a New Language + +1. Create `localizedContent/{lang}/content/` folder (e.g., `fr/content/`) +2. Add the language entry to `metadata/language-metadata.json` with name and nativeName +3. Add translated `.md` files to the content subdirectory +4. Add a translated `_ui-strings.json` to the content subdirectory (see [Translating UI Strings](#translating-ui-strings) below). If no translation is provided, an automatic fallback will be generated. +5. Run `python build-docs.py --all` to generate configs and build. Language will be added dynamically to language picker. + +> **Note:** English content from `content/` is automatically copied to `localizedContent/en/content/` during build. For other languages, English content is used as fallback for missing translations. This includes `_ui-strings.json` — if no translated version exists, English UI strings are used. + +# Translating UI Strings + +The `_ui-strings.json` file controls the text of site-wide UI elements that are not part of the documentation content itself: the header navigation, header buttons, footer text, and the AI translation warning banner. These strings are applied at runtime by the JavaScript bundle for non-English pages. + +The English source is at `content/_ui-strings.json`. To provide translations for a language, create `localizedContent/{lang}/content/_ui-strings.json` with the same keys and translated values. + +If a key is missing from a language's file, or no `_ui-strings.json` exists at all, the English value is used as fallback. + +## Available Keys + +| Key | English value | Element | +|-----|--------------|---------| +| `aiTranslationWarning` | `This content has been translated by AI...` | Warning banner shown on translated pages | +| `header.nav.pricing` | `Pricing` | Header nav link | +| `header.nav.download` | `Download` | Header nav link | +| `header.nav.learn` | `Learn` | Header nav link | +| `header.nav.resources` | `Resources` | Header nav dropdown toggle | +| `header.nav.blog` | `Blog` | Resources dropdown item | +| `header.nav.newsletter` | `Newsletter` | Resources dropdown item | +| `header.nav.publications` | `Publications` | Resources dropdown item | +| `header.nav.documentation` | `Documentation` | Resources dropdown item | +| `header.nav.supportCommunity` | `Support community` | Resources dropdown item | +| `header.nav.contactUs` | `Contact Us` | Header nav link | +| `header.button1` | `Free trial` | Primary header CTA button | +| `header.button2` | `Main page` | Secondary header button | +| `footer.heading` | `Ready to get started?` | Footer section heading | +| `footer.button1` | `Try Tabular Editor 3 for free` | Footer CTA button | +| `footer.button2` | `Buy Tabular Editor 3` | Footer CTA button | +| `footer.aboutUs` | `About us` | Footer left link | +| `footer.contactUs` | `Contact us` | Footer left link | +| `footer.technicalSupport` | `Technical Support` | Footer left link | +| `footer.privacyPolicy` | `Privacy & Cookie policy` | Footer bottom link | +| `footer.termsConditions` | `Terms & Conditions` | Footer bottom link | +| `footer.licenseTerms` | `License terms` | Footer bottom link | + diff --git a/_site/staticwebapp.config.json b/_site/staticwebapp.config.json index b296322c..5d15b248 100644 --- a/_site/staticwebapp.config.json +++ b/_site/staticwebapp.config.json @@ -1,148 +1 @@ -{ - "routes": [ - { - "route": "/references/release-notes", - "redirect": "/references/release-notes/3_25_5.html", - "statusCode": 302 - }, { - "route": "/te3/other/release-notes", - "redirect": "/references/release-notes/3_25_5.html", - "statusCode": 302 - }, - { - "route": "/privacy-policy.html", - "redirect": "/security/privacy-policy.html", - "statusCode": 301 - }, - { - "route": "/Advanced-Scripting.html", - "redirect": "/how-tos/Advanced-Scripting.html", - "statusCode": 301 - }, - { - "route": "/Best-Practice-Analyzer.html", - "redirect": "/features/Best-Practice-Analyzer.html", - "statusCode": 301 - }, - { - "route": "/Importing-Tables.html", - "redirect": "/how-tos/Importing-Tables.html", - "statusCode": 301 - }, - { - "route": "/tmdl", - "redirect": "/features/tmdl.html", - "statusCode": 301 - }, - { - "route": "/roslyn", - "redirect": "/how-tos/Advanced-Scripting.html#compiling-with-roslyn", - "statusCode": 301 - }, - { - "route": "/eula", - "redirect": "/security/te3-eula.html", - "statusCode": 301 - }, - { - "route": "/tmuo", - "redirect": "/references/user-options.html", - "statusCode": 301 - }, - { - "route": "/user-options.html", - "redirect": "/references/user-options.html", - "statusCode": 301 - }, - { - "route": "/workspace", - "redirect": "/tutorials/workspace-mode.html", - "statusCode": 301 - }, - { - "route": "/Workspace-Database.html", - "redirect": "/tutorials/workspace-mode.html", - "statusCode": 301 - }, - { - "route": "/common/Datasets/direct-lake-dataset.html", - "redirect": "/features/Semantic-Model/direct-lake-sql-model.html", - "statusCode": 301 - }, - { - "route": "/projects/te3/en/latest/editions.html", - "redirect": "/getting-started/editions.html", - "statusCode": 301 - }, - { - "route": "/projects/te3/en/latest/security-privacy.html", - "redirect": "/security/security-privacy.html", - "statusCode": 301 - }, - { - "route": "/projects/te3/en/latest/downloads.html", - "redirect": "/references/downloads.html", - "statusCode": 301 - }, - { - "route": "/other/downloads.html", - "redirect": "/references/downloads.html", - "statusCode": 301 - }, - { - "route": "/te3/downloads.html", - "redirect": "/references/downloads.html", - "statusCode": 301 - }, - { - "route": "/Useful-script-snippets.html", - "redirect": "/features/Useful-script-snippets.html", - "statusCode": 301 - }, - { - "route": "/Command-line-Options.html", - "redirect": "/features/Command-line-Options.html", - "statusCode": 301 - }, - { - "route": "/Power-BI-Desktop-Integration.html", - "redirect": "/getting-started/Power-BI-Desktop-Integration.html", - "statusCode": 301 - }, - { - "route": "/projects/te3/en/latest", - "redirect": "/te3", - "statusCode": 301 - }, - { - "route": "/projects/te3", - "redirect": "/te3", - "statusCode": 301 - }, - { - "route": "/projects/te3/en/latest/getting-started.html", - "redirect": "/getting-started/getting-started.html", - "statusCode": 301 - }, - { - "route": "/Custom-Actions.html", - "redirect": "/tutorials/creating-macros.html", - "statusCode": 301 - }, - { - "route": "/FormatDax.html", - "redirect": "/references/FormatDax.html", - "statusCode": 301 - }, - { - "route": "/te3/logo.svg", - "redirect": "/logo.svg", - "statusCode": 301 - } - ], - "responseOverrides": { - "404": { - "rewrite": "/404.html" - } - } -} +{"routes":[{"route":"/","redirect":"/en/","statusCode":301},{"route":"/index.html","redirect":"/en/","statusCode":301},{"route":"/en/references/release-notes","redirect":"/en/references/release-notes/3_25_5.html","statusCode":302},{"route":"/en/te3/other/release-notes","redirect":"/en/references/release-notes/3_25_5.html","statusCode":302},{"route":"/es/references/release-notes","redirect":"/es/references/release-notes/3_25_5.html","statusCode":302},{"route":"/es/te3/other/release-notes","redirect":"/es/references/release-notes/3_25_5.html","statusCode":302},{"route":"/zh/references/release-notes","redirect":"/zh/references/release-notes/3_25_5.html","statusCode":302},{"route":"/zh/te3/other/release-notes","redirect":"/zh/references/release-notes/3_25_5.html","statusCode":302},{"route":"/references/release-notes","redirect":"/en/references/release-notes/3_25_5.html","statusCode":302},{"route":"/Advanced-Scripting.html","redirect":"/en/how-tos/Advanced-Scripting.html","statusCode":301},{"route":"/Best-Practice-Analyzer.html","redirect":"/en/features/Best-Practice-Analyzer.html","statusCode":301},{"route":"/Command-line-Options.html","redirect":"/en/features/Command-line-Options.html","statusCode":301},{"route":"/Custom-Actions.html","redirect":"/en/tutorials/creating-macros.html","statusCode":301},{"route":"/FormatDax.html","redirect":"/en/references/FormatDax.html","statusCode":301},{"route":"/Importing-Tables.html","redirect":"/en/how-tos/Importing-Tables.html","statusCode":301},{"route":"/Power-BI-Desktop-Integration.html","redirect":"/en/getting-started/Power-BI-Desktop-Integration.html","statusCode":301},{"route":"/Useful-script-snippets.html","redirect":"/en/features/Useful-script-snippets.html","statusCode":301},{"route":"/Workspace-Database.html","redirect":"/en/tutorials/workspace-mode.html","statusCode":301},{"route":"/common/Datasets/direct-lake-dataset.html","redirect":"/en/features/Semantic-Model/direct-lake-sql-model.html","statusCode":301},{"route":"/eula","redirect":"/en/security/te3-eula.html","statusCode":301},{"route":"/onboarding/general-introduction.html","redirect":"/en/getting-started/general-introduction.html","statusCode":301},{"route":"/onboarding/index.html","redirect":"/en/getting-started/index.html","statusCode":301},{"route":"/onboarding/installation.html","redirect":"/en/getting-started/installation.html","statusCode":301},{"route":"/other/downloads.html","redirect":"/en/references/downloads.html","statusCode":301},{"route":"/privacy-policy.html","redirect":"/en/security/privacy-policy.html","statusCode":301},{"route":"/projects/te3","redirect":"/en/","statusCode":301},{"route":"/projects/te3/en/latest","redirect":"/en/","statusCode":301},{"route":"/projects/te3/en/latest/downloads.html","redirect":"/en/references/downloads.html","statusCode":301},{"route":"/projects/te3/en/latest/editions.html","redirect":"/en/getting-started/editions.html","statusCode":301},{"route":"/projects/te3/en/latest/getting-started.html","redirect":"/en/getting-started/getting-started.html","statusCode":301},{"route":"/projects/te3/en/latest/security-privacy.html","redirect":"/en/security/security-privacy.html","statusCode":301},{"route":"/roslyn","redirect":"/en/how-tos/Advanced-Scripting.html#compiling-with-roslyn","statusCode":301},{"route":"/te2/Advanced-Scripting.html","redirect":"/en/how-tos/Advanced-Scripting.html","statusCode":301},{"route":"/te2/Best-Practice-Analyzer.html","redirect":"/en/features/Best-Practice-Analyzer.html","statusCode":301},{"route":"/te2/Getting-Started.html","redirect":"/en/getting-started/Getting-Started-te2.html","statusCode":301},{"route":"/te2/Importing-Tables.html","redirect":"/en/how-tos/Importing-Tables.html","statusCode":301},{"route":"/te2/Power-BI-Desktop-Integration.html","redirect":"/en/getting-started/Power-BI-Desktop-Integration.html","statusCode":301},{"route":"/te2/Useful-script-snippets.html","redirect":"/en/features/Useful-script-snippets.html","statusCode":301},{"route":"/te3/downloads.html","redirect":"/en/references/downloads.html","statusCode":301},{"route":"/te3/editions.html","redirect":"/en/getting-started/editions.html","statusCode":301},{"route":"/te3/features/csharp-scripts.html","redirect":"/en/features/csharp-scripts.html","statusCode":301},{"route":"/te3/features/dax-debugger.html","redirect":"/en/features/dax-debugger.html","statusCode":301},{"route":"/te3/features/dax-editor.html","redirect":"/en/features/dax-editor.html","statusCode":301},{"route":"/te3/features/dax-scripts.html","redirect":"/en/features/dax-scripts.html","statusCode":301},{"route":"/te3/features/tmdl.html","redirect":"/en/features/tmdl.html","statusCode":301},{"route":"/te3/getting-started.html","redirect":"/en/getting-started/getting-started.html","statusCode":301},{"route":"/te3/index.html","redirect":"/en/troubleshooting/licensing-activation.html","statusCode":301},{"route":"/te3/logo.svg","redirect":"/en/logo.svg","statusCode":301},{"route":"/te3/other/downloads.html","redirect":"/en/references/downloads.html","statusCode":301},{"route":"/te3/other/release-history.html","redirect":"/en/references/release-history.html","statusCode":301},{"route":"/te3/tutorials/workspace-mode.html","redirect":"/en/tutorials/workspace-mode.html","statusCode":301},{"route":"/tmdl","redirect":"/en/features/tmdl.html","statusCode":301},{"route":"/tmuo","redirect":"/en/references/user-options.html","statusCode":301},{"route":"/user-options.html","redirect":"/en/references/user-options.html","statusCode":301},{"route":"/workspace","redirect":"/en/tutorials/workspace-mode.html","statusCode":301}],"responseOverrides":{"404":{"rewrite":"/404.html"}}} \ No newline at end of file diff --git a/build-docs.py b/build-docs.py new file mode 100644 index 00000000..6335efdd --- /dev/null +++ b/build-docs.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Unified documentation build script for multi-language support. + +Usage: + python build-docs.py # Build all languages (default) + python build-docs.py --all # Build all languages + python build-docs.py --lang en # Build English only + python build-docs.py --lang es zh # Build specific languages + python build-docs.py --list # List available languages + python build-docs.py --serve # Build English and serve locally + +Options: + --all Build all available languages + --lang LANGS Build specific language(s) (space-separated) + --list List available languages and exit + --serve Build and serve locally (English only, for development) + --skip-gen Skip running gen_redirects.py (use existing configs) + --no-api-copy Skip copying API docs to localized sites +""" + +import argparse +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +def run_command(cmd: list[str], description: str, check: bool = True) -> int: + """Run a command and return exit code.""" + print(f"\n{'='*60}") + print(f" {description}") + print(f"{'='*60}") + print(f"Running: {' '.join(cmd)}\n") + + result = subprocess.run(cmd, shell=(os.name == 'nt')) + + if check and result.returncode != 0: + print(f"Error: Command failed with exit code {result.returncode}") + return result.returncode + + return result.returncode + + +def get_available_languages() -> list[str]: + """Get list of available languages from metadata/languages.json or scan localizedContent/.""" + manifest_path = Path("metadata/languages.json") + + if manifest_path.exists(): + with open(manifest_path) as f: + data = json.load(f) + # Handle both simple array and rich metadata formats + languages = data.get("languages", []) + if languages and isinstance(languages[0], dict): + return [lang["code"] for lang in languages] + return languages + + # Fallback: scan localizedContent/ directly + localized_dir = Path("localizedContent") + if not localized_dir.exists(): + return [] + + return sorted([ + d.name for d in localized_dir.iterdir() + if d.is_dir() and len(d.name) <= 5 + ]) + + +def prepare_localized_content(lang: str) -> int: + """Run sync-localized-content.py for a language. + + For English: copies all source content + For other languages: syncs with translation tracking, + falls back to English for outdated/missing files + """ + if lang == "en": + description = "Syncing English content from source" + else: + description = f"Syncing {lang} content (fallback to English for outdated)" + + return run_command( + [sys.executable, "build_scripts/sync-localized-content.py", "--sync", lang], + description + ) + + +def build_language(lang: str) -> int: + """Build documentation for a specific language.""" + config_path = f"localizedContent/{lang}/docfx.json" + + if not os.path.exists(config_path): + print(f"Error: Config file not found: {config_path}") + print("Run 'python gen_redirects.py' first to generate configs.") + return 1 + + # Prepare content (copy from source for en, or fallbacks for other langs) + result = prepare_localized_content(lang) + if result != 0: + return result + + # Build the documentation + return run_command( + ["docfx", config_path], + f"Building {lang} documentation" + ) + + +def copy_languages_manifest() -> int: + """Copy languages.json to _site/ root for runtime access.""" + manifest_src = Path("metadata/languages.json") + manifest_dest = Path("_site/languages.json") + + if not manifest_src.exists(): + print("Warning: languages.json not found, skipping copy") + return 0 + + # Ensure _site directory exists + manifest_dest.parent.mkdir(parents=True, exist_ok=True) + + shutil.copy(manifest_src, manifest_dest) + print(f"Copied languages.json to _site/") + return 0 + + +def copy_404_to_root() -> int: + """Copy 404.html from English site to _site/ root for SWA fallback.""" + src_404 = Path("_site/en/404.html") + dest_404 = Path("_site/404.html") + + if not src_404.exists(): + print("Warning: _site/en/404.html not found, skipping 404 copy") + return 0 + + shutil.copy(src_404, dest_404) + print(f"Copied 404.html to _site/ root") + return 0 + + +def copy_index_to_root() -> int: + """Copy index.html redirect from content to _site/ root for SWA validation.""" + src_index = Path("content/index.html") + dest_index = Path("_site/index.html") + + if not src_index.exists(): + print("Warning: content/index.html not found, skipping index copy") + return 0 + + shutil.copy(src_index, dest_index) + print(f"Copied index.html to _site/ root") + return 0 + + +def copy_api_docs(languages: list[str]) -> int: + """Copy API docs from English to localized sites.""" + en_api = Path("_site/en/api") + + if not en_api.exists(): + print("Warning: English API docs not found, skipping API copy") + return 0 + + print(f"\n{'='*60}") + print(" Copying API docs to localized sites") + print(f"{'='*60}") + + for lang in languages: + dest = Path(f"_site/{lang}/api") + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(en_api, dest) + print(f" Copied API docs to _site/{lang}/api") + + return 0 + + +def fix_xref_in_api() -> int: + """Fix shared xref links in API HTML files.""" + api_dir = Path("_site/en/api") + + if not api_dir.exists(): + return 0 + + print(f"\n{'='*60}") + print(" Fixing xref links in API docs") + print(f"{'='*60}") + + count = 0 + for html_file in api_dir.rglob("*.html"): + content = html_file.read_text(encoding="utf-8") + if 'Shared' in content: + content = content.replace( + 'Shared', + 'Shared' + ) + html_file.write_text(content, encoding="utf-8") + count += 1 + + print(f" Fixed {count} file(s)") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Build documentation for one or more languages", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + parser.add_argument("--all", action="store_true", help="Build all languages") + parser.add_argument("--lang", nargs="+", help="Build specific language(s)") + parser.add_argument("--list", action="store_true", help="List available languages") + parser.add_argument("--serve", action="store_true", help="Build English and serve locally") + parser.add_argument("--skip-gen", action="store_true", help="Skip gen_redirects.py") + parser.add_argument("--no-api-copy", action="store_true", help="Skip copying API docs") + + args = parser.parse_args() + + # List available languages + if args.list: + langs = get_available_languages() + print("Available languages:") + for lang in langs: + suffix = " (default)" if lang == "en" else "" + print(f" {lang}{suffix}") + return 0 + + # Run gen_redirects.py first (unless skipped) + if not args.skip_gen: + result = run_command( + [sys.executable, "build_scripts/gen_redirects.py"], + "Generating docfx configurations" + ) + if result != 0: + return result + + # Generate languages manifest + result = run_command( + [sys.executable, "build_scripts/gen_languages.py"], + "Generating languages manifest" + ) + if result != 0: + return result + + # Determine which languages to build + available_langs = get_available_languages() + + if args.serve: + # Build English only and serve + result = build_language("en") + if result != 0: + return result + + fix_xref_in_api() + copy_languages_manifest() + + # Also copy to _site/en/ for local serving (docfx serve serves from en/) + manifest_src = Path("metadata/languages.json") + manifest_dest = Path("_site/en/languages.json") + if manifest_src.exists(): + shutil.copy(manifest_src, manifest_dest) + print("Copied languages.json to _site/en/") + + return run_command( + ["docfx", "serve", "_site/en"], + "Serving documentation locally" + ) + + if args.lang: + # Build specific languages + build_langs = args.lang + + # Validate languages + for lang in build_langs: + if lang not in available_langs: + print(f"Error: Language '{lang}' not found") + print(f"Available: {', '.join(available_langs)}") + return 1 + else: + # Build all languages (default behavior) + build_langs = available_langs + + # Ensure English is built first (needed for API docs) + if "en" in build_langs: + build_langs = ["en"] + [l for l in build_langs if l != "en"] + + # Build all requested languages + for lang in build_langs: + result = build_language(lang) + if result != 0: + return result + + if lang == "en": + fix_xref_in_api() + + # Copy API docs to localized sites (non-English) + non_en_langs = [l for l in build_langs if l != "en"] + if non_en_langs and not args.no_api_copy and "en" in build_langs: + copy_api_docs(non_en_langs) + + # Copy languages manifest to _site root + copy_languages_manifest() + + # Copy 404.html to site root for SWA fallback + copy_404_to_root() + + # Copy index.html to site root for SWA validation + copy_index_to_root() + + # Inject SEO tags (hreflang, canonical) into HTML files for built languages + for lang in build_langs: + run_command( + [sys.executable, "build_scripts/inject_seo_tags.py", "--lang", lang], + f"Injecting SEO tags for {lang}" + ) + + # Generate staticwebapp.config.json for Azure SWA routing + run_command( + [sys.executable, "build_scripts/gen_staticwebapp_config.py"], + "Generating staticwebapp.config.json" + ) + + print(f"\n{'='*60}") + print(" Build complete!") + print(f"{'='*60}") + print(f"Output: _site/") + for lang in build_langs: + print(f" - {lang}/") + + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nBuild interrupted.") + sys.exit(1) + except Exception as e: + print(f"Error: {e}") + sys.exit(1) diff --git a/build_scripts/config_loader.py b/build_scripts/config_loader.py new file mode 100644 index 00000000..ec69f3da --- /dev/null +++ b/build_scripts/config_loader.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Shared configuration loader for build scripts. + +Loads build-config.json, redirects.json, and language-metadata.json from metadata/. +Provides consistent access to directories, files, and settings across all scripts. +""" + +import hashlib +import json +from pathlib import Path +from typing import Any + + +# Default paths (relative to project root) +BUILD_CONFIG_PATH = "metadata/build-config.json" +REDIRECTS_CONFIG_PATH = "metadata/redirects.json" +LANGUAGE_METADATA_PATH = "metadata/language-metadata.json" + +# Cached configs +_build_config: dict | None = None +_redirects_config: dict | None = None + + +def load_build_config(config_path: Path | str | None = None) -> dict[str, Any]: + """Load the build configuration from JSON file. + + Returns the full config dict with the following keys: + - contentDirectories: directories with translatable content + - sharedDirectories: assets/api that aren't translated + - rootFiles: root-level files (index.md, toc.yml, etc.) + """ + global _build_config + + if _build_config is not None and config_path is None: + return _build_config + + if config_path is None: + config_path = Path(BUILD_CONFIG_PATH) + else: + config_path = Path(config_path) + + if not config_path.exists(): + raise FileNotFoundError(f"Build config not found: {config_path}") + + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + + if config_path == Path(BUILD_CONFIG_PATH): + _build_config = config + + return config + + +def load_redirects_config(config_path: Path | str | None = None) -> dict[str, Any]: + """Load the redirects configuration from JSON file. + + Returns the full config dict with the following keys: + - serverRedirects: 301 redirects handled by Azure SWA + - clientRedirects: meta-refresh HTML redirects + """ + global _redirects_config + + if _redirects_config is not None and config_path is None: + return _redirects_config + + if config_path is None: + config_path = Path(REDIRECTS_CONFIG_PATH) + else: + config_path = Path(config_path) + + if not config_path.exists(): + raise FileNotFoundError(f"Redirects config not found: {config_path}") + + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + + if config_path == Path(REDIRECTS_CONFIG_PATH): + _redirects_config = config + + return config + + +def get_content_directories(config: dict | None = None) -> list[str]: + """Get list of content directories that should be localized.""" + if config is None: + config = load_build_config() + return config.get("contentDirectories", {}).get("directories", []) + + +def get_shared_directories(config: dict | None = None) -> list[str]: + """Get list of shared directories (assets, api) that aren't translated.""" + if config is None: + config = load_build_config() + return config.get("sharedDirectories", {}).get("directories", []) + + +def get_root_files(config: dict | None = None) -> list[str]: + """Get list of root-level files to copy/localize.""" + if config is None: + config = load_build_config() + return config.get("rootFiles", {}).get("files", []) + + +def get_legacy_shortcuts(config: dict | None = None) -> dict[str, str]: + """Get legacy shortcut redirects (old URL → new URL). + + These are server-side 301 redirects for high-priority/vanity URLs. + Filters out keys starting with '_' which are used for comments. + """ + if config is None: + config = load_redirects_config() + redirects = config.get("serverRedirects", {}).get("redirects", {}) + # Filter out comment keys + return {k: v for k, v in redirects.items() if not k.startswith("_")} + + +def get_client_redirects(config: dict | None = None) -> dict[str, str]: + """Get client-side redirects for legacy content URLs. + + These are meta-refresh HTML redirects for content migration. + Keys are paths like '/te2/Getting-Started.html', values are target paths. + Filters out keys starting with '_' which are used for comments. + """ + if config is None: + config = load_redirects_config() + redirects = config.get("clientRedirects", {}).get("redirects", {}) + # Filter out comment keys + return {k: v for k, v in redirects.items() if not k.startswith("_")} + + +def get_all_redirects(config: dict | None = None) -> dict[str, str]: + """Get all redirects (both server and client) merged together. + + Returns a combined dict of all redirects. Server redirects take precedence + if there are any duplicates (though there shouldn't be). + """ + if config is None: + config = load_redirects_config() + + all_redirects = {} + all_redirects.update(get_client_redirects(config)) + all_redirects.update(get_legacy_shortcuts(config)) + return all_redirects + + +def get_default_language(config: dict | None = None) -> str: + """Get the default language code.""" + if config is None: + config = load_build_config() + return config.get("defaultLanguage", "en") + + +def compute_file_hash(file_path: Path | str) -> str: + """Compute SHA256 hash of a file's contents. + + Returns a hex string prefixed with 'sha256:' for clarity. + Returns empty string if file doesn't exist. + """ + file_path = Path(file_path) + + if not file_path.exists(): + return "" + + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + # Read in chunks for large files + for chunk in iter(lambda: f.read(8192), b""): + sha256_hash.update(chunk) + + return f"sha256:{sha256_hash.hexdigest()}" + + +def get_all_content_files(content_dir: Path | str) -> list[Path]: + """Get all content files (markdown, yaml) from a content directory. + + Returns list of paths relative to the content directory. + """ + content_dir = Path(content_dir) + + if not content_dir.exists(): + return [] + + files = [] + for pattern in ["**/*.md", "**/*.yml", "**/*.yaml"]: + files.extend(content_dir.glob(pattern)) + + return sorted(files) diff --git a/build_scripts/gen_languages.py b/build_scripts/gen_languages.py new file mode 100644 index 00000000..a39433fc --- /dev/null +++ b/build_scripts/gen_languages.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Language manifest generator for multi-language documentation. + +Scans localizedContent/ directory and generates languages.json manifest +with rich metadata for each available language. + +Language metadata is loaded from metadata/language-metadata.json. +Unknown languages fall back to using the code as the display name. + +Usage: + python gen_languages.py # Generate to metadata/ + python gen_languages.py --output . # Generate to current directory +""" + +import argparse +import json +import re +from datetime import datetime, timezone +from pathlib import Path + +from config_loader import get_default_language, get_content_directories, get_shared_directories + + +# Default paths +METADATA_FILE = "metadata/language-metadata.json" +DEFAULT_LANGUAGE = get_default_language() + +# Cached metadata (loaded on first use) +_language_metadata: dict | None = None +_default_language: str | None = None + + +def load_language_metadata(metadata_path: Path | None = None) -> tuple[dict, str]: + """Load language metadata from JSON file. + + Returns tuple of (metadata_dict, default_language). + """ + global _language_metadata, _default_language + + if _language_metadata is not None: + return _language_metadata, _default_language or DEFAULT_LANGUAGE + + # Find metadata file + if metadata_path is None: + metadata_path = Path(METADATA_FILE) + + if not metadata_path.exists(): + print(f"Warning: {metadata_path} not found, using built-in defaults") + _language_metadata = { + "en": {"name": "English", "nativeName": "English"} + } + _default_language = DEFAULT_LANGUAGE + return _language_metadata, _default_language + + with open(metadata_path, encoding="utf-8") as f: + data = json.load(f) + + _language_metadata = data.get("languages", {}) + _default_language = data.get("defaultLanguage", DEFAULT_LANGUAGE) + + return _language_metadata, _default_language + + +def get_language_metadata(code: str, metadata: dict | None = None) -> dict: + """Get metadata for a language code, with fallback for unknown languages.""" + code_lower = code.lower() + + if metadata is None: + metadata, _ = load_language_metadata() + + if code_lower in metadata: + result = metadata[code_lower].copy() + else: + # Fallback for unknown languages - use code as name + result = { + "name": code.upper(), + "nativeName": code.upper() + } + + result["code"] = code_lower + return result + + +def scan_localized_content(localized_dir: Path) -> list[str]: + """Scan localizedContent directory for available languages.""" + if not localized_dir.exists(): + return [] + + languages = [] + for item in localized_dir.iterdir(): + if item.is_dir(): + # Check if it looks like a language code (2-5 chars) + name = item.name.lower() + if 2 <= len(name) <= 5 and name.replace("-", "").isalpha(): + # Verify it has content (docfx.json or content folder) + if (item / "docfx.json").exists() or (item / "content").exists(): + languages.append(name) + + return sorted(languages) + + +_LANG_FORMAT_RE = re.compile(r'^[a-z-]+$', re.IGNORECASE) + + +def get_legacy_content_prefixes() -> list[str]: + """Get directory names that match language-code format (2-5 alpha chars). + + Short directory names like 'api' and 'kb' can be confused with language + codes in URLs. The 404 page uses this list to redirect legacy content URLs + (e.g. /api/index.html → /en/api/index.html) instead of treating them as + unsupported language codes. + """ + all_dirs = get_content_directories() + get_shared_directories() + return sorted(d for d in all_dirs if 2 <= len(d) <= 5 and _LANG_FORMAT_RE.match(d)) + + +def generate_manifest(languages: list[str], metadata: dict, default_lang: str) -> dict: + """Generate the languages manifest with rich metadata.""" + lang_list = [] + + for code in languages: + lang_meta = get_language_metadata(code, metadata) + if code == default_lang: + lang_meta["default"] = True + lang_list.append(lang_meta) + + # Ensure default language is first in the list + lang_list.sort(key=lambda x: (not x.get("default", False), x["code"])) + + return { + "languages": lang_list, + "defaultLanguage": default_lang, + "legacyPrefixes": get_legacy_content_prefixes(), + "generated": datetime.now(timezone.utc).isoformat() + } + + +def main(): + parser = argparse.ArgumentParser( + description="Generate languages.json manifest from localizedContent/" + ) + parser.add_argument( + "--output", "-o", + default="metadata", + help="Output directory for languages.json (default: metadata)" + ) + parser.add_argument( + "--localized-dir", "-l", + default="localizedContent", + help="Directory containing localized content (default: localizedContent)" + ) + parser.add_argument( + "--metadata", "-m", + default=METADATA_FILE, + help=f"Path to language metadata JSON (default: {METADATA_FILE})" + ) + parser.add_argument( + "--default", "-d", + help="Override default language code (default: from metadata file)" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print manifest without writing to file" + ) + + args = parser.parse_args() + + # Load language metadata + metadata, default_from_file = load_language_metadata(Path(args.metadata)) + default_lang = args.default or default_from_file + + print(f"Loaded metadata from: {args.metadata}") + print(f"Default language: {default_lang}") + + # Scan for languages + localized_dir = Path(args.localized_dir) + languages = scan_localized_content(localized_dir) + + if not languages: + print(f"Warning: No languages found in {localized_dir}") + print("Creating manifest with default language only.") + languages = [default_lang] + + print(f"Found languages: {', '.join(languages)}") + + # Generate manifest + manifest = generate_manifest(languages, metadata, default_lang) + + if args.dry_run: + print("\nGenerated manifest (dry run):") + print(json.dumps(manifest, indent=2, ensure_ascii=False)) + return 0 + + # Write manifest + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + + output_file = output_dir / "languages.json" + with open(output_file, "w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + + print(f"\nGenerated: {output_file}") + print(f"Languages: {len(manifest['languages'])}") + + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/build_scripts/gen_redirects.py b/build_scripts/gen_redirects.py new file mode 100644 index 00000000..16d44dbd --- /dev/null +++ b/build_scripts/gen_redirects.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Generate docfx configuration files and redirects. + +This script: +1. Generates localizedContent/en/docfx.json for English with redirects +2. Scans localizedContent/ for language folders +3. Generates localizedContent/{lang}/docfx.json for each language + +Note: languages.json manifest is generated by gen_languages.py (not this script). + +Usage: python gen_redirects.py +""" + +import copy +import json +import os +import posixpath +import shutil +import sys +import traceback +from typing import Any + +from config_loader import get_client_redirects + + +def get_available_languages() -> list[str]: + """Scan localizedContent/ folder and return list of language codes (excluding 'en').""" + localized_dir = "localizedContent" + if not os.path.exists(localized_dir): + return [] + + languages = [] + for item in os.listdir(localized_dir): + item_path = os.path.join(localized_dir, item) + # Exclude 'en' since it's generated, not stored + if os.path.isdir(item_path) and len(item) <= 5 and item != "en": + languages.append(item) + + return sorted(languages) + + +def generate_localized_config(template: dict, lang: str) -> dict: + """Generate a docfx config for a specific language based on the template. + + Note: Config files are placed in localizedContent/{lang}/docfx.json alongside + a content/ subdirectory. This allows ~/content/... paths in markdown to resolve + correctly (~ = config location = localizedContent/{lang}/). + + Structure: + localizedContent/{lang}/ + docfx.json <- generated config + content/ + features/ <- localized/fallback content + assets/images/ <- shared resources + ... + """ + config = copy.deepcopy(template) + + # Remove metadata section (API generation) - localized sites use copied API docs + if "metadata" in config: + del config["metadata"] + + build = config["build"] + + # Since docfx.json is in localizedContent/{lang}/ and content is in + # localizedContent/{lang}/content/, paths stay the same as the English template + # No path modifications needed for content entries! + + # Resource paths: keep local content/ paths, but for any fallback to main + # content folder, we'd use ../../content/ (but we copy everything locally now) + + # Set output destination to language subfolder (relative to project root) + # From localizedContent/{lang}/, we go up twice to reach project root + build["dest"] = f"../../_site/{lang}" + + # Update template paths - need to go up two levels to reach project root + if "template" in build: + new_templates = [] + for t in build["template"]: + if t == "default": + new_templates.append(t) + else: + # templates/tabulareditor -> ../../templates/tabulareditor + new_templates.append(f"../../{t}") + build["template"] = new_templates + + return config + + +def generate_redirects_config(template: dict, redirects_data: dict[str, str], en_content_dir: str) -> dict: + """Generate English docfx.json with redirects added. + + Args: + template: Base docfx config template + redirects_data: Dict of redirect paths to target URLs + en_content_dir: Directory for English content (localizedContent/en/content/) + """ + config = copy.deepcopy(template) + + # Fix metadata paths (API generation) - need to go up two levels to reach project root + # DocFX doesn't support ../ in file globs, so we use src to set the base directory + if "metadata" in config: + for meta in config["metadata"]: + # Fix src paths - move ../ to src, keep files as relative globs + if "src" in meta: + new_src = [] + for src in meta["src"]: + if "files" in src: + # Transform files like "content/_apiSource/*.dll" to use src + new_files = [] + for f in src["files"]: + # Extract directory and file pattern + # e.g., "content/_apiSource/*.dll" -> src="../..", files="content/_apiSource/*.dll" + new_files.append(f) + new_src.append({ + "src": "../..", + "files": new_files + }) + else: + new_src.append(src) + meta["src"] = new_src + # Fix dest path + if "dest" in meta: + meta["dest"] = f"../../{meta['dest']}" + # Fix filter path + if "filter" in meta: + meta["filter"] = f"../../{meta['filter']}" + + dirs = dict[str, list[str]]() + + for key, value in redirects_data.items(): + # Redirect paths are relative to content/, convert to en_content_dir + # e.g., content/old-page.md -> localizedContent/en/content/old-page.md + dest_path = key.replace("content/", f"{en_content_dir}/", 1) + dir_path = posixpath.dirname(dest_path) + ext = posixpath.splitext(key)[1] + + if dir_path in dirs: + dirs[dir_path].append(dest_path) + else: + dirs[dir_path] = [dest_path] + + os.makedirs(dir_path, exist_ok=True) + + if ext == ".md": + content_list: list[Any] = config["build"]["content"] + content_list.append({"files": posixpath.relpath(key, "content"), "src": "content"}) + with open(dest_path, mode="w", encoding="utf-8") as f: + f.write(f"""--- +redirect_url: {value} +--- +""") + elif ext == ".html": + resource: list[Any] = config["build"]["resource"] + resource.append({"files": posixpath.relpath(key, "content"), "src": "content"}) + with open(dest_path, mode="w", encoding="utf-8") as f: + f.write(f""" + + + + + + + +""") + else: + print("Unknown file type:", key, file=sys.stderr) + + # Set English output destination (relative to localizedContent/en/) + config["build"]["dest"] = "../../_site/en" + + # Update template paths - need to go up two levels to reach project root + if "template" in config["build"]: + new_templates = [] + for t in config["build"]["template"]: + if t == "default": + new_templates.append(t) + else: + new_templates.append(f"../../{t}") + config["build"]["template"] = new_templates + + return config + + +def main(args: list[str]) -> int: + config_input_path = "docfx-template.json" + localized_content_dir = "localizedContent" + + # Load template + with open(config_input_path) as f: + template = json.load(f) + + # Load redirects from metadata/redirects.json + # Keys are in format '/path.html', need to convert to 'content/path.html' + client_redirects = get_client_redirects() + redirects_data: dict[str, str] = {} + for src, target in client_redirects.items(): + # Convert '/path.html' to 'content/path.html' + content_key = 'content' + src if src.startswith('/') else 'content/' + src + redirects_data[content_key] = target + + print(f"Loaded {len(redirects_data)} client redirects from metadata/redirects.json") + + # Create English directory + en_dir = os.path.join(localized_content_dir, "en") + en_content_dir = os.path.join(en_dir, "content") + os.makedirs(en_content_dir, exist_ok=True) + + # Generate English config with redirects in localizedContent/en/ + en_config_path = os.path.join(en_dir, "docfx.json") + print(f"Generating {en_config_path} (English)...") + english_config = generate_redirects_config(template, redirects_data, en_content_dir) + with open(en_config_path, "w") as f: + json.dump(english_config, f, indent=4) + + # Get available languages from localizedContent/ (excludes 'en') + languages = get_available_languages() + all_languages = ["en"] + languages + print(f"Found {len(all_languages)} language(s): {', '.join(all_languages)}") + + # Generate config for each non-English language + for lang in languages: + lang_dir = os.path.join(localized_content_dir, lang) + os.makedirs(lang_dir, exist_ok=True) + + config_path = os.path.join(lang_dir, "docfx.json") + print(f"Generating {config_path}...") + + localized_config = generate_localized_config(template, lang) + with open(config_path, "w") as f: + json.dump(localized_config, f, indent=4) + + # Note: languages.json manifest is generated by gen_languages.py + # which provides rich metadata (display names, native names, rtl, etc.) + + print("Done!") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv[1:])) + except Exception as ex: + traceback.print_exception(ex, file=sys.stderr) + sys.exit(1) diff --git a/build_scripts/gen_staticwebapp_config.py b/build_scripts/gen_staticwebapp_config.py new file mode 100644 index 00000000..ab3367d3 --- /dev/null +++ b/build_scripts/gen_staticwebapp_config.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Generate staticwebapp.config.json for Azure Static Web Apps. + +This script creates server-side redirects for: +1. Root URLs (/, /index.html) → /en/ (301) +2. Release notes aliases → /en/references/release-notes/{latest}.html (302, auto-detected) +3. Legacy shortcut URLs (/tmdl, /roslyn, etc.) → /en/... (301) + +Note: Legacy directory wildcards (/features/*, etc.) are NOT handled here. +They fall through to 404.html which performs client-side meta-refresh redirects. + +Usage: + python gen_staticwebapp_config.py # Generate config + python gen_staticwebapp_config.py --dry-run # Preview without writing + python gen_staticwebapp_config.py --output custom/ # Custom output path +""" + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +from config_loader import get_legacy_shortcuts, get_default_language + + +# Load from centralized config +LEGACY_SHORTCUTS = get_legacy_shortcuts() +DEFAULT_LANGUAGE = get_default_language() + + +def find_latest_release_notes(site_dir: str = "_site", default_lang: str = "en") -> str | None: + """Find the filename of the latest versioned release notes in the built site. + + Scans {site_dir}/{default_lang}/references/release-notes/ for files matching + the pattern {major}_{minor}_{patch}.html, sorts them by semantic version, + and returns the filename of the newest one (e.g. '3_25_5.html'). + Returns None if the directory doesn't exist or contains no versioned files. + """ + release_notes_dir = Path(site_dir) / default_lang / "references" / "release-notes" + + if not release_notes_dir.exists(): + return None + + version_pattern = re.compile(r"^(\d+)_(\d+)_(\d+)\.html$") + versioned: list[tuple[tuple[int, int, int], str]] = [] + + for html_file in release_notes_dir.glob("*.html"): + m = version_pattern.match(html_file.name) + if m: + version = (int(m.group(1)), int(m.group(2)), int(m.group(3))) + versioned.append((version, html_file.name)) + + if not versioned: + return None + + versioned.sort(key=lambda x: x[0], reverse=True) + return versioned[0][1] + + +def generate_config(languages: list[str], default_lang: str | None = None, site_dir: str = "_site") -> dict[str, Any]: + """Generate the staticwebapp.config.json content.""" + if default_lang is None: + default_lang = DEFAULT_LANGUAGE + routes: list[dict[str, Any]] = [] + + # Resolve the latest release notes file, fall back to release-history.html + latest_rn = find_latest_release_notes(site_dir, default_lang) + if latest_rn: + rn_rel_path = f"references/release-notes/{latest_rn}" + else: + rn_rel_path = "references/release-history.html" + + # 1. Root redirects (301 for SEO) + routes.append({ + "route": "/", + "redirect": f"/{default_lang}/", + "statusCode": 301 + }) + routes.append({ + "route": "/index.html", + "redirect": f"/{default_lang}/", + "statusCode": 301 + }) + + # 2. Release notes special handling (302 - dynamic target) + # These point to the latest release notes which changes over time + # Generate explicit routes per language since Azure SWA doesn't support segment capture + for lang in languages: + routes.append({ + "route": f"/{lang}/references/release-notes", + "redirect": f"/{lang}/{rn_rel_path}", + "statusCode": 302 + }) + routes.append({ + "route": f"/{lang}/te3/other/release-notes", + "redirect": f"/{lang}/{rn_rel_path}", + "statusCode": 302 + }) + # Also handle non-prefixed paths + routes.append({ + "route": "/references/release-notes", + "redirect": f"/{default_lang}/{rn_rel_path}", + "statusCode": 302 + }) + + # 3. Legacy shortcut redirects (301) + for old_path, new_path in sorted(LEGACY_SHORTCUTS.items()): + routes.append({ + "route": old_path, + "redirect": new_path, + "statusCode": 301 + }) + + # 4. Directory wildcard migration (fallback to 404.html) + # Note: Azure SWA doesn't support wildcard capture in redirect targets. + # Non-prefixed URLs like /features/x.html will fall through to 404.html, + # which uses redirects.json to perform meta-refresh redirects. + # This is SEO-acceptable as a 302 + client redirect for legacy URLs. + # + # For high-traffic legacy pages, add explicit routes to legacyShortcuts + # in build-config.json for proper 301 server-side redirects. + + # Build final config + # Note: 404.html is copied to site root during build for language-aware fallback + config = { + "routes": routes, + "responseOverrides": { + "404": { + "rewrite": "/404.html" + } + } + } + + return config + + +def load_languages() -> list[str]: + """Load supported languages from languages.json manifest.""" + manifest_paths = [ + Path("metadata/languages.json"), + Path("_site/languages.json"), + ] + + for path in manifest_paths: + if path.exists(): + with open(path) as f: + data = json.load(f) + languages = data.get("languages", []) + # Handle both simple array and rich metadata formats + if languages and isinstance(languages[0], dict): + return [lang["code"] for lang in languages] + return languages + + # Fallback + print("Warning: languages.json not found, using default [en]") + return ["en"] + + +def main(): + parser = argparse.ArgumentParser( + description="Generate staticwebapp.config.json for Azure Static Web Apps" + ) + parser.add_argument( + "--output", "-o", + default="_site", + help="Output directory (default: _site)" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print config without writing to file" + ) + parser.add_argument( + "--default-lang", "-d", + default=DEFAULT_LANGUAGE, + help=f"Default language for fallback (default: {DEFAULT_LANGUAGE})" + ) + + args = parser.parse_args() + + # Load languages + languages = load_languages() + print(f"Languages: {', '.join(languages)}") + print(f"Default: {args.default_lang}") + + # Generate config + config = generate_config(languages, args.default_lang, args.output) + + latest_rn = find_latest_release_notes(args.output, args.default_lang) + rn_target = f"references/release-notes/{latest_rn}" if latest_rn else "references/release-history.html (fallback)" + release_notes_count = len(languages) * 2 + 1 + print(f"\nGenerated {len(config['routes'])} routes:") + print(f" - Root redirects: 2") + print(f" - Release notes: {release_notes_count} -> {rn_target}") + print(f" - Legacy shortcuts: {len(LEGACY_SHORTCUTS)}") + + if args.dry_run: + print("\n--- DRY RUN: Config preview ---") + print(json.dumps(config, indent=2)) + return 0 + + # Write config + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + + output_file = output_dir / "staticwebapp.config.json" + with open(output_file, "w", encoding="utf-8") as f: + json.dump(config, f, separators=(",", ":")) + + print(f"\nGenerated: {output_file}") + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/build_scripts/inject_seo_tags.py b/build_scripts/inject_seo_tags.py new file mode 100644 index 00000000..335c6379 --- /dev/null +++ b/build_scripts/inject_seo_tags.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Inject SEO tags (hreflang, canonical) into built HTML files. + +This script runs after docfx build to add: +1. Canonical URLs - prevents duplicate content penalties +2. hreflang tags - tells search engines about language alternatives +3. x-default hreflang - indicates the default language version + +Usage: + python inject_seo_tags.py # Process _site/ + python inject_seo_tags.py --base-url https://... # Custom base URL + python inject_seo_tags.py --dry-run # Preview without changes +""" + +import argparse +import json +import re +from pathlib import Path + +from config_loader import get_default_language + + +# Default base URL for the documentation site +DEFAULT_BASE_URL = "https://docs.tabulareditor.com" + +# Default language (used for x-default hreflang) - loaded from config +DEFAULT_LANGUAGE = get_default_language() + + +def load_languages(site_dir: Path) -> list[dict]: + """Load languages from the manifest file.""" + manifest_path = site_dir / "languages.json" + + if not manifest_path.exists(): + # Fallback: try metadata/languages.json (generated before docfx build) + manifest_path = Path("metadata/languages.json") + + if not manifest_path.exists(): + print(f"Warning: languages.json not found in {site_dir} or metadata/, using default [en]") + return [{"code": "en", "name": "English", "nativeName": "English", "default": True}] + + with open(manifest_path, encoding="utf-8") as f: + data = json.load(f) + + languages = data.get("languages", []) + + # Handle both simple array and rich metadata formats + result = [] + for lang in languages: + if isinstance(lang, str): + result.append({"code": lang}) + else: + result.append(lang) + + return result + + +def get_page_path(html_file: Path, site_dir: Path, lang: str) -> str: + """Get the page path relative to the language folder. + + Example: _site/en/features/overview.html -> features/overview.html + """ + rel_path = html_file.relative_to(site_dir / lang) + return str(rel_path).replace("\\", "/") + + +def generate_seo_tags( + page_path: str, + current_lang: str, + languages: list[dict], + base_url: str, + default_lang: str = DEFAULT_LANGUAGE +) -> str: + """Generate canonical and hreflang link tags.""" + lines = [] + + # Canonical URL - always points to the current page + canonical_url = f"{base_url}/{current_lang}/{page_path}" + lines.append(f' ') + + # hreflang tags for each language + for lang in languages: + code = lang.get("code", lang) if isinstance(lang, dict) else lang + lang_url = f"{base_url}/{code}/{page_path}" + lines.append(f' ') + + # x-default points to the default language version + default_url = f"{base_url}/{default_lang}/{page_path}" + lines.append(f' ') + + return "\n".join(lines) + + +def inject_tags_into_html(html_content: str, seo_tags: str) -> str: + """Inject SEO tags into HTML content after the opening tag.""" + # Check if tags already exist (avoid duplicate injection) + if 'rel="canonical"' in html_content: + return html_content + + # Find the tag and insert after it + # Match or + head_pattern = re.compile(r'(]*>)', re.IGNORECASE) + match = head_pattern.search(html_content) + + if match: + insert_pos = match.end() + return html_content[:insert_pos] + "\n" + seo_tags + html_content[insert_pos:] + + # Fallback: if no found, return unchanged + print("Warning: No tag found") + return html_content + + +def process_html_file( + html_file: Path, + site_dir: Path, + lang: str, + languages: list[dict], + base_url: str, + default_lang: str, + dry_run: bool = False +) -> bool: + """Process a single HTML file, injecting SEO tags. + + Returns True if file was modified, False otherwise. + """ + try: + page_path = get_page_path(html_file, site_dir, lang) + seo_tags = generate_seo_tags(page_path, lang, languages, base_url, default_lang) + + with open(html_file, encoding="utf-8") as f: + content = f.read() + + # Skip if already has canonical (avoid duplicate processing) + if 'rel="canonical"' in content: + return False + + # Skip HTML fragments without tag (e.g., partial templates) + if ' int: + """Process all HTML files in a language folder. + + Returns count of files modified. + """ + lang_dir = site_dir / lang + + if not lang_dir.exists(): + print(f"Warning: Language folder {lang_dir} does not exist") + return 0 + + modified_count = 0 + + for html_file in lang_dir.rglob("*.html"): + # Skip toc.html files - they're navigation fragments, not full pages + if html_file.name == "toc.html": + continue + if process_html_file(html_file, site_dir, lang, languages, base_url, default_lang, dry_run): + modified_count += 1 + + return modified_count + + +def main(): + parser = argparse.ArgumentParser( + description="Inject SEO tags (hreflang, canonical) into HTML files" + ) + parser.add_argument( + "--site-dir", "-s", + default="_site", + help="Site output directory (default: _site)" + ) + parser.add_argument( + "--base-url", "-b", + default=DEFAULT_BASE_URL, + help=f"Base URL for the site (default: {DEFAULT_BASE_URL})" + ) + parser.add_argument( + "--default-lang", "-d", + default=DEFAULT_LANGUAGE, + help=f"Default language for x-default (default: {DEFAULT_LANGUAGE})" + ) + parser.add_argument( + "--lang", "-l", + help="Process only this language (default: all)" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview changes without modifying files" + ) + + args = parser.parse_args() + + site_dir = Path(args.site_dir) + + if not site_dir.exists(): + print(f"Error: Site directory {site_dir} does not exist") + return 1 + + # Load languages + languages = load_languages(site_dir) + lang_codes = [l.get("code", l) if isinstance(l, dict) else l for l in languages] + + print(f"Base URL: {args.base_url}") + print(f"Languages: {', '.join(lang_codes)}") + print(f"Default: {args.default_lang}") + + if args.dry_run: + print("\n--- DRY RUN MODE ---\n") + + total_modified = 0 + + # Process specified language or all languages + langs_to_process = [args.lang] if args.lang else lang_codes + + for lang in langs_to_process: + print(f"\nProcessing {lang}/...") + count = process_language_folder( + site_dir, + lang, + languages, + args.base_url, + args.default_lang, + args.dry_run + ) + print(f" Modified: {count} files") + total_modified += count + + print(f"\nTotal: {total_modified} files {'would be ' if args.dry_run else ''}modified") + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/build_scripts/sync-localized-content.py b/build_scripts/sync-localized-content.py new file mode 100644 index 00000000..1436513c --- /dev/null +++ b/build_scripts/sync-localized-content.py @@ -0,0 +1,534 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Sync localized content with translation status tracking. + +This script manages the synchronization between source content (English) +and localized content, tracking which translations are current vs outdated. + +Features: +- Tracks source file hashes to detect changes +- Falls back to English for outdated/missing translations +- Provides status reports on translation coverage + +Usage: + python sync-localized-content.py --status # Show all languages + python sync-localized-content.py --status es # Show Spanish details + python sync-localized-content.py --sync es # Sync Spanish content + python sync-localized-content.py --init es # Initialize tracking + python sync-localized-content.py --mark-translated es # Mark all as translated + python sync-localized-content.py --json # JSON output for CI +""" + +import argparse +import json +import os +import shutil +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from config_loader import ( + compute_file_hash, + get_content_directories, + get_shared_directories, + get_root_files, + get_default_language, +) + + +# Status values +STATUS_TRANSLATED = "translated" +STATUS_OUTDATED = "outdated" +STATUS_UNTRANSLATED = "untranslated" + +# Paths +CONTENT_DIR = Path("content") +LOCALIZED_DIR = Path("localizedContent") +STATUS_FILENAME = ".translation-status.json" + + +def load_translation_status(lang: str) -> dict[str, Any]: + """Load the translation status file for a language.""" + status_path = LOCALIZED_DIR / lang / STATUS_FILENAME + + if not status_path.exists(): + return { + "language": lang, + "lastSync": None, + "sourceBaseline": str(CONTENT_DIR), + "files": {}, + "summary": { + "translated": 0, + "outdated": 0, + "untranslated": 0, + "total": 0, + "completionPercent": 0 + } + } + + with open(status_path, encoding="utf-8") as f: + return json.load(f) + + +def save_translation_status(lang: str, status: dict[str, Any]) -> None: + """Save the translation status file for a language.""" + status_path = LOCALIZED_DIR / lang / STATUS_FILENAME + status_path.parent.mkdir(parents=True, exist_ok=True) + + # Update timestamp + status["lastSync"] = datetime.now(timezone.utc).isoformat() + + # Recalculate summary + files = status.get("files", {}) + translated = sum(1 for f in files.values() if f.get("status") == STATUS_TRANSLATED) + outdated = sum(1 for f in files.values() if f.get("status") == STATUS_OUTDATED) + untranslated = sum(1 for f in files.values() if f.get("status") == STATUS_UNTRANSLATED) + total = len(files) + + status["summary"] = { + "translated": translated, + "outdated": outdated, + "untranslated": untranslated, + "total": total, + "completionPercent": round(translated / total * 100, 1) if total > 0 else 0 + } + + with open(status_path, "w", encoding="utf-8") as f: + json.dump(status, f, indent=2, ensure_ascii=False) + + +def get_source_files() -> dict[str, str]: + """Get all source content files with their hashes. + + Returns dict mapping relative path to hash. + """ + files = {} + + # Content directories + for dir_name in get_content_directories(): + dir_path = CONTENT_DIR / dir_name + if dir_path.exists(): + for file_path in dir_path.rglob("*"): + if file_path.is_file() and file_path.suffix in [".md", ".yml", ".yaml", ".json", ".html"]: + rel_path = str(file_path.relative_to(CONTENT_DIR)).replace("\\", "/") + files[rel_path] = compute_file_hash(file_path) + + # Root files + for file_name in get_root_files(): + file_path = CONTENT_DIR / file_name + if file_path.exists(): + files[file_name] = compute_file_hash(file_path) + + return files + + +def get_available_languages() -> list[str]: + """Get list of available language codes from localizedContent/.""" + if not LOCALIZED_DIR.exists(): + return [] + + languages = [] + for item in LOCALIZED_DIR.iterdir(): + if item.is_dir() and len(item.name) <= 5 and item.name != "en": + languages.append(item.name) + + return sorted(languages) + + +def check_translation_status(lang: str, source_files: dict[str, str]) -> dict[str, Any]: + """Check translation status for a language against source files. + + Returns updated status dict with current state of each file. + """ + status = load_translation_status(lang) + localized_content_dir = LOCALIZED_DIR / lang / "content" + + new_files = {} + + for rel_path, source_hash in source_files.items(): + localized_file = localized_content_dir / rel_path + file_info = status.get("files", {}).get(rel_path, {}) + + if not localized_file.exists(): + # No translation exists + new_files[rel_path] = { + "sourceHash": source_hash, + "status": STATUS_UNTRANSLATED, + "lastChecked": datetime.now(timezone.utc).isoformat() + } + else: + stored_hash = file_info.get("sourceHash", "") + + if stored_hash == source_hash: + # Source hasn't changed, translation is current + new_files[rel_path] = { + "sourceHash": source_hash, + "status": STATUS_TRANSLATED, + "lastChecked": datetime.now(timezone.utc).isoformat(), + "translatedAt": file_info.get("translatedAt", datetime.now(timezone.utc).isoformat()) + } + else: + # Source changed since translation was made + new_files[rel_path] = { + "sourceHash": source_hash, + "status": STATUS_OUTDATED, + "previousHash": stored_hash, + "lastChecked": datetime.now(timezone.utc).isoformat(), + "translatedAt": file_info.get("translatedAt") + } + + status["files"] = new_files + return status + + +def sync_language(lang: str, source_files: dict[str, str], dry_run: bool = False) -> dict[str, int]: + """Sync content for a language, falling back to English for outdated/missing. + + Returns dict with counts of actions taken. + """ + status = check_translation_status(lang, source_files) + localized_content_dir = LOCALIZED_DIR / lang / "content" + + counts = {"copied": 0, "replaced": 0, "kept": 0} + + for rel_path, file_info in status["files"].items(): + source_file = CONTENT_DIR / rel_path + dest_file = localized_content_dir / rel_path + file_status = file_info.get("status") + + if file_status == STATUS_TRANSLATED: + # Keep existing translation + counts["kept"] += 1 + elif file_status == STATUS_OUTDATED: + # Replace with English (fallback) + if not dry_run: + dest_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_file, dest_file) + # Update status to untranslated (since we replaced with English) + file_info["status"] = STATUS_UNTRANSLATED + file_info["replacedAt"] = datetime.now(timezone.utc).isoformat() + counts["replaced"] += 1 + print(f" Replaced (outdated): {rel_path}") + elif file_status == STATUS_UNTRANSLATED: + # Copy English as fallback + if not dry_run: + dest_file.parent.mkdir(parents=True, exist_ok=True) + if not dest_file.exists(): + shutil.copy2(source_file, dest_file) + counts["copied"] += 1 + print(f" Copied (new): {rel_path}") + else: + counts["kept"] += 1 + else: + if not dest_file.exists(): + counts["copied"] += 1 + else: + counts["kept"] += 1 + + # Copy shared directories (assets, api) - always overwrite + for dir_name in get_shared_directories(): + src = CONTENT_DIR / dir_name + dest = localized_content_dir / dir_name + if src.exists() and not dry_run: + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest) + print(f" Synced shared: {dir_name}/") + + if not dry_run: + save_translation_status(lang, status) + + return counts + + +def sync_english(dry_run: bool = False) -> dict[str, int]: + """Sync English content (copy all from source). + + Note: This preserves any existing files in the destination that don't exist + in the source (e.g., redirect stub files generated by gen_redirects.py). + """ + en_content_dir = LOCALIZED_DIR / "en" / "content" + counts = {"copied": 0, "updated": 0} + + if not dry_run: + en_content_dir.mkdir(parents=True, exist_ok=True) + + # Copy content directories (overwrite existing, preserve unrelated) + for dir_name in get_content_directories(): + src = CONTENT_DIR / dir_name + dest = en_content_dir / dir_name + if src.exists(): + if not dry_run: + # Remove destination dir if exists (to get clean copy of this dir) + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest) + file_count = sum(1 for _ in src.rglob("*") if _.is_file()) + counts["copied"] += file_count + + # Copy shared directories (overwrite) + for dir_name in get_shared_directories(): + src = CONTENT_DIR / dir_name + dest = en_content_dir / dir_name + if src.exists() and not dry_run: + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest) + print(f" Synced shared: {dir_name}/") + + # Copy root files (overwrite) + for file_name in get_root_files(): + src = CONTENT_DIR / file_name + if src.exists(): + if not dry_run: + shutil.copy2(src, en_content_dir / file_name) + counts["copied"] += 1 + + return counts + + +def init_language(lang: str, source_files: dict[str, str]) -> None: + """Initialize translation tracking for an existing language. + + Marks all existing translations as 'translated' with current source hash. + """ + localized_content_dir = LOCALIZED_DIR / lang / "content" + + if not localized_content_dir.exists(): + print(f"Error: No content found for '{lang}' at {localized_content_dir}") + return + + status = { + "language": lang, + "lastSync": datetime.now(timezone.utc).isoformat(), + "sourceBaseline": str(CONTENT_DIR), + "files": {} + } + + for rel_path, source_hash in source_files.items(): + localized_file = localized_content_dir / rel_path + + if localized_file.exists(): + # File exists - mark as translated with current hash + status["files"][rel_path] = { + "sourceHash": source_hash, + "status": STATUS_TRANSLATED, + "lastChecked": datetime.now(timezone.utc).isoformat(), + "translatedAt": datetime.now(timezone.utc).isoformat() + } + else: + # File missing - mark as untranslated + status["files"][rel_path] = { + "sourceHash": source_hash, + "status": STATUS_UNTRANSLATED, + "lastChecked": datetime.now(timezone.utc).isoformat() + } + + save_translation_status(lang, status) + + summary = status["summary"] + print(f"Initialized tracking for '{lang}':") + print(f" Translated: {summary['translated']}") + print(f" Untranslated: {summary['untranslated']}") + + +def mark_translated(lang: str, file_paths: list[str] | None, source_files: dict[str, str]) -> None: + """Mark files as translated with current source hash. + + If file_paths is None, marks all files in the language. + """ + status = load_translation_status(lang) + now = datetime.now(timezone.utc).isoformat() + + if file_paths is None: + # Mark all files + file_paths = list(source_files.keys()) + + updated = 0 + for rel_path in file_paths: + if rel_path in source_files: + status["files"][rel_path] = { + "sourceHash": source_files[rel_path], + "status": STATUS_TRANSLATED, + "lastChecked": now, + "translatedAt": now + } + updated += 1 + + save_translation_status(lang, status) + print(f"Marked {updated} file(s) as translated for '{lang}'") + + +def print_status_summary(json_output: bool = False) -> None: + """Print status summary for all languages.""" + languages = get_available_languages() + source_files = get_source_files() + + results = [] + + for lang in languages: + status = check_translation_status(lang, source_files) + summary = status.get("summary", {}) + results.append({ + "language": lang, + "translated": summary.get("translated", 0), + "outdated": summary.get("outdated", 0), + "untranslated": summary.get("untranslated", 0), + "total": summary.get("total", 0), + "completionPercent": summary.get("completionPercent", 0) + }) + + if json_output: + print(json.dumps({"languages": results}, indent=2)) + return + + if not results: + print("No languages found in localizedContent/") + return + + print("\nTranslation Status Report") + print("=" * 60) + print() + print(f"{'Language':<10} {'Translated':<12} {'Outdated':<10} {'Untranslated':<14} {'Completion':<10}") + print("-" * 60) + + for r in results: + print(f"{r['language']:<10} {r['translated']:<12} {r['outdated']:<10} {r['untranslated']:<14} {r['completionPercent']:.1f}%") + + print() + print("Run with --status for details.") + + +def print_language_status(lang: str, source_files: dict[str, str], json_output: bool = False) -> None: + """Print detailed status for a specific language.""" + status = check_translation_status(lang, source_files) + files = status.get("files", {}) + summary = status.get("summary", {}) + + if json_output: + print(json.dumps(status, indent=2)) + return + + print(f"\nTranslation Status: {lang}") + print("=" * 60) + print() + print(f"Summary: {summary['translated']}/{summary['total']} translated " + f"({summary['completionPercent']:.1f}%), " + f"{summary['outdated']} outdated, " + f"{summary['untranslated']} untranslated") + print() + + # Group by status + outdated = [(p, f) for p, f in files.items() if f.get("status") == STATUS_OUTDATED] + untranslated = [(p, f) for p, f in files.items() if f.get("status") == STATUS_UNTRANSLATED] + + if outdated: + print(f"OUTDATED ({len(outdated)} files - will use English fallback):") + for path, info in sorted(outdated)[:20]: # Limit output + print(f" - {path}") + if len(outdated) > 20: + print(f" ... and {len(outdated) - 20} more") + print() + + if untranslated: + print(f"UNTRANSLATED ({len(untranslated)} files - using English):") + for path, info in sorted(untranslated)[:20]: + print(f" - {path}") + if len(untranslated) > 20: + print(f" ... and {len(untranslated) - 20} more") + print() + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Sync localized content with translation tracking" + ) + parser.add_argument( + "--status", "-s", + nargs="?", + const="__all__", + metavar="LANG", + help="Show translation status (optionally for specific language)" + ) + parser.add_argument( + "--sync", + metavar="LANG", + help="Sync content for a language (use 'en' for English)" + ) + parser.add_argument( + "--init", + metavar="LANG", + help="Initialize tracking for existing translations" + ) + parser.add_argument( + "--mark-translated", + metavar="LANG", + help="Mark files as translated (all files if no --files specified)" + ) + parser.add_argument( + "--files", + nargs="+", + help="Specific files to mark as translated" + ) + parser.add_argument( + "--json", + action="store_true", + help="Output in JSON format" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be done without making changes" + ) + + args = parser.parse_args() + + # Get source files (needed for most operations) + source_files = get_source_files() + + if args.status: + if args.status == "__all__": + print_status_summary(args.json) + else: + print_language_status(args.status, source_files, args.json) + return 0 + + if args.sync: + lang = args.sync + print(f"Syncing content for '{lang}'...") + + if lang == "en": + counts = sync_english(args.dry_run) + print(f"\nEnglish sync complete: {counts['copied']} files copied") + else: + counts = sync_language(lang, source_files, args.dry_run) + print(f"\nSync complete for '{lang}':") + print(f" Kept (translated): {counts['kept']}") + print(f" Replaced (outdated->English): {counts['replaced']}") + print(f" Copied (new->English): {counts['copied']}") + return 0 + + if args.init: + init_language(args.init, source_files) + return 0 + + if args.mark_translated: + mark_translated(args.mark_translated, args.files, source_files) + return 0 + + # Default: show help + parser.print_help() + return 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nInterrupted.") + sys.exit(1) + except Exception as e: + print(f"Error: {e}") + sys.exit(1) diff --git a/content/404.html b/content/404.html index 4d896d4f..9d9a6789 100644 --- a/content/404.html +++ b/content/404.html @@ -1,11 +1,15 @@ + + Page Not Found - Tabular Editor Documentation
-
-
-
-
-

404

+
+
+

404

+
-
+
+

Borp has no idea what this is!

+

The page you are looking for could not be found.

-
-

- Borp has no idea what this is! -

+
+ Language not available: + is not currently supported. +

+ View in English +
-

The page you are looking for could not be found.

+ - Home -
+
+ + diff --git a/content/_ui-strings.json b/content/_ui-strings.json new file mode 100644 index 00000000..608b2f7e --- /dev/null +++ b/content/_ui-strings.json @@ -0,0 +1,24 @@ +{ + "aiTranslationWarning": "This content has been translated by AI and has not been reviewed by humans.", + "header.nav.pricing": "Pricing", + "header.nav.download": "Download", + "header.nav.learn": "Learn", + "header.nav.resources": "Resources", + "header.nav.blog": "Blog", + "header.nav.newsletter": "Newsletter", + "header.nav.publications": "Publications", + "header.nav.documentation": "Documentation", + "header.nav.supportCommunity": "Support community", + "header.nav.contactUs": "Contact Us", + "header.button1": "Free trial", + "header.button2": "Main page", + "footer.heading": "Ready to get started?", + "footer.button1": "Try Tabular Editor 3 for free", + "footer.button2": "Buy Tabular Editor 3", + "footer.aboutUs": "About us", + "footer.contactUs": "Contact us", + "footer.technicalSupport": "Technical Support", + "footer.privacyPolicy": "Privacy & Cookie policy", + "footer.termsConditions": "Terms & Conditions", + "footer.licenseTerms": "License terms" +} diff --git a/content/index.html b/content/index.html new file mode 100644 index 00000000..81772853 --- /dev/null +++ b/content/index.html @@ -0,0 +1,12 @@ + + + + + + + Redirecting... + + +

Redirecting to English documentation...

+ + diff --git a/content/toc.yml b/content/toc.yml index 8b3cef92..b6b79185 100644 --- a/content/toc.yml +++ b/content/toc.yml @@ -14,7 +14,7 @@ homepage: tutorials/index.md - name: References href: references/ - homepage: references/index.md + homepage: references/index.md - name: Troubleshooting href: troubleshooting/ homepage: troubleshooting/index.md @@ -23,4 +23,5 @@ homepage: security/index.md - name: API href: api/ - homepage: api/index.md \ No newline at end of file + homepage: api/index.md + \ No newline at end of file diff --git a/crowdin.yml b/crowdin.yml index 7fbcd494..9683dbb3 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,7 +1,7 @@ "files": [ { "source": "/content/**/*.md", - "translation": "/localizedContent/%two_letters_code%/%original_path%/%original_file_name%", + "translation": "/localizedContent/%two_letters_code%/content/%original_path%/%original_file_name%", "ignore": [ "/**/TOC.md" ], @@ -11,10 +11,14 @@ }, { "source": "/content/404.html", - "translation": "/localizedContent/%two_letters_code%/404.html" + "translation": "/localizedContent/%two_letters_code%/content/404.html" }, { "source": "/content/toc.yml", - "translation": "/localizedContent/%two_letters_code%/toc.yml" + "translation": "/localizedContent/%two_letters_code%/content/toc.yml" + }, + { + "source": "/content/_ui-strings.json", + "translation": "/localizedContent/%two_letters_code%/content/_ui-strings.json" } ] diff --git a/docfx-template.json b/docfx-template.json index 40305956..7158659a 100644 --- a/docfx-template.json +++ b/docfx-template.json @@ -37,10 +37,12 @@ "resource": [ { "files": "**", "src": "content/assets/images", "dest": "images" }, { "files": "**", "src": "content/whats-new", "dest": "whats-new" }, - { "files": "*.html", "src": "content" } + { "files": "*.html", "src": "content" }, + { "files": "_ui-strings.json", "src": "content" } ], "globalMetadata": { "_appTitle": "Tabular Editor Documentation", + "_baseUrl": "https://docs.tabulareditor.com", "_enableSearch": true, "_enableNewTab": true, "_disableNavbar": true, diff --git a/gen_redirects.py b/gen_redirects.py deleted file mode 100644 index 8aa16dc6..00000000 --- a/gen_redirects.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import json -import os -import posixpath -import sys -import traceback - -def main(args: list[str]) -> int: - config_input_path = "docfx-template.json" - config_output_path = "docfx.json" - redirects_path = "redirects.json" - - with open(config_input_path) as f: - config = json.load(f) - - with open(redirects_path) as f: - data : dict[str, str] = json.load(f) - - dirs = dict[str, list[str]]() - for key, value in data.items(): - dir = posixpath.dirname(key) - ext = posixpath.splitext(key)[1] - if dir in dirs: - dirs[dir].append(key) - else: - dirs[dir] = [key] - os.makedirs(dir, exist_ok=True) - if ext == ".md": - content: list[Any] = config["build"]["content"] - content.append({"files": posixpath.relpath(key, "content"), "src": "content"}) - with open(key, mode="w", encoding="utf-8") as f: - f.write(f"""--- -redirect_url: {value} ---- -""") - elif ext == ".html": - resource: list[Any] = config["build"]["resource"] - resource.append({"files": posixpath.relpath(key, "content"), "src": "content"}) - with open(key, mode="w", encoding="utf-8") as f: - f.write(f""" - - - - - - - -""") - else: - print("Unknown file type:", key, file=sys.stderr) - - for dir, files in dirs.items(): - with open(posixpath.join(dir, ".gitignore"), "a") as f: - f.write("\n") - f.writelines("/" + posixpath.basename(file) + "\n" for file in files) - f.write("/.gitignore\n") - - with open(config_output_path, "w") as f: - json.dump(config, f, indent=4) - - return 0 - - -if __name__ == "__main__": - import sys - try: - sys.exit(main(sys.argv[1:])) - except Exception as ex: - traceback.print_exception(ex, file=sys.stderr) - sys.exit(1) diff --git a/localizedContent/es/toc.yml b/localizedContent/es/content/toc.yml similarity index 100% rename from localizedContent/es/toc.yml rename to localizedContent/es/content/toc.yml diff --git a/localizedContent/zh/toc.yml b/localizedContent/zh/content/toc.yml similarity index 100% rename from localizedContent/zh/toc.yml rename to localizedContent/zh/content/toc.yml diff --git a/metadata/build-config.json b/metadata/build-config.json new file mode 100644 index 00000000..aea98afd --- /dev/null +++ b/metadata/build-config.json @@ -0,0 +1,34 @@ +{ + "_comment": "Shared build configuration for all build scripts. Edit this file to add/remove content directories.", + "defaultLanguage": "en", + "contentDirectories": { + "_comment": "Directories that contain translatable content (markdown and HTML files)", + "directories": [ + "features", + "getting-started", + "how-tos", + "references", + "kb", + "security", + "troubleshooting", + "tutorials", + "whats-new" + ] + }, + "sharedDirectories": { + "_comment": "Directories that are shared across languages (assets, API docs). Not translated.", + "directories": [ + "assets", + "api" + ] + }, + "rootFiles": { + "_comment": "Root-level files that should be copied/localized", + "files": [ + "index.md", + "toc.yml", + "404.html", + "_ui-strings.json" + ] + } +} \ No newline at end of file diff --git a/metadata/language-metadata.json b/metadata/language-metadata.json new file mode 100644 index 00000000..51821cb0 --- /dev/null +++ b/metadata/language-metadata.json @@ -0,0 +1,33 @@ +{ + "_comment": "Language display names and metadata. Add entries here for new languages.", + "defaultLanguage": "en", + "languages": { + "en": { "name": "English", "nativeName": "English" }, + "es": { "name": "Spanish", "nativeName": "Español" }, + "zh": { "name": "Chinese (Simplified)", "nativeName": "简体中文" }, + "zh-tw": { "name": "Chinese (Traditional)", "nativeName": "繁體中文" }, + "ja": { "name": "Japanese", "nativeName": "日本語" }, + "ko": { "name": "Korean", "nativeName": "한국어" }, + "de": { "name": "German", "nativeName": "Deutsch" }, + "fr": { "name": "French", "nativeName": "Français" }, + "it": { "name": "Italian", "nativeName": "Italiano" }, + "pt": { "name": "Portuguese", "nativeName": "Português" }, + "pt-br": { "name": "Portuguese (Brazil)", "nativeName": "Português (Brasil)" }, + "ru": { "name": "Russian", "nativeName": "Русский" }, + "ar": { "name": "Arabic", "nativeName": "العربية", "rtl": true }, + "he": { "name": "Hebrew", "nativeName": "עברית", "rtl": true }, + "nl": { "name": "Dutch", "nativeName": "Nederlands" }, + "pl": { "name": "Polish", "nativeName": "Polski" }, + "sv": { "name": "Swedish", "nativeName": "Svenska" }, + "da": { "name": "Danish", "nativeName": "Dansk" }, + "no": { "name": "Norwegian", "nativeName": "Norsk" }, + "fi": { "name": "Finnish", "nativeName": "Suomi" }, + "cs": { "name": "Czech", "nativeName": "Čeština" }, + "tr": { "name": "Turkish", "nativeName": "Türkçe" }, + "th": { "name": "Thai", "nativeName": "ไทย" }, + "vi": { "name": "Vietnamese", "nativeName": "Tiếng Việt" }, + "id": { "name": "Indonesian", "nativeName": "Bahasa Indonesia" }, + "ms": { "name": "Malay", "nativeName": "Bahasa Melayu" }, + "hi": { "name": "Hindi", "nativeName": "हिन्दी" } + } +} diff --git a/metadata/redirects.json b/metadata/redirects.json new file mode 100644 index 00000000..b129e7cb --- /dev/null +++ b/metadata/redirects.json @@ -0,0 +1,265 @@ +{ + "_comment": "All URL redirects. serverRedirects are 301s via Azure SWA. clientRedirects generate meta-refresh HTML files.", + "serverRedirects": { + "_comment": "Short URLs that redirect to full paths. Add new shortcuts here.", + "redirects": { + "/tmdl": "/en/features/tmdl.html", + "/roslyn": "/en/how-tos/Advanced-Scripting.html#compiling-with-roslyn", + "/eula": "/en/security/te3-eula.html", + "/tmuo": "/en/references/user-options.html", + "/workspace": "/en/tutorials/workspace-mode.html", + "/privacy-policy.html": "/en/security/privacy-policy.html", + "/user-options.html": "/en/references/user-options.html", + "/Advanced-Scripting.html": "/en/how-tos/Advanced-Scripting.html", + "/Best-Practice-Analyzer.html": "/en/features/Best-Practice-Analyzer.html", + "/Importing-Tables.html": "/en/how-tos/Importing-Tables.html", + "/Workspace-Database.html": "/en/tutorials/workspace-mode.html", + "/Useful-script-snippets.html": "/en/features/Useful-script-snippets.html", + "/Command-line-Options.html": "/en/features/Command-line-Options.html", + "/Power-BI-Desktop-Integration.html": "/en/getting-started/Power-BI-Desktop-Integration.html", + "/Custom-Actions.html": "/en/tutorials/creating-macros.html", + "/FormatDax.html": "/en/references/FormatDax.html", + "/common/Datasets/direct-lake-dataset.html": "/en/features/Semantic-Model/direct-lake-sql-model.html", + "/other/downloads.html": "/en/references/downloads.html", + "/te3/downloads.html": "/en/references/downloads.html", + "/te3/logo.svg": "/en/logo.svg", + "/projects/te3/en/latest": "/en/", + "/projects/te3": "/en/", + "/projects/te3/en/latest/editions.html": "/en/getting-started/editions.html", + "/projects/te3/en/latest/security-privacy.html": "/en/security/security-privacy.html", + "/projects/te3/en/latest/downloads.html": "/en/references/downloads.html", + "/projects/te3/en/latest/getting-started.html": "/en/getting-started/getting-started.html", + "_comment_high_traffic": "High-traffic legacy pages (direct 301 for best SEO)", + "/te2/Getting-Started.html": "/en/getting-started/Getting-Started-te2.html", + "/te2/Advanced-Scripting.html": "/en/how-tos/Advanced-Scripting.html", + "/te2/Best-Practice-Analyzer.html": "/en/features/Best-Practice-Analyzer.html", + "/te2/Power-BI-Desktop-Integration.html": "/en/getting-started/Power-BI-Desktop-Integration.html", + "/te2/Importing-Tables.html": "/en/how-tos/Importing-Tables.html", + "/te2/Useful-script-snippets.html": "/en/features/Useful-script-snippets.html", + "/te3/getting-started.html": "/en/getting-started/getting-started.html", + "/te3/editions.html": "/en/getting-started/editions.html", + "/te3/index.html": "/en/troubleshooting/licensing-activation.html", + "/te3/features/dax-editor.html": "/en/features/dax-editor.html", + "/te3/features/dax-scripts.html": "/en/features/dax-scripts.html", + "/te3/features/dax-debugger.html": "/en/features/dax-debugger.html", + "/te3/features/csharp-scripts.html": "/en/features/csharp-scripts.html", + "/te3/features/tmdl.html": "/en/features/tmdl.html", + "/te3/other/downloads.html": "/en/references/downloads.html", + "/te3/other/release-history.html": "/en/references/release-history.html", + "/te3/tutorials/workspace-mode.html": "/en/tutorials/workspace-mode.html", + "/onboarding/index.html": "/en/getting-started/index.html", + "/onboarding/installation.html": "/en/getting-started/installation.html", + "/onboarding/general-introduction.html": "/en/getting-started/general-introduction.html" + } + }, + "clientRedirects": { + "_comment": "Legacy content URLs that need meta-refresh HTML redirects. Keys are old paths (without content/ prefix), values are new paths. These are handled by gen_redirects.py to create redirect HTML files.", + "redirects": { + "/common/CSharpScripts/Advanced/script-add-databricks-metadata-descriptions.html": "/en/features/CSharpScripts/Advanced/script-add-databricks-metadata-descriptions.html", + "/common/CSharpScripts/Advanced/script-convert-dlsql-to-dlol.html": "/en/features/CSharpScripts/Advanced/script-convert-dlsql-to-dlol.html", + "/common/CSharpScripts/Advanced/script-convert-import-to-dlol.html": "/en/features/CSharpScripts/Advanced/script-convert-import-to-dlol.html", + "/common/CSharpScripts/Advanced/script-count-things.html": "/en/features/CSharpScripts/Advanced/script-count-things.html", + "/common/CSharpScripts/Advanced/script-create-and-replace-M-parameter.html": "/en/features/CSharpScripts/Advanced/script-create-and-replace-M-parameter.html", + "/common/CSharpScripts/Advanced/script-create-databricks-relationships.html": "/en/features/CSharpScripts/Advanced/script-create-databricks-relationships.html", + "/common/CSharpScripts/Advanced/script-create-date-table.html": "/en/features/CSharpScripts/Advanced/script-create-date-table.html", + "/common/CSharpScripts/Advanced/script-databricks-semantic-model-set-up.html": "/en/features/CSharpScripts/Advanced/script-databricks-semantic-model-set-up.html", + "/common/CSharpScripts/Advanced/script-find-replace-selected-measures.html": "/en/features/CSharpScripts/Advanced/script-find-replace-selected-measures.html", + "/common/CSharpScripts/Advanced/script-format-power-query.html": "/en/features/CSharpScripts/Advanced/script-format-power-query.html", + "/common/CSharpScripts/Advanced/script-implement-incremental-refresh.html": "/en/features/CSharpScripts/Advanced/script-implement-incremental-refresh.html", + "/common/CSharpScripts/Advanced/script-output-things.html": "/en/features/CSharpScripts/Advanced/script-output-things.html", + "/common/CSharpScripts/Advanced/script-remove-measures-with-error.html": "/en/features/CSharpScripts/Advanced/script-remove-measures-with-error.html", + "/common/CSharpScripts/Beginner/script-count-rows.html": "/en/features/CSharpScripts/Beginner/script-count-rows.html", + "/common/CSharpScripts/Beginner/script-create-field-parameter.html": "/en/features/CSharpScripts/Beginner/script-create-field-parameter.html", + "/common/CSharpScripts/Beginner/script-create-m-parameter.html": "/en/features/CSharpScripts/Beginner/script-create-m-parameter.html", + "/common/CSharpScripts/Beginner/script-create-measure-table.html": "/en/features/CSharpScripts/Beginner/script-create-measure-table.html", + "/common/CSharpScripts/Beginner/script-create-sum-measures-from-columns.html": "/en/features/CSharpScripts/Beginner/script-create-sum-measures-from-columns.html", + "/common/CSharpScripts/Beginner/script-create-table-groups.html": "/en/features/CSharpScripts/Beginner/script-create-table-groups.html", + "/common/CSharpScripts/Beginner/script-display-unique-column-values.html": "/en/features/CSharpScripts/Beginner/script-display-unique-column-values.html", + "/common/CSharpScripts/Beginner/script-edit-hidden-partitions.html": "/en/features/CSharpScripts/Beginner/script-edit-hidden-partitions.html", + "/common/CSharpScripts/Beginner/script-format-numeric-measures.html": "/en/features/CSharpScripts/Beginner/script-format-numeric-measures.html", + "/common/CSharpScripts/Beginner/script-show-data-source-dependencies.html": "/en/features/CSharpScripts/Beginner/script-show-data-source-dependencies.html", + "/common/CSharpScripts/Template/csharp-script-Template.html": "/en/features/CSharpScripts/Template/csharp-script-Template.html", + "/common/CSharpScripts/csharp-script-library-advanced.html": "/en/features/CSharpScripts/csharp-script-library-advanced.html", + "/common/CSharpScripts/csharp-script-library-beginner.html": "/en/features/CSharpScripts/csharp-script-library-beginner.html", + "/common/CSharpScripts/csharp-script-library.html": "/en/features/CSharpScripts/csharp-script-library.html", + "/common/Semantic Model/direct-lake-sql-model.html": "/en/features/Semantic-Model/direct-lake-sql-model.html", + "/common/Semantic Model/direct-query-over-as.html": "/en/features/Semantic-Model/direct-query-over-as.html", + "/common/Semantic Model/semantic-model-types.html": "/en/features/Semantic-Model/semantic-model-types.html", + "/common/common-features.html": "/en/getting-started/Getting-Started-te2.html", + "/common/desktop-limitations.html": "/en/getting-started/desktop-limitations.html", + "/common/policies.html": "/en/references/policies.html", + "/common/save-to-folder.html": "/en/features/save-to-folder.html", + "/common/script-helper-methods.html": "/en/features/script-helper-methods.html", + "/common/tmdl-common.html": "/en/features/tmdl.html", + "/common/toc.html": "/en/index.html", + "/common/using-bpa-sample-rules-expressions.html": "/en/features/using-bpa-sample-rules-expressions.html", + "/common/using-bpa.html": "/en/features/using-bpa.html", + "/common/xmla-as-connectivity.html": "/en/how-tos/xmla-as-connectivity.html", + "/onboarding/as-cicd.html": "/en/getting-started/as-cicd.html", + "/onboarding/boosting-productivity-te3.html": "/en/getting-started/boosting-productivity-te3.html", + "/onboarding/bpa.html": "/en/getting-started/bpa.html", + "/onboarding/creating-and-testing-dax.html": "/en/getting-started/creating-and-testing-dax.html", + "/onboarding/cs-scripts-and-macros.html": "/en/getting-started/cs-scripts-and-macros.html", + "/onboarding/dax-script-introduction.html": "/en/getting-started/dax-script-introduction.html", + "/onboarding/importing-tables-data-modeling.html": "/en/getting-started/importing-tables-data-modeling.html", + "/onboarding/migrate-from-desktop.html": "/en/getting-started/migrate-from-desktop.html", + "/onboarding/migrate-from-te2.html": "/en/getting-started/migrate-from-te2.html", + "/onboarding/migrate-from-vs.html": "/en/getting-started/migrate-from-vs.html", + "/onboarding/optimizing-workflow-workspace-mode.html": "/en/getting-started/optimizing-workflow-workspace-mode.html", + "/onboarding/parallel-development.html": "/en/getting-started/parallel-development.html", + "/onboarding/personalizing-te3.html": "/en/getting-started/personalizing-te3.html", + "/onboarding/powerbi-cicd.html": "/en/getting-started/powerbi-cicd.html", + "/onboarding/refresh-preview-query.html": "/en/getting-started/refresh-preview-query.html", + "/onboarding/toc.html": "/en/getting-started/toc.html", + "/te2/Advanced-Filtering-of-the-Explorer-Tree.html": "/en/how-tos/Advanced-Filtering-of-the-Explorer-Tree.html", + "/te2/Advanced-features.html": "/en/getting-started/Getting-Started-te2.html", + "/te2/Best-Practice-Analyzer-Improvements.html": "/en/features/Best-Practice-Analyzer.html", + "/te2/Command-line-Options.html": "/en/features/Command-line-Options.html", + "/te2/Custom-Actions.html": "/en/features/Custom-Actions-hidden.html", + "/te2/FAQ.html": "/en/references/FAQ.html", + "/te2/Features-at-a-glance.html": "/en/getting-started/Getting-Started-te2.html", + "/te2/FormatDax.html": "/en/references/FormatDax.html", + "/te2/Keyboard-Shortcuts.html": "/en/references/Keyboard-Shortcuts2.html", + "/te2/Master-model-pattern.html": "/en/how-tos/Master-model-pattern.html", + "/te2/Roadmap.html": "/en/references/Roadmap2-h.html", + "/te2/SQL-Server-2017-support.html": "/en/references/SQL-Server-2017-support-h.html", + "/te2/TabularEditor.TOMWrapper.html": "/en/references/TabularEditor.TOMWrapper-h.html", + "/te2/Training-Webinar-for-Tabular-Editor.html": "/en/getting-started/Training-Webinar-for-Tabular-Editor.html", + "/te2/Workspace-Database.html": "/en/features/Workspace-Database.html", + "/te2/gdpr-delete.html": "/en/security/gdpr-delete.html", + "/te2/importing-tables-from-excel.html": "/en/how-tos/importing-tables-from-excel.html", + "/te2/incremental-refresh.html": "/en/how-tos/incremental-refresh2-h.html", + "/te2/privacy-policy.html": "/en/security/privacy-policy.html", + "/te2/toc.html": "/en/getting-started/Getting-Started-te2.html", + "/te3/azure-marketplace.html": "/en/getting-started/azure-marketplace.html", + "/te3/desktop-limitations.html": "/en/getting-started/desktop-limitations.html", + "/te3/features/code-actions.html": "/en/features/code-actions.html", + "/te3/features/dax-optimizer-integration.html": "/en/features/dax-optimizer-integration.html", + "/te3/features/dax-package-manager.html": "/en/features/dax-package-manager.html", + "/te3/features/dax-query.html": "/en/features/dax-query.html", + "/te3/features/deployment.html": "/en/features/deployment.html", + "/te3/features/diagram-view.html": "/en/features/views/diagram-view.html", + "/te3/features/metadata-translation-editor.html": "/en/features/metadata-translation-editor.html", + "/te3/features/perspective-editor.html": "/en/features/perspective-editor.html", + "/te3/features/pivot-grid.html": "/en/features/pivot-grid.html", + "/te3/features/preferences.html": "/en/references/preferences.html", + "/te3/features/security-privacy.html": "/en/security/security-privacy.html", + "/te3/features/shortcuts.html": "/en/references/shortcuts3.html", + "/te3/features/supported-files.html": "/en/references/supported-files.html", + "/te3/features/table-groups.html": "/en/features/table-groups.html", + "/te3/features/user-options.html": "/en/references/user-options.html", + "/te3/import-tables.partial.html": "/en/features/import-tables.partial.html", + "/te3/other/privacy-policy.html": "/en/security/privacy-policy.html", + "/te3/other/release-notes/3_0_1.html": "/en/references/release-notes/3_0_1.html", + "/te3/other/release-notes/3_0_10.html": "/en/references/release-notes/3_0_10.html", + "/te3/other/release-notes/3_0_2.html": "/en/references/release-notes/3_0_2.html", + "/te3/other/release-notes/3_0_3.html": "/en/references/release-notes/3_0_3.html", + "/te3/other/release-notes/3_0_4.html": "/en/references/release-notes/3_0_4.html", + "/te3/other/release-notes/3_0_5.html": "/en/references/release-notes/3_0_5.html", + "/te3/other/release-notes/3_0_6.html": "/en/references/release-notes/3_0_6.html", + "/te3/other/release-notes/3_0_7.html": "/en/references/release-notes/3_0_7.html", + "/te3/other/release-notes/3_0_8.html": "/en/references/release-notes/3_0_8.html", + "/te3/other/release-notes/3_0_9.html": "/en/references/release-notes/3_0_9.html", + "/te3/other/release-notes/3_10_0.html": "/en/references/release-notes/3_10_0.html", + "/te3/other/release-notes/3_10_1.html": "/en/references/release-notes/3_10_1.html", + "/te3/other/release-notes/3_11_0.html": "/en/references/release-notes/3_11_0.html", + "/te3/other/release-notes/3_12_0.html": "/en/references/release-notes/3_12_0.html", + "/te3/other/release-notes/3_12_1.html": "/en/references/release-notes/3_12_1.html", + "/te3/other/release-notes/3_13_0.html": "/en/references/release-notes/3_13_0.html", + "/te3/other/release-notes/3_14_0.html": "/en/references/release-notes/3_14_0.html", + "/te3/other/release-notes/3_15_0.html": "/en/references/release-notes/3_15_0.html", + "/te3/other/release-notes/3_16_0.html": "/en/references/release-notes/3_16_0.html", + "/te3/other/release-notes/3_16_1.html": "/en/references/release-notes/3_16_1.html", + "/te3/other/release-notes/3_16_2.html": "/en/references/release-notes/3_16_2.html", + "/te3/other/release-notes/3_17_0.html": "/en/references/release-notes/3_17_0.html", + "/te3/other/release-notes/3_17_1.html": "/en/references/release-notes/3_17_1.html", + "/te3/other/release-notes/3_18_0.html": "/en/references/release-notes/3_18_0.html", + "/te3/other/release-notes/3_18_1.html": "/en/references/release-notes/3_18_1.html", + "/te3/other/release-notes/3_18_2.html": "/en/references/release-notes/3_18_2.html", + "/te3/other/release-notes/3_19_0.html": "/en/references/release-notes/3_19_0.html", + "/te3/other/release-notes/3_1_0.html": "/en/references/release-notes/3_1_0.html", + "/te3/other/release-notes/3_1_1.html": "/en/references/release-notes/3_1_1.html", + "/te3/other/release-notes/3_1_2.html": "/en/references/release-notes/3_1_2.html", + "/te3/other/release-notes/3_1_3.html": "/en/references/release-notes/3_1_3.html", + "/te3/other/release-notes/3_1_4.html": "/en/references/release-notes/3_1_4.html", + "/te3/other/release-notes/3_1_5.html": "/en/references/release-notes/3_1_5.html", + "/te3/other/release-notes/3_1_6.html": "/en/references/release-notes/3_1_6.html", + "/te3/other/release-notes/3_1_7.html": "/en/references/release-notes/3_1_7.html", + "/te3/other/release-notes/3_20_0.html": "/en/references/release-notes/3_20_0.html", + "/te3/other/release-notes/3_20_1.html": "/en/references/release-notes/3_20_1.html", + "/te3/other/release-notes/3_21_0.html": "/en/references/release-notes/3_21_0.html", + "/te3/other/release-notes/3_22_0.html": "/en/references/release-notes/3_22_0.html", + "/te3/other/release-notes/3_22_1.html": "/en/references/release-notes/3_22_1.html", + "/te3/other/release-notes/3_23_0.html": "/en/references/release-notes/3_23_0.html", + "/te3/other/release-notes/3_23_1.html": "/en/references/release-notes/3_23_1.html", + "/te3/other/release-notes/3_24_0.html": "/en/references/release-notes/3_24_0.html", + "/te3/other/release-notes/3_24_1.html": "/en/references/release-notes/3_24_1.html", + "/te3/other/release-notes/3_24_2.html": "/en/references/release-notes/3_24_2.html", + "/te3/other/release-notes/3_2_0.html": "/en/references/release-notes/3_2_0.html", + "/te3/other/release-notes/3_2_1.html": "/en/references/release-notes/3_2_1.html", + "/te3/other/release-notes/3_2_2.html": "/en/references/release-notes/3_2_2.html", + "/te3/other/release-notes/3_2_3.html": "/en/references/release-notes/3_2_3.html", + "/te3/other/release-notes/3_3_0.html": "/en/references/release-notes/3_3_0.html", + "/te3/other/release-notes/3_3_1.html": "/en/references/release-notes/3_3_1.html", + "/te3/other/release-notes/3_3_2.html": "/en/references/release-notes/3_3_2.html", + "/te3/other/release-notes/3_3_3.html": "/en/references/release-notes/3_3_3.html", + "/te3/other/release-notes/3_3_4.html": "/en/references/release-notes/3_3_4.html", + "/te3/other/release-notes/3_3_5.html": "/en/references/release-notes/3_3_5.html", + "/te3/other/release-notes/3_3_6.html": "/en/references/release-notes/3_3_6.html", + "/te3/other/release-notes/3_4_0.html": "/en/references/release-notes/3_4_0.html", + "/te3/other/release-notes/3_4_1.html": "/en/references/release-notes/3_4_1.html", + "/te3/other/release-notes/3_4_2.html": "/en/references/release-notes/3_4_2.html", + "/te3/other/release-notes/3_5_0.html": "/en/references/release-notes/3_5_0.html", + "/te3/other/release-notes/3_5_1.html": "/en/references/release-notes/3_5_1.html", + "/te3/other/release-notes/3_6_0.html": "/en/references/release-notes/3_6_0.html", + "/te3/other/release-notes/3_7_0.html": "/en/references/release-notes/3_7_0.html", + "/te3/other/release-notes/3_7_1.html": "/en/references/release-notes/3_7_1.html", + "/te3/other/release-notes/3_8_0.html": "/en/references/release-notes/3_8_0.html", + "/te3/other/release-notes/3_9_0.html": "/en/references/release-notes/3_9_0.html", + "/te3/other/release-notes/beta-16_6.html": "/en/references/release-notes/beta-16_6.html", + "/te3/other/release-notes/beta-17_4.html": "/en/references/release-notes/beta-17_4.html", + "/te3/other/release-notes/beta-18_1.html": "/en/references/release-notes/beta-18_1.html", + "/te3/other/release-notes/beta-18_2.html": "/en/references/release-notes/beta-18_2.html", + "/te3/other/release-notes/beta-18_3.html": "/en/references/release-notes/beta-18_3.html", + "/te3/other/release-notes/beta-18_4.html": "/en/references/release-notes/beta-18_4.html", + "/te3/other/release-notes/beta-18_5.html": "/en/references/release-notes/beta-18_5.html", + "/te3/other/roadmap.html": "/en/references/roadmap.html", + "/te3/other/te3-eula.html": "/en/security/te3-eula.html", + "/te3/other/third-party-notices.html": "/en/security/third-party-notices.html", + "/te3/powerbi-xmla-pbix-workaround.html": "/en/how-tos/powerbi-xmla-pbix-workaround.html", + "/te3/powerbi-xmla.html": "/en/tutorials/powerbi-xmla.html", + "/te3/proxy-settings.html": "/en/troubleshooting/proxy-settings.html", + "/te3/toc.html": "/en/index.html", + "/te3/troubleshooting/calendar-blank-value.html": "/en/troubleshooting/calendar-blank-value.html", + "/te3/troubleshooting/direct-lake-entity-updates-reverting.html": "/en/troubleshooting/direct-lake-entity-updates-reverting.html", + "/te3/troubleshooting/locale-not-supported.html": "/en/troubleshooting/locale-not-supported.html", + "/te3/tutorials/calendars.html": "/en/tutorials/calendars.html", + "/te3/tutorials/connecting-to-azure-databricks.html": "/en/tutorials/connecting-to-azure-databricks.html", + "/te3/tutorials/creating-macros.html": "/en/features/creating-macros.html", + "/te3/tutorials/data-security/data-security-about.html": "/en/tutorials/data-security/data-security-about.html", + "/te3/tutorials/data-security/data-security-setup-ols.html": "/en/tutorials/data-security/data-security-setup-ols.html", + "/te3/tutorials/data-security/data-security-setup-rls.html": "/en/tutorials/data-security/data-security-setup-rls.html", + "/te3/tutorials/data-security/data-security-testing.html": "/en/tutorials/data-security/data-security-testing.html", + "/te3/tutorials/direct-lake-guidance.html": "/en/tutorials/direct-lake-guidance.html", + "/te3/tutorials/importing-tables.html": "/en/tutorials/importing-tables.html", + "/te3/tutorials/incremental-refresh/incremental-refresh-about.html": "/en/tutorials/incremental-refresh/incremental-refresh-about.html", + "/te3/tutorials/incremental-refresh/incremental-refresh-modify.html": "/en/tutorials/incremental-refresh/incremental-refresh-modify.html", + "/te3/tutorials/incremental-refresh/incremental-refresh-schema.html": "/en/tutorials/incremental-refresh/incremental-refresh-schema.html", + "/te3/tutorials/incremental-refresh/incremental-refresh-setup.html": "/en/tutorials/incremental-refresh/incremental-refresh-setup.html", + "/te3/tutorials/incremental-refresh/incremental-refresh-workspace-mode.html": "/en/tutorials/incremental-refresh/incremental-refresh-workspace-mode.html", + "/te3/tutorials/new-as-model.html": "/en/tutorials/new-as-model.html", + "/te3/tutorials/new-pbi-model.html": "/en/tutorials/new-pbi-model.html", + "/te3/tutorials/udfs.html": "/en/tutorials/udfs.html", + "/te3/views/bpa-view.html": "/en/features/views/bpa-view.html", + "/te3/views/data-refresh-view.html": "/en/features/views/data-refresh-view.html", + "/te3/views/find-replace.html": "/en/features/views/find-replace.html", + "/te3/views/macros-view.html": "/en/features/views/macros-view.html", + "/te3/views/messages-view.html": "/en/features/views/messages-view.html", + "/te3/views/properties-view.html": "/en/features/views/properties-view.html", + "/te3/views/tom-explorer-view.html": "/en/features/views/tom-explorer-view.html", + "/te3/views/user-interface.html": "/en/features/views/user-interface.html", + "/te3/whats-new.html": "/en/references/whats-new.html", + "/te3/workspace-mode.partial.html": "/en/features/workspace-mode.partial.html" + } + } +} \ No newline at end of file diff --git a/redirects.json b/redirects.json deleted file mode 100644 index 85f43077..00000000 --- a/redirects.json +++ /dev/null @@ -1,228 +0,0 @@ -{ - "content/common/CSharpScripts/Advanced/script-add-databricks-metadata-descriptions.html": "/features/CSharpScripts/Advanced/script-add-databricks-metadata-descriptions.html", - "content/common/CSharpScripts/Advanced/script-convert-dlsql-to-dlol.html": "/features/CSharpScripts/Advanced/script-convert-dlsql-to-dlol.html", - "content/common/CSharpScripts/Advanced/script-convert-import-to-dlol.html": "/features/CSharpScripts/Advanced/script-convert-import-to-dlol.html", - "content/common/CSharpScripts/Advanced/script-count-things.html": "/features/CSharpScripts/Advanced/script-count-things.html", - "content/common/CSharpScripts/Advanced/script-create-and-replace-M-parameter.html": "/features/CSharpScripts/Advanced/script-create-and-replace-M-parameter.html", - "content/common/CSharpScripts/Advanced/script-create-databricks-relationships.html": "/features/CSharpScripts/Advanced/script-create-databricks-relationships.html", - "content/common/CSharpScripts/Advanced/script-create-date-table.html": "/features/CSharpScripts/Advanced/script-create-date-table.html", - "content/common/CSharpScripts/Advanced/script-databricks-semantic-model-set-up.html": "/features/CSharpScripts/Advanced/script-databricks-semantic-model-set-up.html", - "content/common/CSharpScripts/Advanced/script-find-replace-selected-measures.html": "/features/CSharpScripts/Advanced/script-find-replace-selected-measures.html", - "content/common/CSharpScripts/Advanced/script-format-power-query.html": "/features/CSharpScripts/Advanced/script-format-power-query.html", - "content/common/CSharpScripts/Advanced/script-implement-incremental-refresh.html": "/features/CSharpScripts/Advanced/script-implement-incremental-refresh.html", - "content/common/CSharpScripts/Advanced/script-output-things.html": "/features/CSharpScripts/Advanced/script-output-things.html", - "content/common/CSharpScripts/Advanced/script-remove-measures-with-error.html": "/features/CSharpScripts/Advanced/script-remove-measures-with-error.html", - "content/common/CSharpScripts/Beginner/script-count-rows.html": "/features/CSharpScripts/Beginner/script-count-rows.html", - "content/common/CSharpScripts/Beginner/script-create-field-parameter.html": "/features/CSharpScripts/Beginner/script-create-field-parameter.html", - "content/common/CSharpScripts/Beginner/script-create-m-parameter.html": "/features/CSharpScripts/Beginner/script-create-m-parameter.html", - "content/common/CSharpScripts/Beginner/script-create-measure-table.html": "/features/CSharpScripts/Beginner/script-create-measure-table.html", - "content/common/CSharpScripts/Beginner/script-create-sum-measures-from-columns.html": "/features/CSharpScripts/Beginner/script-create-sum-measures-from-columns.html", - "content/common/CSharpScripts/Beginner/script-create-table-groups.html": "/features/CSharpScripts/Beginner/script-create-table-groups.html", - "content/common/CSharpScripts/Beginner/script-display-unique-column-values.html": "/features/CSharpScripts/Beginner/script-display-unique-column-values.html", - "content/common/CSharpScripts/Beginner/script-edit-hidden-partitions.html": "/features/CSharpScripts/Beginner/script-edit-hidden-partitions.html", - "content/common/CSharpScripts/Beginner/script-format-numeric-measures.html": "/features/CSharpScripts/Beginner/script-format-numeric-measures.html", - "content/common/CSharpScripts/Beginner/script-show-data-source-dependencies.html": "/features/CSharpScripts/Beginner/script-show-data-source-dependencies.html", - "content/common/CSharpScripts/Template/csharp-script-Template.html": "/features/CSharpScripts/Template/csharp-script-Template.html", - "content/common/CSharpScripts/csharp-script-library-advanced.html": "/features/CSharpScripts/csharp-script-library-advanced.html", - "content/common/CSharpScripts/csharp-script-library-beginner.html": "/features/CSharpScripts/csharp-script-library-beginner.html", - "content/common/CSharpScripts/csharp-script-library.html": "/features/CSharpScripts/csharp-script-library.html", - "content/common/Semantic Model/direct-lake-sql-model.html": "/features/Semantic-Model/direct-lake-sql-model.html", - "content/common/Semantic Model/direct-query-over-as.html": "/features/Semantic-Model/direct-query-over-as.html", - "content/common/Semantic Model/semantic-model-types.html": "/features/Semantic-Model/semantic-model-types.html", - "content/common/common-features.html": "/getting-started/Getting-Started-te2.html", - "content/common/desktop-limitations.html": "/getting-started/desktop-limitations.html", - "content/common/policies.html": "/references/policies.html", - "content/common/save-to-folder.html": "/features/save-to-folder.html", - "content/common/script-helper-methods.html": "/features/script-helper-methods.html", - "content/common/tmdl-common.html": "/features/tmdl.html", - "content/common/toc.html": "/index.html", - "content/common/using-bpa-sample-rules-expressions.html": "/features/using-bpa-sample-rules-expressions.html", - "content/common/using-bpa.html": "/features/using-bpa.html", - "content/common/xmla-as-connectivity.html": "/how-tos/xmla-as-connectivity.html", - "content/onboarding/as-cicd.html": "/getting-started/as-cicd.html", - "content/onboarding/boosting-productivity-te3.html": "/getting-started/boosting-productivity-te3.html", - "content/onboarding/bpa.html": "/getting-started/bpa.html", - "content/onboarding/creating-and-testing-dax.html": "/getting-started/creating-and-testing-dax.html", - "content/onboarding/cs-scripts-and-macros.html": "/getting-started/cs-scripts-and-macros.html", - "content/onboarding/dax-script-introduction.html": "/getting-started/dax-script-introduction.html", - "content/onboarding/general-introduction.html": "/getting-started/general-introduction.html", - "content/onboarding/importing-tables-data-modeling.html": "/getting-started/importing-tables-data-modeling.html", - "content/onboarding/index.html": "/getting-started/index.html", - "content/onboarding/installation.html": "/getting-started/installation.html", - "content/onboarding/migrate-from-desktop.html": "/getting-started/migrate-from-desktop.html", - "content/onboarding/migrate-from-te2.html": "/getting-started/migrate-from-te2.html", - "content/onboarding/migrate-from-vs.html": "/getting-started/migrate-from-vs.html", - "content/onboarding/optimizing-workflow-workspace-mode.html": "/getting-started/optimizing-workflow-workspace-mode.html", - "content/onboarding/parallel-development.html": "/getting-started/parallel-development.html", - "content/onboarding/personalizing-te3.html": "/getting-started/personalizing-te3.html", - "content/onboarding/powerbi-cicd.html": "/getting-started/powerbi-cicd.html", - "content/onboarding/refresh-preview-query.html": "/getting-started/refresh-preview-query.html", - "content/onboarding/toc.html": "/getting-started/toc.html", - "content/te2/Advanced-Filtering-of-the-Explorer-Tree.html": "/how-tos/Advanced-Filtering-of-the-Explorer-Tree.html", - "content/te2/Advanced-Scripting.html": "/how-tos/Advanced-Scripting.html", - "content/te2/Advanced-features.html": "/getting-started/Getting-Started-te2.html", - "content/te2/Best-Practice-Analyzer-Improvements.html": "/features/Best-Practice-Analyzer.html", - "content/te2/Best-Practice-Analyzer.html": "/features/Best-Practice-Analyzer.html", - "content/te2/Command-line-Options.html": "/features/Command-line-Options.html", - "content/te2/Custom-Actions.html": "/features/Custom-Actions-hidden.html", - "content/te2/FAQ.html": "/references/FAQ.html", - "content/te2/Features-at-a-glance.html": "/getting-started/Getting-Started-te2.html", - "content/te2/FormatDax.html": "/references/FormatDax.html", - "content/te2/Getting-Started.html": "/getting-started/Getting-Started-te2.html", - "content/te2/Importing-Tables.html": "/how-tos/Importing-Tables.html", - "content/te2/Keyboard-Shortcuts.html": "/references/Keyboard-Shortcuts2.html", - "content/te2/Master-model-pattern.html": "/how-tos/Master-model-pattern.html", - "content/te2/Power-BI-Desktop-Integration.html": "/getting-started/Power-BI-Desktop-Integration.html", - "content/te2/Roadmap.html": "/references/Roadmap2-h.html", - "content/te2/SQL-Server-2017-support.html": "/references/SQL-Server-2017-support-h.html", - "content/te2/TabularEditor.TOMWrapper.html": "/references/TabularEditor.TOMWrapper-h.html", - "content/te2/Training-Webinar-for-Tabular-Editor.html": "/getting-started/Training-Webinar-for-Tabular-Editor.html", - "content/te2/Useful-script-snippets.html": "/features/Useful-script-snippets.html", - "content/te2/Workspace-Database.html": "/features/Workspace-Database.html", - "content/te2/gdpr-delete.html": "/security/gdpr-delete.html", - "content/te2/importing-tables-from-excel.html": "/how-tos/importing-tables-from-excel.html", - "content/te2/incremental-refresh.html": "/how-tos/incremental-refresh2-h.html", - "content/te2/privacy-policy.html": "/security/privacy-policy.html", - "content/te2/toc.html": "/getting-started/Getting-Started-te2.html", - "content/te3/azure-marketplace.html": "/getting-started/azure-marketplace.html", - "content/te3/desktop-limitations.html": "/getting-started/desktop-limitations.html", - "content/te3/editions.html": "/getting-started/editions.html", - "content/te3/features/code-actions.html": "/features/code-actions.html", - "content/te3/features/csharp-scripts.html": "/features/csharp-scripts.html", - "content/te3/features/dax-debugger.html": "/features/dax-debugger.html", - "content/te3/features/dax-editor.html": "/features/dax-editor.html", - "content/te3/features/dax-optimizer-integration.html": "/features/dax-optimizer-integration.html", - "content/te3/features/dax-package-manager.html": "/features/dax-package-manager.html", - "content/te3/features/dax-query.html": "/features/dax-query.html", - "content/te3/features/dax-scripts.html": "/features/dax-scripts.html", - "content/te3/features/deployment.html": "/features/deployment.html", - "content/te3/features/diagram-view.html": "/features/views/diagram-view.html", - "content/te3/features/metadata-translation-editor.html": "/features/metadata-translation-editor.html", - "content/te3/features/perspective-editor.html": "/features/perspective-editor.html", - "content/te3/features/pivot-grid.html": "/features/pivot-grid.html", - "content/te3/features/preferences.html": "/references/preferences.html", - "content/te3/features/security-privacy.html": "/security/security-privacy.html", - "content/te3/features/shortcuts.html": "/references/shortcuts3.html", - "content/te3/features/supported-files.html": "/references/supported-files.html", - "content/te3/features/table-groups.html": "/features/table-groups.html", - "content/te3/features/tmdl.html": "/features/tmdl.html", - "content/te3/features/user-options.html": "/references/user-options.html", - "content/te3/getting-started.html": "/getting-started/getting-started.html", - "content/te3/import-tables.partial.html": "/features/import-tables.partial.html", - "content/te3/index.html": "/troubleshooting/licensing-activation.html", - "content/te3/other/downloads.html": "/references/downloads.html", - "content/te3/other/privacy-policy.html": "/security/privacy-policy.html", - "content/te3/other/release-history.html": "/references/release-history.html", - "content/te3/other/release-notes/3_0_1.html": "/references/release-notes/3_0_1.html", - "content/te3/other/release-notes/3_0_10.html": "/references/release-notes/3_0_10.html", - "content/te3/other/release-notes/3_0_2.html": "/references/release-notes/3_0_2.html", - "content/te3/other/release-notes/3_0_3.html": "/references/release-notes/3_0_3.html", - "content/te3/other/release-notes/3_0_4.html": "/references/release-notes/3_0_4.html", - "content/te3/other/release-notes/3_0_5.html": "/references/release-notes/3_0_5.html", - "content/te3/other/release-notes/3_0_6.html": "/references/release-notes/3_0_6.html", - "content/te3/other/release-notes/3_0_7.html": "/references/release-notes/3_0_7.html", - "content/te3/other/release-notes/3_0_8.html": "/references/release-notes/3_0_8.html", - "content/te3/other/release-notes/3_0_9.html": "/references/release-notes/3_0_9.html", - "content/te3/other/release-notes/3_10_0.html": "/references/release-notes/3_10_0.html", - "content/te3/other/release-notes/3_10_1.html": "/references/release-notes/3_10_1.html", - "content/te3/other/release-notes/3_11_0.html": "/references/release-notes/3_11_0.html", - "content/te3/other/release-notes/3_12_0.html": "/references/release-notes/3_12_0.html", - "content/te3/other/release-notes/3_12_1.html": "/references/release-notes/3_12_1.html", - "content/te3/other/release-notes/3_13_0.html": "/references/release-notes/3_13_0.html", - "content/te3/other/release-notes/3_14_0.html": "/references/release-notes/3_14_0.html", - "content/te3/other/release-notes/3_15_0.html": "/references/release-notes/3_15_0.html", - "content/te3/other/release-notes/3_16_0.html": "/references/release-notes/3_16_0.html", - "content/te3/other/release-notes/3_16_1.html": "/references/release-notes/3_16_1.html", - "content/te3/other/release-notes/3_16_2.html": "/references/release-notes/3_16_2.html", - "content/te3/other/release-notes/3_17_0.html": "/references/release-notes/3_17_0.html", - "content/te3/other/release-notes/3_17_1.html": "/references/release-notes/3_17_1.html", - "content/te3/other/release-notes/3_18_0.html": "/references/release-notes/3_18_0.html", - "content/te3/other/release-notes/3_18_1.html": "/references/release-notes/3_18_1.html", - "content/te3/other/release-notes/3_18_2.html": "/references/release-notes/3_18_2.html", - "content/te3/other/release-notes/3_19_0.html": "/references/release-notes/3_19_0.html", - "content/te3/other/release-notes/3_1_0.html": "/references/release-notes/3_1_0.html", - "content/te3/other/release-notes/3_1_1.html": "/references/release-notes/3_1_1.html", - "content/te3/other/release-notes/3_1_2.html": "/references/release-notes/3_1_2.html", - "content/te3/other/release-notes/3_1_3.html": "/references/release-notes/3_1_3.html", - "content/te3/other/release-notes/3_1_4.html": "/references/release-notes/3_1_4.html", - "content/te3/other/release-notes/3_1_5.html": "/references/release-notes/3_1_5.html", - "content/te3/other/release-notes/3_1_6.html": "/references/release-notes/3_1_6.html", - "content/te3/other/release-notes/3_1_7.html": "/references/release-notes/3_1_7.html", - "content/te3/other/release-notes/3_20_0.html": "/references/release-notes/3_20_0.html", - "content/te3/other/release-notes/3_20_1.html": "/references/release-notes/3_20_1.html", - "content/te3/other/release-notes/3_21_0.html": "/references/release-notes/3_21_0.html", - "content/te3/other/release-notes/3_22_0.html": "/references/release-notes/3_22_0.html", - "content/te3/other/release-notes/3_22_1.html": "/references/release-notes/3_22_1.html", - "content/te3/other/release-notes/3_23_0.html": "/references/release-notes/3_23_0.html", - "content/te3/other/release-notes/3_23_1.html": "/references/release-notes/3_23_1.html", - "content/te3/other/release-notes/3_24_0.html": "/references/release-notes/3_24_0.html", - "content/te3/other/release-notes/3_24_1.html": "/references/release-notes/3_24_1.html", - "content/te3/other/release-notes/3_24_2.html": "/references/release-notes/3_24_2.html", - "content/te3/other/release-notes/3_2_0.html": "/references/release-notes/3_2_0.html", - "content/te3/other/release-notes/3_2_1.html": "/references/release-notes/3_2_1.html", - "content/te3/other/release-notes/3_2_2.html": "/references/release-notes/3_2_2.html", - "content/te3/other/release-notes/3_2_3.html": "/references/release-notes/3_2_3.html", - "content/te3/other/release-notes/3_3_0.html": "/references/release-notes/3_3_0.html", - "content/te3/other/release-notes/3_3_1.html": "/references/release-notes/3_3_1.html", - "content/te3/other/release-notes/3_3_2.html": "/references/release-notes/3_3_2.html", - "content/te3/other/release-notes/3_3_3.html": "/references/release-notes/3_3_3.html", - "content/te3/other/release-notes/3_3_4.html": "/references/release-notes/3_3_4.html", - "content/te3/other/release-notes/3_3_5.html": "/references/release-notes/3_3_5.html", - "content/te3/other/release-notes/3_3_6.html": "/references/release-notes/3_3_6.html", - "content/te3/other/release-notes/3_4_0.html": "/references/release-notes/3_4_0.html", - "content/te3/other/release-notes/3_4_1.html": "/references/release-notes/3_4_1.html", - "content/te3/other/release-notes/3_4_2.html": "/references/release-notes/3_4_2.html", - "content/te3/other/release-notes/3_5_0.html": "/references/release-notes/3_5_0.html", - "content/te3/other/release-notes/3_5_1.html": "/references/release-notes/3_5_1.html", - "content/te3/other/release-notes/3_6_0.html": "/references/release-notes/3_6_0.html", - "content/te3/other/release-notes/3_7_0.html": "/references/release-notes/3_7_0.html", - "content/te3/other/release-notes/3_7_1.html": "/references/release-notes/3_7_1.html", - "content/te3/other/release-notes/3_8_0.html": "/references/release-notes/3_8_0.html", - "content/te3/other/release-notes/3_9_0.html": "/references/release-notes/3_9_0.html", - "content/te3/other/release-notes/beta-16_6.html": "/references/release-notes/beta-16_6.html", - "content/te3/other/release-notes/beta-17_4.html": "/references/release-notes/beta-17_4.html", - "content/te3/other/release-notes/beta-18_1.html": "/references/release-notes/beta-18_1.html", - "content/te3/other/release-notes/beta-18_2.html": "/references/release-notes/beta-18_2.html", - "content/te3/other/release-notes/beta-18_3.html": "/references/release-notes/beta-18_3.html", - "content/te3/other/release-notes/beta-18_4.html": "/references/release-notes/beta-18_4.html", - "content/te3/other/release-notes/beta-18_5.html": "/references/release-notes/beta-18_5.html", - "content/te3/other/roadmap.html": "/references/roadmap.html", - "content/te3/other/te3-eula.html": "/security/te3-eula.html", - "content/te3/other/third-party-notices.html": "/security/third-party-notices.html", - "content/te3/powerbi-xmla-pbix-workaround.html": "/how-tos/powerbi-xmla-pbix-workaround.html", - "content/te3/powerbi-xmla.html": "/tutorials/powerbi-xmla.html", - "content/te3/proxy-settings.html": "/troubleshooting/proxy-settings.html", - "content/te3/toc.html": "/index.html", - "content/te3/troubleshooting/calendar-blank-value.html": "/troubleshooting/calendar-blank-value.html", - "content/te3/troubleshooting/direct-lake-entity-updates-reverting.html": "/troubleshooting/direct-lake-entity-updates-reverting.html", - "content/te3/troubleshooting/locale-not-supported.html": "/troubleshooting/locale-not-supported.html", - "content/te3/tutorials/calendars.html": "/tutorials/calendars.html", - "content/te3/tutorials/connecting-to-azure-databricks.html": "/tutorials/connecting-to-azure-databricks.html", - "content/te3/tutorials/creating-macros.html": "/features/creating-macros.html", - "content/te3/tutorials/data-security/data-security-about.html": "/tutorials/data-security/data-security-about.html", - "content/te3/tutorials/data-security/data-security-setup-ols.html": "/tutorials/data-security/data-security-setup-ols.html", - "content/te3/tutorials/data-security/data-security-setup-rls.html": "/tutorials/data-security/data-security-setup-rls.html", - "content/te3/tutorials/data-security/data-security-testing.html": "/tutorials/data-security/data-security-testing.html", - "content/te3/tutorials/direct-lake-guidance.html": "/tutorials/direct-lake-guidance.html", - "content/te3/tutorials/importing-tables.html": "/tutorials/importing-tables.html", - "content/te3/tutorials/incremental-refresh/incremental-refresh-about.html": "/tutorials/incremental-refresh/incremental-refresh-about.html", - "content/te3/tutorials/incremental-refresh/incremental-refresh-modify.html": "/tutorials/incremental-refresh/incremental-refresh-modify.html", - "content/te3/tutorials/incremental-refresh/incremental-refresh-schema.html": "/tutorials/incremental-refresh/incremental-refresh-schema.html", - "content/te3/tutorials/incremental-refresh/incremental-refresh-setup.html": "/tutorials/incremental-refresh/incremental-refresh-setup.html", - "content/te3/tutorials/incremental-refresh/incremental-refresh-workspace-mode.html": "/tutorials/incremental-refresh/incremental-refresh-workspace-mode.html", - "content/te3/tutorials/new-as-model.html": "/tutorials/new-as-model.html", - "content/te3/tutorials/new-pbi-model.html": "/tutorials/new-pbi-model.html", - "content/te3/tutorials/udfs.html": "/tutorials/udfs.html", - "content/te3/tutorials/workspace-mode.html": "/tutorials/workspace-mode.html", - "content/te3/views/bpa-view.html": "/features/views/bpa-view.html", - "content/te3/views/data-refresh-view.html": "/features/views/data-refresh-view.html", - "content/te3/views/find-replace.html": "/features/views/find-replace.html", - "content/te3/views/macros-view.html": "/features/views/macros-view.html", - "content/te3/views/messages-view.html": "/features/views/messages-view.html", - "content/te3/views/properties-view.html": "/features/views/properties-view.html", - "content/te3/views/tom-explorer-view.html": "/features/views/tom-explorer-view.html", - "content/te3/views/user-interface.html": "/features/views/user-interface.html", - "content/te3/whats-new.html": "/references/whats-new.html", - "content/te3/workspace-mode.partial.html": "/features/workspace-mode.partial.html", - "content/te3/tutorials/workspace-mode.html": "/features/workspace-mode.partial.html" -} diff --git a/templates/tabulareditor/ManagedReference.extension.js b/templates/tabulareditor/ManagedReference.extension.js index 3f2e1ebc..e39d2ecd 100644 --- a/templates/tabulareditor/ManagedReference.extension.js +++ b/templates/tabulareditor/ManagedReference.extension.js @@ -6,56 +6,68 @@ exports.preTransform = function (model) { mainMenu: [ { text: "Pricing", - url: "https://tabulareditor.com/pricing" + url: "https://tabulareditor.com/pricing", + uiStringKey: "header.nav.pricing" }, { text: "Download", - url: "https://tabulareditor.com/downloads" + url: "https://tabulareditor.com/downloads", + uiStringKey: "header.nav.download" }, { text: "Learn", - url: "https://tabulareditor.com/learn" + url: "https://tabulareditor.com/learn", + uiStringKey: "header.nav.learn" }, { text: "Resources" , url: "/", + uiStringKey: "header.nav.resources", subMenu: { items: [ { text: "Blog", - url: "https://tabulareditor.com/blog" + url: "https://tabulareditor.com/blog", + uiStringKey: "header.nav.blog" }, { text: "Newsletter", - url: "https://tabulareditor.com/newsletter" + url: "https://tabulareditor.com/newsletter", + uiStringKey: "header.nav.newsletter" }, { text: "Publications", - url: "https://tabulareditor.com/publications" + url: "https://tabulareditor.com/publications", + uiStringKey: "header.nav.publications" }, { text: "Documentation", - url: "https://docs.tabulareditor.com/?tabs=TE3" + url: "https://docs.tabulareditor.com/?tabs=TE3", + uiStringKey: "header.nav.documentation" }, { text: "Support community", - url: "https://github.com/TabularEditor/TabularEditor3" + url: "https://github.com/TabularEditor/TabularEditor3", + uiStringKey: "header.nav.supportCommunity" } ] } }, { text: "Contact Us", - url: "https://tabulareditor.com/contact" + url: "https://tabulareditor.com/contact", + uiStringKey: "header.nav.contactUs" } ], button1: { text: "Free trial", - url: "https://www.tabulareditor.com/downloads" + url: "https://www.tabulareditor.com/downloads", + uiStringKey: "header.button1" }, button2: { text: "Sign in", - url: "https://www.tabulareditor.com" + url: "https://www.tabulareditor.com", + uiStringKey: "header.button2" }, } @@ -63,27 +75,32 @@ exports.preTransform = function (model) { buttons: [ { text: "Try Tabular Editor 3 for free", - url: "https://www.tabulareditor.com/downloads" + url: "https://www.tabulareditor.com/downloads", + uiStringKey: "footer.button1" }, { text: "Buy Tabular Editor 3", - url: "https://www.tabulareditor.com/pricing" + url: "https://www.tabulareditor.com/pricing", + uiStringKey: "footer.button2" } ], leftLinks: [ { text: "About us", - url: "https://tabulareditor.com/about-us" + url: "https://tabulareditor.com/about-us", + uiStringKey: "footer.aboutUs" }, { text: "Contact us", - url: "https://tabulareditor.com/contact" + url: "https://tabulareditor.com/contact", + uiStringKey: "footer.contactUs" }, { text: "Technical Support", url: "mailto:support@tabulareditor.com", rel: "noopener noreferrer", - target: "_blank" + target: "_blank", + uiStringKey: "footer.technicalSupport" } ], rightLinks: [ @@ -101,17 +118,20 @@ exports.preTransform = function (model) { bottomLinks: [ { text: "Privacy & Cookie policy", - url: "https://tabulareditor.com/privacy-policy" + url: "https://tabulareditor.com/privacy-policy", + uiStringKey: "footer.privacyPolicy" }, { text: "Terms & Conditions", - url: "https://tabulareditor.com/terms" + url: "https://tabulareditor.com/terms", + uiStringKey: "footer.termsConditions" }, { text: "License terms", - url: "https://tabulareditor.com/license-terms" + url: "https://tabulareditor.com/license-terms", + uiStringKey: "footer.licenseTerms" } ] } return model; -} \ No newline at end of file +} diff --git a/templates/tabulareditor/README.md b/templates/tabulareditor/README.md index da1eda4f..c88993de 100644 --- a/templates/tabulareditor/README.md +++ b/templates/tabulareditor/README.md @@ -113,6 +113,14 @@ And temporary for the forked repository. rsync -av --exclude='src' --exclude='tools' ./templates/tabulareditor ../TabularEditorDocsFork/templates/ ``` +For windows + +```bash +npm run build + +robocopy "\templates\tabulareditor" "\TabularEditorDocs\templates\tabulareditor" /E /XD src tools /MIR +``` + ## Additional Files - **`tabulareditor/src/hubspot`**: Contains rewritten Sass and TypeScript files based on the HubSpot theme. diff --git a/templates/tabulareditor/conceptual.html.primary.js b/templates/tabulareditor/conceptual.html.primary.js index 4984d8e7..3953d929 100644 --- a/templates/tabulareditor/conceptual.html.primary.js +++ b/templates/tabulareditor/conceptual.html.primary.js @@ -4,56 +4,68 @@ exports.transform = function (model) { mainMenu: [ { text: "Pricing", - url: "https://tabulareditor.com/pricing" + url: "https://tabulareditor.com/pricing", + uiStringKey: "header.nav.pricing" }, { text: "Download", - url: "https://tabulareditor.com/downloads" + url: "https://tabulareditor.com/downloads", + uiStringKey: "header.nav.download" }, { text: "Learn", - url: "https://tabulareditor.com/learn" + url: "https://tabulareditor.com/learn", + uiStringKey: "header.nav.learn" }, { text: "Resources" , url: "/", + uiStringKey: "header.nav.resources", subMenu: { items: [ { text: "Blog", - url: "https://tabulareditor.com/blog" + url: "https://tabulareditor.com/blog", + uiStringKey: "header.nav.blog" }, { text: "Newsletter", - url: "https://tabulareditor.com/newsletter" + url: "https://tabulareditor.com/newsletter", + uiStringKey: "header.nav.newsletter" }, { text: "Publications", - url: "https://tabulareditor.com/publications" + url: "https://tabulareditor.com/publications", + uiStringKey: "header.nav.publications" }, { text: "Documentation", - url: "https://docs.tabulareditor.com/?tabs=TE3" + url: "https://docs.tabulareditor.com/?tabs=TE3", + uiStringKey: "header.nav.documentation" }, { text: "Support community", - url: "https://github.com/TabularEditor/TabularEditor3" + url: "https://github.com/TabularEditor/TabularEditor3", + uiStringKey: "header.nav.supportCommunity" } ] } }, { text: "Contact Us", - url: "https://tabulareditor.com/contact" + url: "https://tabulareditor.com/contact", + uiStringKey: "header.nav.contactUs" } ], button1: { text: "Free trial", - url: "https://www.tabulareditor.com/downloads" + url: "https://www.tabulareditor.com/downloads", + uiStringKey: "header.button1" }, button2: { text: "Sign in", - url: "https://www.tabulareditor.com" + url: "https://www.tabulareditor.com", + uiStringKey: "header.button2" }, } @@ -61,27 +73,32 @@ exports.transform = function (model) { buttons: [ { text: "Try Tabular Editor 3 for free", - url: "https://www.tabulareditor.com/downloads" + url: "https://www.tabulareditor.com/downloads", + uiStringKey: "footer.button1" }, { text: "Buy Tabular Editor 3", - url: "https://www.tabulareditor.com/pricing" + url: "https://www.tabulareditor.com/pricing", + uiStringKey: "footer.button2" } ], leftLinks: [ { text: "About us", - url: "https://tabulareditor.com/about-us" + url: "https://tabulareditor.com/about-us", + uiStringKey: "footer.aboutUs" }, { text: "Contact us", - url: "https://tabulareditor.com/contact" + url: "https://tabulareditor.com/contact", + uiStringKey: "footer.contactUs" }, { text: "Technical Support", url: "mailto:support@tabulareditor.com", rel: "noopener noreferrer", - target: "_blank" + target: "_blank", + uiStringKey: "footer.technicalSupport" } ], rightLinks: [ @@ -99,17 +116,20 @@ exports.transform = function (model) { bottomLinks: [ { text: "Privacy & Cookie policy", - url: "https://tabulareditor.com/privacy-policy" + url: "https://tabulareditor.com/privacy-policy", + uiStringKey: "footer.privacyPolicy" }, { text: "Terms & Conditions", - url: "https://tabulareditor.com/terms" + url: "https://tabulareditor.com/terms", + uiStringKey: "footer.termsConditions" }, { text: "License terms", - url: "https://tabulareditor.com/license-terms" + url: "https://tabulareditor.com/license-terms", + uiStringKey: "footer.licenseTerms" } ] } return model; -} \ No newline at end of file +} diff --git a/templates/tabulareditor/layout/_master.tmpl b/templates/tabulareditor/layout/_master.tmpl index 672ab3cb..3beaf963 100644 --- a/templates/tabulareditor/layout/_master.tmpl +++ b/templates/tabulareditor/layout/_master.tmpl @@ -117,11 +117,11 @@ {{#__header.mainMenu}} {{#subMenu}} {{/items}} {{/subMenu}} @@ -160,11 +160,15 @@ {{/_enableSearch}} +
+ +
+
{{#__header.button1}}
- {{text}} + {{text}} @@ -174,7 +178,7 @@ {{#__header.button2}}
- {{text}} + {{text}} @@ -249,48 +253,18 @@ {{! meta applies to }} {{#applies_to}} -
Applies to: -
    - {{#products}} -
  • - {{^editions}} - {{#full}}{{/full}} - {{#partial}}{{/partial}} - {{#none}}{{/none}} - {{/editions}} - {{#none}}{{product}}{{/none}} - {{^none}}{{product}}{{/none}} - {{^editions}} - {{#full}}{{/full}} - {{#partial}}{{/partial}} - {{/editions}} - {{#since}} - - ({{#until}}Available in {{since}}–{{until}}{{/until}}{{^until}}Available since {{since}}{{/until}}) - - {{/since}} - {{^editions}} - {{#note}}{{note}}{{/note}} - {{/editions}} - {{#editions}} -
    - {{#none}} - {{edition}} - {{/none}} - {{^none}} - {{#partial}} - {{edition}}{{#note}}{{note}}{{/note}} - {{/partial}} - {{^partial}} - {{edition}} - {{/partial}} - {{/none}} -
    - {{/editions}} -
  • - {{/products}} -
-
+
Applies to:
    + {{#editions}} + {{#none}}
  • {{edition}} Edition
  • {{/none}} + {{#partial}}
  • {{edition}} Edition
  • {{/partial}} + {{^partial}}{{^none}}
  • {{edition}} Edition
  • {{/none}}{{/partial}} + {{/editions}} + {{#versions}} + {{#none}}
  • Tabular Editor {{version}}
  • {{/none}} + {{#partial}}
  • Tabular Editor {{version}}
  • {{/partial}} + {{^partial}}{{^none}}
  • Tabular Editor {{version}}
  • {{/none}}{{/partial}} + {{/versions}} +
{{/applies_to}} {{! START CLOSE DIV: Check if document has updated or applies_to metadata, add a begin div to be able to add spacing for the metadata}} @@ -346,14 +320,14 @@