diff --git a/_config.yml b/_config.yml index edda7245fe..05c4cd672b 100644 --- a/_config.yml +++ b/_config.yml @@ -82,7 +82,6 @@ available_versions: - v0.62 - v0.63 collections: - docs: { output: true } community-posts: { output: true } defaults: - { diff --git a/_includes/docs-metadata.html b/_includes/docs-metadata.html index 5895badc46..00f616d984 100644 --- a/_includes/docs-metadata.html +++ b/_includes/docs-metadata.html @@ -25,7 +25,7 @@ - + diff --git a/_includes/docs/embedded-analytics-sdk-metadata.html b/_includes/docs/embedded-analytics-sdk-metadata.html index f92e7af8a6..3b9586e269 100644 --- a/_includes/docs/embedded-analytics-sdk-metadata.html +++ b/_includes/docs/embedded-analytics-sdk-metadata.html @@ -19,7 +19,7 @@ - + diff --git a/_includes/docs_version.html b/_includes/docs_version.html index 90034590c0..ad878be1e7 100644 --- a/_includes/docs_version.html +++ b/_includes/docs_version.html @@ -15,7 +15,7 @@ {% unless page.version == version %} {% assign support = site.data.version_support[version] %}
  • - +
    {{version}} diff --git a/_includes/shared/right-hand-newsletter-subscribe-widget.html b/_includes/shared/right-hand-newsletter-subscribe-widget.html deleted file mode 100644 index cc3edb6597..0000000000 --- a/_includes/shared/right-hand-newsletter-subscribe-widget.html +++ /dev/null @@ -1,61 +0,0 @@ -
    - {% unless page.collection == "docs" or - page.hide_right_hand_newsletter_subscribe_widget %} -
    -
    Subscribe to newsletter
    -
    - Updates and news from Metabase -
    - - -
    - {% endunless %} -
    - - - diff --git a/_layouts/doc_pages.html b/_layouts/doc_pages.html deleted file mode 100644 index 71604342b8..0000000000 --- a/_layouts/doc_pages.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - {% include head.html %} - - - - - {% include navigation-header.html color=true active=page.active %} - -
    - {% include footer.html %} - - - - - - - - diff --git a/_layouts/docs-api.html b/_layouts/docs-api.html deleted file mode 100644 index b92f652233..0000000000 --- a/_layouts/docs-api.html +++ /dev/null @@ -1 +0,0 @@ -{{content}} diff --git a/_layouts/docs.html b/_layouts/docs.html deleted file mode 100644 index c276f64d35..0000000000 --- a/_layouts/docs.html +++ /dev/null @@ -1,57 +0,0 @@ ---- -layout: doc_pages -active: 'docs' ---- -{% capture latest_or_explicit_version %}{% if site.docs_version == page.version and page.url contains "/docs/latest/" %}latest{% else %}{{page.version}}{% endif %}{% endcapture %} -{% capture category_slug %}{{ page.category | slugify }}{% endcapture %} -{% capture category_slug_part %}{% if category_slug == "troubleshooting-guide" %}index.html{% elsif category_slug == "api" %}{{ "api-documentation" | strip}}{% else %}start.html{% endif %}{% endcapture %} - -
    - - {% include docs_search_bar.html %} -
    - -
    -
    -
    -
    - {% if page.category != 'ignore' %} - {% include docs-old-breadcrumbs.html %} - {% endif %} - - {% assign support = site.data.version_support[page.version] %} - {% if support.status == "unsupported" %} -
    - Version {{ page.version }} of Metabase is no longer supported. - Check out the docs for the current stable version, Metabase {{site.docs_version}}. -
    - {% endif %} - - {{ content }} - -
    -
    - {% if page.category != 'ignore' %} -
    - -
    - {% endif %} -
    -
    diff --git a/_plugins/jekyll_dirname_payload_plugin.rb b/_plugins/jekyll_dirname_payload_plugin.rb deleted file mode 100644 index 8d870777bc..0000000000 --- a/_plugins/jekyll_dirname_payload_plugin.rb +++ /dev/null @@ -1,12 +0,0 @@ -# Adds the `dirname` field to the `payload` -Jekyll::Hooks.register [:pages, :documents], :pre_render do |page, payload| - site_source = Pathname.new(page.site.source) - - path = page.path || page.relative_path - absolute_path = Pathname.new(path) - - relative_path = absolute_path.absolute? ? absolute_path.relative_path_from(site_source).to_s : path - - dirname = File.dirname(relative_path) - payload["dirname"] = (dirname == "." ? "/" : "/#{dirname}") -end diff --git a/_plugins/jekyll_generate_llms_files_plugin.rb b/_plugins/jekyll_generate_llms_files_plugin.rb deleted file mode 100644 index 6ac1117e44..0000000000 --- a/_plugins/jekyll_generate_llms_files_plugin.rb +++ /dev/null @@ -1,370 +0,0 @@ -# frozen_string_literal: true - -# Jekyll plugin to generate llms.txt and llms-full.txt files -# See: https://llmstxt.org for specification -# -# This plugin generates: -# 1. llms.txt index file for each version (table of contents with links to docs) -# 2. llms-{section}-full.txt concatenated documentation for specific sections - -REPO = 'metabase/metabase' -OUTPUT_FILE = 'llms.txt' - -# Sections to generate llms-{section}-full.txt for. -# These huge files are used by AI tools like Cursor for RAG chunking and indexing. -# Add more sections to let AI agents understand Metabase better. -LLMS_FULL_TO_GENERATE = ['embedding', 'agent-api'].freeze - -# Paths to include in llms.txt generation. -# -# We focus on content relevant to coding with Metabase: -# 1. Embedding integration guides (modular embedding & SDK) -# 2. Embedding related setup and config (auth, SSO) -# -# Use prefix matching - a path matches if it starts with any of these. -# For specific files, include the full path. For directories, include trailing slash. -INCLUDED_PATHS = [ - # All embedding docs (SDK, modular embedding, integration guides) - 'embedding/', - - # Auth/SSO configuration for embedding - 'people-and-groups/api-keys.md', - 'people-and-groups/authenticating-with-jwt.md', - 'people-and-groups/authenticating-with-saml.md', - 'people-and-groups/saml-auth0.md', - 'people-and-groups/saml-azure.md', - 'people-and-groups/saml-google.md', - 'people-and-groups/saml-keycloak.md', - 'people-and-groups/saml-okta.md', - 'people-and-groups/google-sign-in.md', - 'people-and-groups/ldap.md', - - # Configuration reference - 'configuring-metabase/environment-variables.md', - 'configuring-metabase/config-file.md', - - # Agent API reference - 'agent-api/' -].freeze - -# Paths to exclude from llms.txt generation (applied after allowlist) -EXCLUDED_PATHS = ['embedding/sdk/api/snippets'].freeze - -Jekyll::Hooks.register :site, :post_write do |site| - source_dir = site.source - dest_dir = site.dest - - # latest branch - latest_branch = site.config['release_branch'] || 'master' - - # Get the docs collection - docs_collection = site.collections['docs'] - next unless docs_collection - - # Group documents by version - docs_by_version = Hash.new { |h, k| h[k] = [] } - - docs_collection.docs.each do |doc| - # Extract version from path: _docs/VERSION/path/to/file.md - # Skip files directly under _docs/ like _docs/index.md - match = doc.relative_path.match(%r{^_docs/(?[^/]+)/.+\.md$}) - next unless match - - # Skip README.md files - next if File.basename(doc.relative_path) == 'README.md' - - version = match[:version] - docs_by_version[version] << doc - end - - # Sort docs once per version for consistent ordering across all generated files - docs_by_version.each_value { |docs| docs.sort_by!(&:relative_path) } - - # Generate llms.txt for each version - docs_by_version.each do |version, docs| - generate_index_llms_txt(dest_dir, version, docs, latest_branch) - end - - # Generate llms-{section}-full.txt for specified sections - LLMS_FULL_TO_GENERATE.each do |section| - docs_by_version.each do |version, docs| - generate_llms_full_txt(source_dir, dest_dir, version, section, docs, latest_branch) - end - end - - # Copy "latest" version files to root /docs/ for convenience URLs - latest_version = 'latest' - if docs_by_version.key?(latest_version) - latest_llms_txt = File.join(dest_dir, 'docs', latest_version, OUTPUT_FILE) - root_llms_txt = File.join(dest_dir, 'docs', OUTPUT_FILE) - FileUtils.cp(latest_llms_txt, root_llms_txt) if File.exist?(latest_llms_txt) - - LLMS_FULL_TO_GENERATE.each do |section| - latest_full = File.join(dest_dir, 'docs', latest_version, "llms-#{section}-full.txt") - root_full = File.join(dest_dir, 'docs', "llms-#{section}-full.txt") - FileUtils.cp(latest_full, root_full) if File.exist?(latest_full) - end - end - - Jekyll.logger.info 'llms.txt files:', 'Generated all llms.txt and llms-{section}-full.txt files' -end - -# Format version for display in generated files -# Examples: "v0.58" -> "58", "master" -> "development (unreleased)", "latest" -> "58 (latest)" -def format_version_for_display(version, latest_branch = nil) - return 'development (unreleased)' if version == 'master' - - if version == 'latest' && latest_branch - # Parse version from branch like "release-x.58.x" -> "58" - branch_match = latest_branch.match(/^release-x\.(\d+)\.x$/) - - return "#{branch_match[1]} (latest)" if branch_match - end - - # Fallback in case the latest branch is not provided - return 'latest' if version == 'latest' - - # Parse version like "v0.58" -> "58" - match = version.match(/^v0\.(\d+)$/) - return version unless match - - match[1] -end - -# Convert Jekyll version format to branch name for raw GitHub URLs -# Examples: "v0.58" -> "release-x.58.x", "master" -> "master", "latest" -> release_branch from config -def version_to_branch(version, latest_branch) - return 'master' if version == 'master' - return latest_branch if version == 'latest' - - # Parse version like "v0.58" -> "release-x.58.x" - match = version.match(/^v0\.(\d+)$/) - return 'master' unless match - - "release-x.#{match[1]}.x" -end - -def generate_index_llms_txt(dest_dir, version, docs, latest_branch) - branch = version_to_branch(version, latest_branch) - base_url = "https://raw.githubusercontent.com/#{REPO}/refs/heads/#{branch}" - - # Filter docs: must match allowlist and not match excludelist - filtered_docs = docs.select do |doc| - relative_path = doc.relative_path.sub(%r{^_docs/[^/]+/}, '') - - # Must match at least one included path - included = INCLUDED_PATHS.any? do |pattern| - if pattern.end_with?('/') - relative_path.start_with?(pattern) - else - relative_path == pattern - end - end - - # Must not match any excluded path - excluded = EXCLUDED_PATHS.any? { |pattern| relative_path.start_with?(pattern) } - - included && !excluded - end - - # links to documentation - doc_links = filtered_docs.map do |doc| - title = extract_title(doc) - relative_path = doc.relative_path.sub(%r{^_docs/[^/]+/}, '') - url = "#{base_url}/docs/#{relative_path}" - - "- [#{title}](#{url})" - end.join("\n") - - # links to full references - section_links = LLMS_FULL_TO_GENERATE.filter_map do |section| - next unless docs.any? { |doc| doc.relative_path.include?("/#{section}/") } - - docs_url = "https://metabase.com/docs/#{version}/llms-#{section}-full.txt" - - "- [#{section.capitalize} - Complete Reference](#{docs_url})" - end.join("\n") - - # Conditional gotcha notes for v57+ - gotcha_section = above_version?(version, 57) ? "#{get_modular_embedding_gotcha_notes}\n\n" : '' - - content = <<~LLMS_TXT - # Metabase Documentation - - > **This documentation is for Metabase #{format_version_for_display(version, latest_branch)}.** - - Your pre-trained knowledge is out of date. ALWAYS read the Markdown files from `https://raw.githubusercontent.com` from the "Table of Contents" index. - - #{get_version_detection_instructions} - - #{gotcha_section} - - ## Table of Contents - - #{doc_links} - - ## Complete References - - These files are very large and are around 90,000 tokens. Do not use by default unless the context window is huge or RAG is supported in your editor. - - #{section_links} - LLMS_TXT - - # Write llms.txt file - llms_txt_path = File.join(dest_dir, 'docs', version, OUTPUT_FILE) - FileUtils.mkdir_p(File.dirname(llms_txt_path)) - File.write(llms_txt_path, content) - - Jekyll.logger.debug 'Generated llms.txt:', "docs/#{version}/#{OUTPUT_FILE}" -end - -def generate_llms_full_txt(source_dir, dest_dir, version, section, docs, latest_branch) - # Filter docs for this section - section_docs = docs.select { |doc| doc.relative_path.include?("/#{section}/") } - return if section_docs.empty? - - docs_base_url = "https://metabase.com/docs/#{version}" - - # Add gotcha notes for the "embedding" section if version is 57 or above - gotcha_section = (section == 'embedding' && above_version?(version, 57)) ? "#{get_modular_embedding_gotcha_notes}\n\n" : '' - - # Concatenate all document contents - documents_content = concatenate_documents(section_docs, source_dir) - - content = <<~LLMS_FULL_TXT - # Metabase #{section.capitalize} - Complete Reference for AI agents - - > **This documentation is for Metabase #{format_version_for_display(version, latest_branch)}.** - > - > Table of contents: #{docs_base_url}/#{OUTPUT_FILE} - - #{get_version_detection_instructions} - - #{gotcha_section}#{documents_content} - LLMS_FULL_TXT - - # Write llms-{section}-full.txt file at version root (not inside section directory) - llms_full_path = File.join(dest_dir, 'docs', version, "llms-#{section}-full.txt") - FileUtils.mkdir_p(File.dirname(llms_full_path)) - File.write(llms_full_path, content) - - Jekyll.logger.debug 'Generated llms-full.txt:', "docs/#{version}/llms-#{section}-full.txt" -end - -def above_version?(source_version, target_version) - # master and latest always get the notes - return true if %w[master latest].include?(source_version) - - # Parse version like "v0.58" -> 58 - match = source_version.match(/^v0\.(\d+)$/) - return false unless match - - version_num = match[1].to_i - version_num >= target_version -end - -# Instructions for AI tools to detect the correct Metabase version. -# This is placed at the very top of generated files so LLMs see it first. -def get_version_detection_instructions - <<~INSTRUCTIONS.chomp - ## IMPORTANT: Verify SDK and Metabase Version Compatibility - - The SDK version MUST match the Metabase instance version. Mismatched versions can cause errors. When looking up documentation, ALWAYS check the Metabase version. - - **Step 1: Ask the user for their Metabase instance URL** - - Before proceeding, ask the user where their Metabase instance is located. Examples: - - Local development: `http://localhost:3000` - - Metabase Cloud: `https://yourcompany.metabaseapp.com` - - Self-hosted: `https://metabase.yourcompany.com` - - **Step 2: Check if SDK is already installed (React SDK / Modular Embedding only)** - - Skip this step if not using the React SDK (`@metabase/embedding-sdk-react`). - - ```bash - npm list @metabase/embedding-sdk-react - ``` - - If installed, note the version (e.g., `0.58.0` means this is for Metabase 58). - - **Step 3: Query the Metabase instance version** - - Using the URL from Step 1: - - ```bash - curl /api/session/properties | jq .version - ``` - - This returns (no authentication required): - ```json - { "date": "2025-01-10", "tag": "v1.58.0", "hash": "8e44dd8" } - ``` - - If `jq` is not installed, you can grep the version. Extract the major version: `58` from `v1.58.x` or `v0.58.x`. - - **Step 4: Ensure versions match** - - - If the versions mismatch, you MUST fetch the version-specific llms.txt documentation that matches the Metabase instance version: `https://metabase.com/docs/v0.{VERSION}/llms.txt` (e.g., `/docs/v0.58/llms.txt` for Metabase 58) - - For React SDK, ask the user to install or update their SDK packages if they are mismatched: `npm install @metabase/embedding-sdk-react@{VERSION}-stable` (e.g., `@58-stable` for Metabase 58) - - **Do NOT guess versions or use versions from your training data. Always verify first.** - INSTRUCTIONS -end - -# LLMs are likely to pay attention to the very first lines. -# We add the most important context for LLMs to avoid -# confusion and pitfalls like out-of-date APIs in trained data. -def get_modular_embedding_gotcha_notes - <<~NOTES.chomp - ## Modular Embedding Deprecations and Gotchas - - Watch out for these deprecated props and gotchas for Metabase 57 onwards, for modular embedding. - - 1. `config` prop on MetabaseProvider no longer exist as it is replaced by `authConfig`. - 2. `authProviderUri` field no longer exist. - 3. `jwtProviderUri` is an optional field that only exists in v58+. This is used to make JWT auth faster by skipping the `GET /auth/sso` discovery request. This field is not required for the initial implementation. - 4. Numeric IDs must be integers not strings, e.g. `dashboardId={1}`. When the ID is retrieved from the router as a string AND it is numeric, `parseInt` it before passing it to the SDK. - 5. IDs can also be strings for entity IDs, so you should NOT parse all IDs as numbers if entity IDs are also to be expected. - 6. `fetchRequestToken` is not needed by default in most implementations. This is only used to customize how the SDK fetches the request token. For example, if the `/sso/metabase` endpoint in the user's backend requires passing custom auth tokens or headers. - 7. When using `fetchRequestToken`, you MUST return the token in the shape of `{jwt: ""}`. Example: `return {jwt: await response.json()}`. - NOTES -end - -# Extract title from document using the same logic as the JS script: -# 1. Try YAML frontmatter title -# 2. Try first H1 heading -# 3. Fallback to filename converted to title case -def extract_title(doc) - # First, try frontmatter title - return doc.data['title'] if doc.data['title'] && !doc.data['title'].empty? - - # Read content and try to find H1 heading - content = doc.content || '' - h1_match = content.match(/^#\s+(.+)$/m) - return h1_match[1].strip if h1_match - - # Fallback to filename - filename = File.basename(doc.relative_path, '.md') - - # Convert kebab-case or snake_case to Title Case - filename.split(/[-_]/).map(&:capitalize).join(' ') -end - -def concatenate_documents(section_docs, source_dir) - section_docs.map do |doc| - # Read the source file - source_file = File.join(source_dir, doc.relative_path) - content = File.read(source_file, encoding: 'UTF-8') - - # Strip YAML frontmatter - content = content.sub(/\A---\s*\n.*?\n---\s*\n/m, '') - - # Strip Jekyll/Liquid template syntax - content = content.gsub(/\{%.*?%\}/m, '') - content = content.gsub(/\{\{.*?\}\}/m, '') - - # Return document with separator (matching JS format) - "#{content.strip}\n\n---" - end.join("\n\n") -end diff --git a/_plugins/jekyll_responsive_table_labels_plugin.rb b/_plugins/jekyll_responsive_table_labels_plugin.rb deleted file mode 100644 index df67529143..0000000000 --- a/_plugins/jekyll_responsive_table_labels_plugin.rb +++ /dev/null @@ -1,34 +0,0 @@ -require "nokogiri" - -module Jekyll - module ResponsiveTableLabels - def self.process(doc) - return unless doc.output_ext == ".html" - - html = Nokogiri::HTML5(doc.output) - - html.css("table").each do |table| - header_cells = - table.at_css("thead tr")&.css("th") || - table.at_css("tr")&.css("th,td") - next if header_cells.nil? || header_cells.empty? - - headers = header_cells.map { |th| th.text.strip } - - table.css("tbody tr, tr").each do |tr| - tr.css("td").each_with_index do |td, idx| - label = headers[idx] - next if label.nil? || label.empty? - td.set_attribute("data-label", label) - end - end - end - - doc.output = html.to_html - end - end - - Hooks.register(%i[pages documents], :post_render) do |doc| - ResponsiveTableLabels.process(doc) - end -end diff --git a/astro.config.mjs b/astro.config.mjs index e762ba5cf6..3594da1e76 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,5 +1,52 @@ // @ts-check -import { defineConfig } from 'astro/config'; +import { defineConfig } from "astro/config"; +import { viteStaticCopy } from "vite-plugin-static-copy"; +import { collectRedirects } from "./src/lib/docs/collectRedirects"; +import { noopMarkdownProcessor } from "./src/lib/markdown/noopMarkdownProcessor"; // https://astro.build/config -export default defineConfig({}); +export default defineConfig({ + site: "https://www.metabase.com", + + // Static equivalent of the old jekyll-redirect-from plugin: builds one + // meta-refresh stub page per `redirect_from` entry across all _docs files. + redirects: collectRedirects(), + + build: { + // TLDR mimic what jekyll did to prevent broken links. + // E.g. some old hrefs point to like `start.html` so moving the file to `start/index.html` would break the link. + // But we also want the ability to have like `about/index.html` instead of `about.html` as well. + format: "preserve", + }, + vite: { + plugins: [ + viteStaticCopy({ + targets: [ + { + src: "_docs/**/*.{jpg,png,gif,json}", + dest: "docs", + rename: { stripBase: 1 }, // strips `_docs/` + }, + { + // TypeDoc-generated CSS/JS/icons the SDK API reference .html + // docs load via relative `assets/...` URLs. + src: "_docs/**/embedding/sdk/api/assets/*.{css,js,svg,ico}", + dest: "docs", + rename: { stripBase: 1 }, + }, + ], + }), + ], + }, + markdown: { + // Use `getMarkdownRenderer` instead for faster dev builds. There are + // thousands of docs md files, and astro processes the markdown for all of + // them even if you don't even navigate to a markdown-generated page. Also, + // docs md files use liquid syntax which must be processed before the + // markdown is converted to html. Processing liquid as part of a satteri + // plugin would be unnecessarily and frustratingly slow for dev builds + // since it would need to resolve all the includes for thousands of + // markdown files. + processor: noopMarkdownProcessor, + }, +}); diff --git a/bun.lock b/bun.lock index ff9f4c8069..33a767968e 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,7 @@ "@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@linthtml/linthtml": "^0.8.6", "@linthtml/linthtml-config-recommended": "^0.1.0", + "@types/glob": "^5.0.10", "@types/yamljs": "^0.2.34", "astro": "^7.0.9", "concurrently": "^10.0.3", @@ -44,6 +45,7 @@ "stylelint-config-standard-scss": "^4.0.0", "stylelint-order": "^5.0.0", "typescript": "^6.0.3", + "vite-plugin-static-copy": "^4.1.1", "wait-on": "^9.0.10", }, }, @@ -392,19 +394,23 @@ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/glob": ["@types/glob@5.0.38", "", { "dependencies": { "@types/minimatch": "*", "@types/node": "*" } }, "sha512-rTtf75rwyP9G2qO5yRpYtdJ6aU1QqEhWbtW55qEgquEDa6bXW0s2TWZfDm02GuppjEozOWG/F2UnPq5hAQb+gw=="], + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], "@types/is-empty": ["@types/is-empty@1.2.3", "", {}, "sha512-4J1l5d79hoIvsrKh5VUKVRA1aIdsOb10Hu5j3J2VfP/msDnfTdGPmNp2E1Wg+vs97Bktzo+MZePFFXSGoykYJw=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + "@types/minimatch": ["@types/minimatch@6.0.0", "", { "dependencies": { "minimatch": "*" } }, "sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA=="], + "@types/minimist": ["@types/minimist@1.2.5", "", {}, "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], - "@types/node": ["@types/node@18.19.100", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-ojmMP8SZBKprc3qGrGk8Ujpo80AXkrP7G2tOT4VWr5jlr5DHjsJF+emXJz+Wm0glmy4Js62oKMdZZ6B9Y+tEcA=="], + "@types/node": ["@types/node@22.15.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg=="], "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], @@ -514,6 +520,8 @@ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], @@ -974,6 +982,8 @@ "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], "is-buffer": ["is-buffer@2.0.5", "", {}, "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ=="], @@ -1348,6 +1358,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-map": ["p-map@7.0.5", "", {}, "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA=="], + "p-queue": ["p-queue@9.3.1", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-POWdiIPmsUPGwb4FeQ4OBg46aqmcInSWe45CKDsGHiOBiVQM9chqfQTuqhuTzcg2Vz9faTI65at0KkVyVEiCHw=="], "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], @@ -1746,7 +1758,7 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unified": ["unified@10.1.2", "", { "dependencies": { "@types/unist": "^2.0.0", "bail": "^2.0.0", "extend": "^3.0.0", "is-buffer": "^2.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^5.0.0" } }, "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q=="], @@ -1800,6 +1812,8 @@ "vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="], + "vite-plugin-static-copy": ["vite-plugin-static-copy@4.1.1", "", { "dependencies": { "chokidar": "^3.6.0", "p-map": "^7.0.4", "picocolors": "^1.1.1", "tinyglobby": "^0.2.17" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-GrlA8YklrAfSyxJ4M3fdQLOo9oNkp56IM9FYgX/WtEgeIFkPwhu4wzpufBCIuNKCa6Fn77FkRdYxkHqV0FwjAw=="], + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], "volar-service-css": ["volar-service-css@0.0.71", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw=="], @@ -1914,7 +1928,7 @@ "@npmcli/map-workspaces/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@types/concat-stream/@types/node": ["@types/node@22.15.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg=="], + "@types/minimatch/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@types/nlcst/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -2134,6 +2148,8 @@ "typescript-auto-import-cache/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "unified-engine/@types/node": ["@types/node@18.19.100", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-ojmMP8SZBKprc3qGrGk8Ujpo80AXkrP7G2tOT4VWr5jlr5DHjsJF+emXJz+Wm0glmy4Js62oKMdZZ6B9Y+tEcA=="], + "unified-engine/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], "unified-engine/parse-json": ["parse-json@6.0.2", "", { "dependencies": { "@babel/code-frame": "^7.16.0", "error-ex": "^1.3.2", "json-parse-even-better-errors": "^2.3.1", "lines-and-columns": "^2.0.2" } }, "sha512-SA5aMiaIjXkAiBrW/yPgLgQAQg42f7K3ACO+2l/zOvtQBwX58DMUsFJXelW2fx3yMBmWOVkR6j1MGsdSbCA4UA=="], @@ -2166,6 +2182,8 @@ "vite/postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], + "vite-plugin-static-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "volar-service-typescript/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "vscode-css-languageservice/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], @@ -2202,7 +2220,7 @@ "@npmcli/map-workspaces/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], - "@types/concat-stream/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@types/minimatch/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "ajv-i18n/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -2358,6 +2376,8 @@ "tar-stream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "unified-engine/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "unified-engine/glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], "unified-engine/parse-json/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], @@ -2372,6 +2392,10 @@ "vfile-reporter/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "vite-plugin-static-copy/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "vite-plugin-static-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "vite/postcss/nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], @@ -2384,6 +2408,8 @@ "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@types/minimatch/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "concurrently/yargs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], "concurrently/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -2444,6 +2470,8 @@ "vfile-reporter/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + "vite-plugin-static-copy/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "@astrojs/internal-helpers/unified/vfile/vfile-message/unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], "concurrently/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], diff --git a/docs/sitemap.xml b/docs/sitemap.xml deleted file mode 100644 index f749f15d30..0000000000 --- a/docs/sitemap.xml +++ /dev/null @@ -1,18 +0,0 @@ ---- -layout: null -title: Docs Sitemap -permalink: /docs/sitemap.xml ---- - - -{% assign latest_docs = site.docs | where_exp: "doc", "doc.url contains '/docs/latest/'" | sort: "url" %} -{% for doc in latest_docs %} - {% assign canonical = doc.canonical | default: "" %} - {% unless canonical contains "http" or doc.url contains ".json" %} - {% assign loc = doc.url | replace: "/index", "/" | replace: "/.html", "/" | replace: ".html", "" %} - - https://www.metabase.com{{ loc | xml_escape }} - - {% endunless %} -{% endfor %} - diff --git a/package.json b/package.json index 4da21f2f6d..17328e8a27 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@linthtml/linthtml": "^0.8.6", "@linthtml/linthtml-config-recommended": "^0.1.0", + "@types/glob": "^5.0.10", "@types/yamljs": "^0.2.34", "astro": "^7.0.9", "concurrently": "^10.0.3", @@ -88,6 +89,7 @@ "stylelint-config-standard-scss": "^4.0.0", "stylelint-order": "^5.0.0", "typescript": "^6.0.3", + "vite-plugin-static-copy": "^4.1.1", "wait-on": "^9.0.10" }, "resolutions": { diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000000..44aa8e6d69 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +export const DOCS_SRC_ROOT = "_docs"; diff --git a/src/content.config.ts b/src/content.config.ts index 3a204e761c..49c5563892 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,10 +1,22 @@ import { defineCollection } from "astro:content"; import { glob } from "astro/loaders"; +import { docsHtmlLoader } from "./lib/docs/docsHtmlLoader"; +import { DOCS_SRC_ROOT } from "./constants"; -const examples = defineCollection({ +const docs = defineCollection({ loader: glob({ - pattern: ["src/example-collection/**/*.md"], + pattern: ["**/*.md", "!**/embedding/sdk/api/snippets/**"], + base: DOCS_SRC_ROOT, + + // Preserves dots (.) in pathnames + generateId: ({ entry }) => entry.replace(/\.md$/, ""), }), }); -export const collections = { examples }; +// Raw, standalone HTML docs (TypeDoc-generated SDK API reference pages, +// per-version api.html ToC pages) that the glob() loader can't parse. +const docsHtml = defineCollection({ + loader: docsHtmlLoader(), +}); + +export const collections = { docs, docsHtml }; diff --git a/src/example-collection/example-doc.md b/src/example-collection/example-doc.md deleted file mode 100644 index fc840b018e..0000000000 --- a/src/example-collection/example-doc.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -version: v0.62 -has_magic_breadcrumbs: true -show_category_breadcrumb: true -show_title_breadcrumb: true -layout: new-docs -no_index_no_follow: true ---- - -# Example markdown astro page - -## Liquid include examples - -{% include plans-blockquote.html feature="Authenticated embeds" sdk=true is_plural=true%} - -{% include youtube.html id='yTRzCQeTmO8' %} - -{% include beta-blockquote.html %} - -## Custom include_file examples - -Whole file (remove snippet comments): - -```js -{% include_file "{{ dirname }}/sdk/snippets/next-js/app-router-authentication-api-route.ts" %} -``` - -Specific snippet: - -```js -{% include_file "{{ dirname }}/sdk/snippets/authentication/auth-config-base.tsx" snippet="example" %} -``` - -## Miscellaneous cases - -### Inline attribute lists - -``` -[Metabase Expert](/partners/){:target="_blank"} -``` - -gets rendered as [Metabase Expert](/partners/){:target="_blank"}. - -### Inline code snippet - -Inline backticks like `this` get rendered as inline code with no copy button. - -### Responsive tables - -The columns should stack on small screens. - -{% include_file "{{ dirname }}/sdk/api/snippets/ParameterChangePayload.md" snippet="properties" %} diff --git a/src/layouts/DefaultLayout.astro b/src/layouts/DefaultLayout.astro new file mode 100644 index 0000000000..b243c42ea4 --- /dev/null +++ b/src/layouts/DefaultLayout.astro @@ -0,0 +1,62 @@ +--- +import LiquidInclude from "@/components/LiquidInclude.astro"; +import { getLiquidRenderer } from "@/lib/liquid/liquidRenderer"; + +type Props = { + page: { + version?: string; + }; + dirname: string; + active?: string; +}; + +const { page, dirname, active } = Astro.props; + +const lq = getLiquidRenderer({ page, dirname }); + +const currentDocsVersion = await lq.render("{{ site.docs_version }}"); +--- + + + + + + + + +
    + { + page.version && page.version !== currentDocsVersion && ( +
    +
    + You are viewing the documentation for Metabase {page.version}. + The{" "} + + most recent docs version is {currentDocsVersion}. + +
    +
    + ) + } + + +
    + + + + + + + + + diff --git a/src/layouts/NewDocsLayout.astro b/src/layouts/NewDocsLayout.astro index 525ab42e45..3f1e220e1e 100644 --- a/src/layouts/NewDocsLayout.astro +++ b/src/layouts/NewDocsLayout.astro @@ -12,10 +12,9 @@ type Props = { const { page, dirname } = Astro.props; -/* Do not show breadcrumb on home page */ const parts = Astro.url.pathname.split("/"); -const showBreadcrumb = - parts[1] == "docs" && parts.length == 4 && parts[3] === "index.html"; +const isHomePage = parts[3] === "index" || !parts[3]; +const showBreadcrumb = !isHomePage; const lq = getLiquidRenderer({ page, dirname }); --- @@ -30,7 +29,7 @@ const lq = getLiquidRenderer({ page, dirname }); lq={lq} src="learn/top-bar.html" domain="docs" - show_breadcrumb="show_breadcrumb" + show_breadcrumb={showBreadcrumb} />
    diff --git a/src/layouts/OldDocsLayout.astro b/src/layouts/OldDocsLayout.astro new file mode 100644 index 0000000000..f8531f9043 --- /dev/null +++ b/src/layouts/OldDocsLayout.astro @@ -0,0 +1,99 @@ +--- +import LiquidInclude from "@/components/LiquidInclude.astro"; +import { getVersionSupport } from "@/lib/docs/versionSupport"; +import { getLiquidRenderer } from "@/lib/liquid/liquidRenderer"; +import DefaultLayout from "./DefaultLayout.astro"; + +type Props = { + page: { + category: string; + version: string; + url: string; + title?: string; + source_url?: string; + }; + dirname: string; +}; + +const { page, dirname } = Astro.props; + +const lq = getLiquidRenderer({ page, dirname }); + +const currentDocsVersion = await lq.render("{{ site.docs_version }}"); +const latestOrExplicitVersion = + page.version === currentDocsVersion && page.url.includes("/docs/latest/") + ? "latest" + : page.version; + +const support = getVersionSupport(page.version); + +const showChrome = page.category !== "ignore"; +--- + + +
    + + +
    + +
    +
    +
    +
    + { + showChrome && ( + + ) + } + + { + support?.status === "unsupported" && ( +
    + Version {page.version} of Metabase is no longer{" "} + supported. Check out the{" "} + + docs for the current stable version, Metabase{" "} + {currentDocsVersion}. + +
    + ) + } + + +
    +
    + { + showChrome && ( +
    + +
    + ) + } +
    +
    +
    diff --git a/src/lib/docs/collectRedirects.ts b/src/lib/docs/collectRedirects.ts new file mode 100644 index 0000000000..18b2e56344 --- /dev/null +++ b/src/lib/docs/collectRedirects.ts @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import path from "node:path"; +import glob from "glob"; +import matter from "gray-matter"; +import { DOCS_SRC_ROOT } from "../../constants"; +import { resolveDocUrl } from "./resolveDoc"; + +const EXCLUDE = ["**/embedding/sdk/api/snippets/**"]; + +type ScanTarget = { extension: "md" | "html"; stripExtension: boolean }; + +const SCAN_TARGETS: ScanTarget[] = [ + { extension: "md", stripExtension: true }, + { extension: "html", stripExtension: false }, +]; + +// Builds a flat { [oldPath]: canonicalUrl } map from every doc's +// `redirect_from` frontmatter, for use as Astro's `redirects` config. +export const collectRedirects = (): Record => { + const redirects: Record = {}; + const claimedBy = new Map(); + + for (const { extension, stripExtension } of SCAN_TARGETS) { + const base = path.resolve(DOCS_SRC_ROOT); + const entries: string[] = glob.sync(`**/*.${extension}`, { + cwd: base, + ignore: EXCLUDE, + }); + + for (const relPath of entries) { + const absPath = path.join(base, relPath); + const { data } = matter(fs.readFileSync(absPath, "utf8")); + const redirectFrom: string[] | undefined = data.redirect_from; + if (!redirectFrom?.length) continue; + + const id = stripExtension ? relPath.replace(/\.md$/, "") : relPath; + const { url } = resolveDocUrl({ id, permalink: data.permalink }); + + for (const source of redirectFrom) { + const existingOwner = claimedBy.get(source); + if (existingOwner && redirects[source] !== url) { + console.warn( + `[collectRedirects] "${source}" is claimed by both ${existingOwner} (-> ${redirects[source]}) and ${relPath} (-> ${url}); keeping the latest.`, + ); + } + claimedBy.set(source, relPath); + redirects[source] = url; + } + } + } + + return redirects; +}; diff --git a/src/lib/docs/docsHtmlLoader.ts b/src/lib/docs/docsHtmlLoader.ts new file mode 100644 index 0000000000..b31fd154fe --- /dev/null +++ b/src/lib/docs/docsHtmlLoader.ts @@ -0,0 +1,45 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { DOCS_SRC_ROOT } from "@/constants"; +import type { Loader } from "astro/loaders"; +import glob from "glob"; +import matter from "gray-matter"; + +// Astro's built-in glob() loader only parses frontmatter and body content for +// *markdown* files, but our docs root also contains HTML files (TypeDoc-generated +// SDK API reference pages and per-version api.html pages) that use the same +// frontmatter-plus-Liquid conventions as the Markdown docs. This custom loader +// lets us process both file types consistently. +export const docsHtmlLoader = (): Loader => ({ + name: "docs-html-loader", + load: async ({ config, store, parseData, generateDigest, logger }) => { + store.clear(); + + const base = new URL(`${DOCS_SRC_ROOT}/`, config.root); + const baseDir = fileURLToPath(base); + const rootDir = fileURLToPath(config.root); + const entries: string[] = glob.sync("**/*.html", { + cwd: baseDir, + ignore: ["**/embedding/sdk/api/snippets/**"], + }); + + for (const entry of entries) { + const absPath = path.join(baseDir, entry); + const contents = await fs.readFile(absPath, "utf-8"); + const { data, content: body } = matter(contents); + const id = entry; + + const parsedData = await parseData({ id, data, filePath: absPath }); + store.set({ + id, + data: parsedData, + body, + filePath: path.relative(rootDir, absPath).split(path.sep).join("/"), + digest: generateDigest(contents), + }); + } + + logger.info(`Loaded ${entries.length} html docs`); + }, +}); diff --git a/src/lib/docs/llmsTxt.ts b/src/lib/docs/llmsTxt.ts new file mode 100644 index 0000000000..36fedd7fdc --- /dev/null +++ b/src/lib/docs/llmsTxt.ts @@ -0,0 +1,349 @@ +// Astro port of `_plugins/jekyll_generate_llms_files_plugin.rb`, which ran as +// a Jekyll `post_write` hook. Docs are no longer rendered by Jekyll so this +// logic now lives here and is consumed by `src/pages/docs/[version]/llms.txt.ts` +// and `src/pages/docs/[version]/llms-[section]-full.txt.ts`. +// +// See: https://llmstxt.org for the spec. + +import fs from "node:fs"; +import path from "node:path"; +import { getCollection, type DataEntryMap } from "astro:content"; +import YAML from "yamljs"; + +export type Doc = DataEntryMap["docs"][number]; + +const REPO = "metabase/metabase"; + +// Sections to generate llms-{section}-full.txt for. +// These huge files are used by AI tools like Cursor for RAG chunking and indexing. +// Add more sections to let AI agents understand Metabase better. +// +// NOTE: adding a section here also requires adding a matching literal page +// file, e.g. `src/pages/docs/[version]/llms-{section}-full.txt.ts` re-using +// `generateFullContent`/`getFullSections` below (mirroring the two existing +// ones), since Astro needs a concrete route to build. + +// TODO: "agent-api" is not a folder so nothing gets output for it. This was an issue in the jekyll hook and left as-is in the astro migration. +export const LLMS_FULL_SECTIONS = ["embedding", "agent-api"] as const; +export type LlmsFullSection = (typeof LLMS_FULL_SECTIONS)[number]; + +// Paths to include in llms.txt generation. +// +// We focus on content relevant to coding with Metabase: +// 1. Embedding integration guides (modular embedding & SDK) +// 2. Embedding related setup and config (auth, SSO) +// +// Use prefix matching - a path matches if it starts with any of these. +// For specific files, include the full path. For directories, include trailing slash. +const INCLUDED_PATHS = [ + // All embedding docs (SDK, modular embedding, integration guides) + "embedding/", + + // Auth/SSO configuration for embedding + "people-and-groups/api-keys.md", + "people-and-groups/authenticating-with-jwt.md", + "people-and-groups/authenticating-with-saml.md", + "people-and-groups/saml-auth0.md", + "people-and-groups/saml-azure.md", + "people-and-groups/saml-google.md", + "people-and-groups/saml-keycloak.md", + "people-and-groups/saml-okta.md", + "people-and-groups/google-sign-in.md", + "people-and-groups/ldap.md", + + // Configuration reference + "configuring-metabase/environment-variables.md", + "configuring-metabase/config-file.md", + + // Agent API reference + "agent-api/", +]; + +// Paths to exclude from llms.txt generation (applied after allowlist) +const EXCLUDED_PATHS = ["embedding/sdk/api/snippets"]; + +const releaseBranch: string | undefined = YAML.parse( + fs.readFileSync(path.join(process.cwd(), "_config.yml"), "utf8"), +).release_branch; + +// Path relative to the doc's version root, with the `.md` extension +// restored (e.g. "embedding/authentication.md"), matching the old plugin's +// `doc.relative_path.sub(%r{^_docs/[^/]+/}, '')`. +const versionRelativePath = (doc: Doc): string => + `${doc.id.slice(doc.id.indexOf("/") + 1)}.md`; + +// Groups the `docs` content collection by version, keyed like the old +// `_docs/VERSION/...` directory structure (e.g. "latest", "v0.58"), sorted +// for consistent ordering across all generated files. Skips README.md files +// and docs directly under `_docs/` (e.g. `_docs/index.md`), matching the old +// plugin's `docs_by_version` grouping. +export const getDocsByVersion = async (): Promise> => { + const docs = await getCollection("docs"); + const byVersion = new Map(); + + for (const doc of docs) { + if (path.basename(doc.id) === "README") continue; + + const separatorIndex = doc.id.indexOf("/"); + if (separatorIndex === -1) continue; + + const version = doc.id.slice(0, separatorIndex); + const list = byVersion.get(version); + if (list) { + list.push(doc); + } else { + byVersion.set(version, [doc]); + } + } + + // Sort on the full `.md`-suffixed path (not `doc.id`), matching the old + // plugin's `sort_by!(&:relative_path)`. This matters whenever one doc's + // filename is a strict prefix of a sibling's (e.g. "full-app-embedding.md" + // vs "full-app-embedding-quick-start-guide.md") — comparing with the + // extension present sorts "-" (0x2D) before "." (0x2E), which flips the + // order you'd get comparing the bare, extension-less ids. + for (const list of byVersion.values()) { + list.sort((a, b) => { + const pathA = versionRelativePath(a); + const pathB = versionRelativePath(b); + // Plain codepoint comparison (not `localeCompare`) to match Ruby's + // byte-wise `<=>` used by the old plugin's `sort_by!`. + return pathA < pathB ? -1 : pathA > pathB ? 1 : 0; + }); + } + + return byVersion; +}; + +export const getFullSections = (docs: Doc[]): LlmsFullSection[] => + LLMS_FULL_SECTIONS.filter((section) => + docs.some((doc) => doc.id.includes(`/${section}/`)), + ); + +// Format version for display in generated files +// Examples: "v0.58" -> "58", "master" -> "development (unreleased)", "latest" -> "58 (latest)" +const formatVersionForDisplay = ( + version: string, + latestBranch?: string, +): string => { + if (version === "master") return "development (unreleased)"; + + if (version === "latest" && latestBranch) { + // Parse version from branch like "release-x.58.x" -> "58" + const branchMatch = latestBranch.match(/^release-x\.(\d+)\.x$/); + if (branchMatch) return `${branchMatch[1]} (latest)`; + } + + // Fallback in case the latest branch is not provided + if (version === "latest") return "latest"; + + // Parse version like "v0.58" -> "58" + const match = version.match(/^v0\.(\d+)$/); + return match ? match[1] : version; +}; + +// Convert Jekyll version format to branch name for raw GitHub URLs +// Examples: "v0.58" -> "release-x.58.x", "master" -> "master", "latest" -> release_branch from config +const versionToBranch = (version: string, latestBranch?: string): string => { + if (version === "master") return "master"; + if (version === "latest") return latestBranch ?? "master"; + + // Parse version like "v0.58" -> "release-x.58.x" + const match = version.match(/^v0\.(\d+)$/); + return match ? `release-x.${match[1]}.x` : "master"; +}; + +const aboveVersion = ( + sourceVersion: string, + targetVersion: number, +): boolean => { + // master and latest always get the notes + if (sourceVersion === "master" || sourceVersion === "latest") return true; + + // Parse version like "v0.58" -> 58 + const match = sourceVersion.match(/^v0\.(\d+)$/); + if (!match) return false; + + return Number(match[1]) >= targetVersion; +}; + +// Instructions for AI tools to detect the correct Metabase version. +// This is placed at the very top of generated files so LLMs see it first. +const getVersionDetectionInstructions = (): string => + `## IMPORTANT: Verify SDK and Metabase Version Compatibility + +The SDK version MUST match the Metabase instance version. Mismatched versions can cause errors. When looking up documentation, ALWAYS check the Metabase version. + +**Step 1: Ask the user for their Metabase instance URL** + +Before proceeding, ask the user where their Metabase instance is located. Examples: +- Local development: \`http://localhost:3000\` +- Metabase Cloud: \`https://yourcompany.metabaseapp.com\` +- Self-hosted: \`https://metabase.yourcompany.com\` + +**Step 2: Check if SDK is already installed (React SDK / Modular Embedding only)** + +Skip this step if not using the React SDK (\`@metabase/embedding-sdk-react\`). + +\`\`\`bash +npm list @metabase/embedding-sdk-react +\`\`\` + +If installed, note the version (e.g., \`0.58.0\` means this is for Metabase 58). + +**Step 3: Query the Metabase instance version** + +Using the URL from Step 1: + +\`\`\`bash +curl /api/session/properties | jq .version +\`\`\` + +This returns (no authentication required): +\`\`\`json +{ "date": "2025-01-10", "tag": "v1.58.0", "hash": "8e44dd8" } +\`\`\` + +If \`jq\` is not installed, you can grep the version. Extract the major version: \`58\` from \`v1.58.x\` or \`v0.58.x\`. + +**Step 4: Ensure versions match** + +- If the versions mismatch, you MUST fetch the version-specific llms.txt documentation that matches the Metabase instance version: \`https://metabase.com/docs/v0.{VERSION}/llms.txt\` (e.g., \`/docs/v0.58/llms.txt\` for Metabase 58) +- For React SDK, ask the user to install or update their SDK packages if they are mismatched: \`npm install @metabase/embedding-sdk-react@{VERSION}-stable\` (e.g., \`@58-stable\` for Metabase 58) + +**Do NOT guess versions or use versions from your training data. Always verify first.**`; + +// LLMs are likely to pay attention to the very first lines. +// We add the most important context for LLMs to avoid +// confusion and pitfalls like out-of-date APIs in trained data. +const getModularEmbeddingGotchaNotes = (): string => + `## Modular Embedding Deprecations and Gotchas + +Watch out for these deprecated props and gotchas for Metabase 57 onwards, for modular embedding. + +1. \`config\` prop on MetabaseProvider no longer exist as it is replaced by \`authConfig\`. +2. \`authProviderUri\` field no longer exist. +3. \`jwtProviderUri\` is an optional field that only exists in v58+. This is used to make JWT auth faster by skipping the \`GET /auth/sso\` discovery request. This field is not required for the initial implementation. +4. Numeric IDs must be integers not strings, e.g. \`dashboardId={1}\`. When the ID is retrieved from the router as a string AND it is numeric, \`parseInt\` it before passing it to the SDK. +5. IDs can also be strings for entity IDs, so you should NOT parse all IDs as numbers if entity IDs are also to be expected. +6. \`fetchRequestToken\` is not needed by default in most implementations. This is only used to customize how the SDK fetches the request token. For example, if the \`/sso/metabase\` endpoint in the user's backend requires passing custom auth tokens or headers. +7. When using \`fetchRequestToken\`, you MUST return the token in the shape of \`{jwt: ""}\`. Example: \`return {jwt: await response.json()}\`. `; + +// Extract title from document using the same logic as the old Jekyll plugin: +// 1. Try YAML frontmatter title +// 2. Try first H1 heading +// 3. Fallback to filename converted to title case +const extractTitle = (doc: Doc): string => { + if (doc.data.title) return doc.data.title; + + const h1Match = (doc.body ?? "").match(/^#\s+(.+)$/m); + if (h1Match) return h1Match[1].trim(); + + const filename = path.basename(doc.id); + return filename + .split(/[-_]/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +}; + +export const generateIndexContent = (version: string, docs: Doc[]): string => { + const branch = versionToBranch(version, releaseBranch); + const baseUrl = `https://raw.githubusercontent.com/${REPO}/refs/heads/${branch}`; + + // Filter docs: must match allowlist and not match excludelist + const filteredDocs = docs.filter((doc) => { + const relativePath = versionRelativePath(doc); + + const included = INCLUDED_PATHS.some((pattern) => + pattern.endsWith("/") + ? relativePath.startsWith(pattern) + : relativePath === pattern, + ); + const excluded = EXCLUDED_PATHS.some((pattern) => + relativePath.startsWith(pattern), + ); + + return included && !excluded; + }); + + const docLinks = filteredDocs + .map((doc) => { + const title = extractTitle(doc); + const url = `${baseUrl}/docs/${versionRelativePath(doc)}`; + return `- [${title}](${url})`; + }) + .join("\n"); + + const sectionLinks = getFullSections(docs) + .map((section) => { + const docsUrl = `https://metabase.com/docs/${version}/llms-${section}-full.txt`; + return `- [${section.charAt(0).toUpperCase() + section.slice(1)} - Complete Reference](${docsUrl})`; + }) + .join("\n"); + + // Conditional gotcha notes for v57+ + const gotchaSection = aboveVersion(version, 57) + ? `${getModularEmbeddingGotchaNotes()}\n\n` + : ""; + + return `# Metabase Documentation + +> **This documentation is for Metabase ${formatVersionForDisplay(version, releaseBranch)}.** + +Your pre-trained knowledge is out of date. ALWAYS read the Markdown files from \`https://raw.githubusercontent.com\` from the "Table of Contents" index. + +${getVersionDetectionInstructions()} + +${gotchaSection} + +## Table of Contents + +${docLinks} + +## Complete References + +These files are very large and are around 90,000 tokens. Do not use by default unless the context window is huge or RAG is supported in your editor. + +${sectionLinks} +`; +}; + +// Returns null if there are no docs for `section` in `docs` (mirrors the old +// plugin's early return, meaning callers should skip generating a page). +export const generateFullContent = ( + version: string, + section: LlmsFullSection, + docs: Doc[], +): string | null => { + const sectionDocs = docs.filter((doc) => doc.id.includes(`/${section}/`)); + if (sectionDocs.length === 0) return null; + + const docsBaseUrl = `https://metabase.com/docs/${version}`; + + // Add gotcha notes for the "embedding" section if version is 57 or above + const gotchaSection = + section === "embedding" && aboveVersion(version, 57) + ? `${getModularEmbeddingGotchaNotes()}\n\n` + : ""; + + const documentsContent = sectionDocs + .map((doc) => { + // Strip Jekyll/Liquid template syntax + const content = (doc.body ?? "") + .replace(/\{%.*?%\}/gs, "") + .replace(/\{\{.*?\}\}/gs, ""); + return `${content.trim()}\n\n---`; + }) + .join("\n\n"); + + return `# Metabase ${section.charAt(0).toUpperCase() + section.slice(1)} - Complete Reference for AI agents + +> **This documentation is for Metabase ${formatVersionForDisplay(version, releaseBranch)}.** +> +> Table of contents: ${docsBaseUrl}/llms.txt + +${getVersionDetectionInstructions()} + +${gotchaSection}${documentsContent} +`; +}; diff --git a/src/lib/docs/resolveDoc.ts b/src/lib/docs/resolveDoc.ts new file mode 100644 index 0000000000..38de3749f7 --- /dev/null +++ b/src/lib/docs/resolveDoc.ts @@ -0,0 +1,29 @@ +// Derives a doc's version/slug/URL from its content collection id (or +// frontmatter `permalink` override), since docs are stored as +// `/.md` but need a canonical `/docs//` URL for +// routing, sitemaps, and redirects. +export const resolveDocUrl = ({ + id, + permalink, + includeTrailingIndex, +}: { + id: string; + permalink?: string; + includeTrailingIndex?: boolean; +}): { version: string; slug: string; url: string } => { + let resolvedId = (permalink?.replace(/^\/docs\//, "") ?? id).replace( + /\.html$/, + "", + ); + if (!includeTrailingIndex) { + resolvedId = resolvedId.replace(/index$/, ""); + } + const separatorIndex = resolvedId.indexOf("/"); + const version = + (separatorIndex !== -1 + ? resolvedId.slice(0, separatorIndex) + : resolvedId) || "latest"; + const slug = + separatorIndex !== -1 ? resolvedId.slice(separatorIndex + 1) : ""; + return { version, slug, url: `/docs/${version}/${slug}` }; +}; diff --git a/src/lib/docs/versionSupport.ts b/src/lib/docs/versionSupport.ts new file mode 100644 index 0000000000..ea2e1da06e --- /dev/null +++ b/src/lib/docs/versionSupport.ts @@ -0,0 +1,65 @@ +// Looks up support status for a docs version (e.g. "v0.63") against +// _data/major_version_support.json, refreshed nightly from the +// `major_version_support` key of https://static.metabase.com/version-info.json +// by .github/workflows/update-version-support.yml +// +// Mirrors the logic previously provided by +// _plugins/jekyll_version_support_plugin.rb, which built a +// site.data.version_support lookup table at Jekyll build time. +// +// The data file only tracks the most recent majors, so anything older than +// the oldest tracked major is unsupported. Versions with no entry and no +// verdict (such as "latest" and "master") return null. + +import majorVersionSupport from "../../../_data/major_version_support.json"; + +type MajorVersionSupportEntry = { + major: number; + released?: string; + lts?: boolean; + eol?: string; +}; + +export type VersionSupport = { + status: "supported" | "unsupported"; + lts: boolean; +}; + +const entries = majorVersionSupport as MajorVersionSupportEntry[]; +const byMajor = new Map(entries.map((entry) => [entry.major, entry])); +const oldestTracked = Math.min(...byMajor.keys()); + +// "v0.63" -> 63 +const majorOf = (version: string): number | null => { + const match = /^v\d+\.(\d+)$/.exec(version); + return match ? Number(match[1]) : null; +}; + +export const getVersionSupport = (version: string): VersionSupport | null => { + const major = majorOf(version); + if (major === null) return null; + + const entry = byMajor.get(major); + if (!entry) { + return major < oldestTracked ? { status: "unsupported", lts: false } : null; + } + + const eol = entry.eol ? new Date(entry.eol) : null; + const status = + !eol || eol.getTime() > Date.now() ? "supported" : "unsupported"; + + return { status, lts: entry.lts ?? false }; +}; + +// Builds the full version -> support lookup table (e.g. for site.data.version_support +// in the Liquid context), mirroring VersionSupportGenerator#generate. +export const buildVersionSupportTable = ( + availableVersions: string[], +): Record => { + const table: Record = {}; + for (const version of availableVersions ?? []) { + const support = getVersionSupport(version); + if (support) table[version] = support; + } + return table; +}; diff --git a/src/lib/fn.ts b/src/lib/fn.ts new file mode 100644 index 0000000000..91d3d2e8ac --- /dev/null +++ b/src/lib/fn.ts @@ -0,0 +1,4 @@ +export const compose = + (...fns: ((arg: T) => T)[]) => + (initialValue: T) => + fns.reduceRight((acc, fn) => fn(acc), initialValue); diff --git a/src/lib/liquid/liquidRenderer.ts b/src/lib/liquid/liquidRenderer.ts index deed1d438b..e9bdab02a4 100644 --- a/src/lib/liquid/liquidRenderer.ts +++ b/src/lib/liquid/liquidRenderer.ts @@ -1,9 +1,45 @@ import fs from "node:fs"; import path from "node:path"; -import { Liquid } from "liquidjs"; +import { buildVersionSupportTable } from "@/lib/docs/versionSupport"; +import { compose } from "@/lib/fn"; +import { Liquid, ParseError, TokenizationError } from "liquidjs"; import YAML from "yamljs"; +import { registerCustomIncludeTag } from "./tags/customIncludeTag"; import { registerIncludeFileTag } from "./tags/includeFileTag"; +// Old markdown docs sometimes contain text that merely looks like Liquid +// (e.g. "{{#...}}" used to describe template tag syntax) but isn't valid +// Liquid. Jekyll/Ruby rendered these as blank rather than failing the build, +// so on a syntax error we cut out just the offending "{{ }}"/"{% %}" span and +// retry, instead of taking down the whole page. +const stripInvalidLiquidSpan = ( + source: string, + err: unknown, +): string | null => { + if (!(err instanceof TokenizationError) && !(err instanceof ParseError)) { + return null; + } + + let { begin, end } = err.token; + if (!(source.startsWith("{{", begin) || source.startsWith("{%", begin))) { + const outputOpen = source.lastIndexOf("{{", begin); + const tagOpen = source.lastIndexOf("{%", begin); + let openStart = outputOpen; + let closer = "}}"; + if (tagOpen > outputOpen) { + openStart = tagOpen; + closer = "%}"; + } + if (openStart === -1) return null; + const closeIdx = source.indexOf(closer, end); + if (closeIdx === -1) return null; + begin = openStart; + end = closeIdx + closer.length; + } + + return source.slice(0, begin) + source.slice(end); +}; + const ROOT = process.cwd(); const INCLUDES_ROOT = path.join(ROOT, "_includes"); @@ -27,10 +63,17 @@ const loadDataDir = (dir: string): Record => { return data; }; +const siteConfig = YAML.parse( + fs.readFileSync(path.join(ROOT, "_config.yml"), "utf8"), +); + const baseCtx = { site: { - ...YAML.parse(fs.readFileSync(path.join(ROOT, "_config.yml"), "utf8")), - data: loadDataDir(path.join(ROOT, "_data")), + ...siteConfig, + data: { + ...loadDataDir(path.join(ROOT, "_data")), + version_support: buildVersionSupportTable(siteConfig.available_versions), + }, }, jekyll: { environment: process.env.NODE_ENV || "development", @@ -39,6 +82,11 @@ const baseCtx = { let liquidEngine: Liquid; +// Remove multiple newlines between elements so satteri doesn't turn them into code snippets. +// Needed to preserve previous behavior (which used jekyll + kramdown). +const collapseBlankLines = (html: string): string => + html.replace(/>([ \t]*\r?\n){2,}[ \t]*\n<"); + export const getLiquidRenderer = ({ page, dirname, @@ -52,8 +100,12 @@ export const getLiquidRenderer = ({ jekyllInclude: true, jekyllWhere: true, strictVariables: false, // TODO: Would be nice to flip this to true + cache: import.meta.env.MODE === "production", }); - registerIncludeFileTag(liquidEngine); + compose( + registerIncludeFileTag, + registerCustomIncludeTag(collapseBlankLines), + )(liquidEngine); } const ctx = { @@ -63,14 +115,32 @@ export const getLiquidRenderer = ({ }; return { - render: ( + render: async ( html: string, { include }: { include?: Record } = {}, + { maxSyntaxErrors = 0 }: { maxSyntaxErrors?: number } = {}, ) => { - return liquidEngine.parseAndRender(html, { - ...ctx, - include, - }); + let source = html; + for (let attempt = 0; attempt < maxSyntaxErrors + 1; attempt++) { + try { + return await liquidEngine.parseAndRender(source, { + ...ctx, + include, + }); + } catch (err) { + const stripped = stripInvalidLiquidSpan(source, err); + if (stripped === null) throw err; + console.warn( + `[liquid] Ignoring invalid Liquid syntax in ${dirname}: ${ + (err as Error).message + }`, + ); + source = stripped; + } + } + throw new Error( + `[liquid] Too many invalid Liquid syntax errors in ${dirname}`, + ); }, }; }; diff --git a/src/lib/liquid/tags/customIncludeTag.ts b/src/lib/liquid/tags/customIncludeTag.ts new file mode 100644 index 0000000000..9959614714 --- /dev/null +++ b/src/lib/liquid/tags/customIncludeTag.ts @@ -0,0 +1,27 @@ +import { IncludeTag, Liquid, type Context, type Emitter } from "liquidjs"; + +// Overrides LiquidJS's built-in `include` tag so each included template's rendered HTML +// can be post-processed before it's written to the main output. + +class BufferingEmitter implements Emitter { + buffer = ""; + write(html: unknown) { + this.buffer += String(html); + } +} + +export const registerCustomIncludeTag = + (postProcessFn: (includeHtml: string) => string) => (engine: Liquid) => { + class CustomIncludeTag extends IncludeTag { + *render( + ctx: Context, + emitter: Emitter, + ): Generator { + const buffer = new BufferingEmitter(); + yield* super.render(ctx, buffer); + emitter.write(postProcessFn(buffer.buffer)); + } + } + engine.registerTag("include", CustomIncludeTag); + return engine; + }; diff --git a/src/lib/markdown/markdownRenderer.ts b/src/lib/markdown/markdownRenderer.ts index a6f0c275cb..0c045ce88f 100644 --- a/src/lib/markdown/markdownRenderer.ts +++ b/src/lib/markdown/markdownRenderer.ts @@ -1,13 +1,15 @@ import { satteri } from "@astrojs/markdown-satteri"; +import { codeDefaultsHastPlugin } from "./plugins/codeDefaultsHastPlugin"; import { ialHastPlugin } from "./plugins/ialHastPlugin"; -import { inlineCodeHastPlugin } from "./plugins/inlineCodeHastPlugin"; +import { relativeImagePlugin } from "./plugins/relativeImagePlugin"; import { responsiveTableLabelsHastPlugin } from "./plugins/responsiveTableLabelsHastPlugin"; const docsMarkdownProcessor = satteri({ hastPlugins: [ ialHastPlugin, - inlineCodeHastPlugin, + codeDefaultsHastPlugin, responsiveTableLabelsHastPlugin, + relativeImagePlugin, ], }); diff --git a/src/lib/markdown/noopMarkdownProcessor.ts b/src/lib/markdown/noopMarkdownProcessor.ts new file mode 100644 index 0000000000..b604bb598d --- /dev/null +++ b/src/lib/markdown/noopMarkdownProcessor.ts @@ -0,0 +1,17 @@ +import type { MarkdownProcessor } from "astro/markdown"; + +export const noopMarkdownProcessor: MarkdownProcessor = { + name: "no-op", + options: {}, + createRenderer: async () => ({ + render: async () => ({ + code: "", + metadata: { + headings: [], + localImagePaths: [], + remoteImagePaths: [], + frontmatter: {}, + }, + }), + }), +}; diff --git a/src/lib/markdown/plugins/codeDefaultsHastPlugin.ts b/src/lib/markdown/plugins/codeDefaultsHastPlugin.ts new file mode 100644 index 0000000000..8bf3f20a96 --- /dev/null +++ b/src/lib/markdown/plugins/codeDefaultsHastPlugin.ts @@ -0,0 +1,42 @@ +import { defineHastPlugin } from "satteri"; + +// Kramdown/Rouge (the old Jekyll pipeline) defaulted undeclared-language +// code to a plaintext lexer; Sätteri leaves it bare instead. Two knock-on +// effects this restores: +// - Inline spans get `language-plaintext highlighter-rouge` unconditionally, +// since js/new-docs-code-snippet-copy.js skips the copy-button overlay on +// `language-plaintext` and js/syntax-highlight.js only highlights +// `.highlighter-rouge` elements. +// - Fenced blocks with no declared language get hljs's own `nohighlight` +// marker instead, so `highlightAll()` doesn't auto-detect (and sometimes +// misdetect, e.g. a table read as SQL) a language for them. This is kept +// distinct from `language-plaintext` so blocks still get a copy button — +// only inline spans should lose it. +export const codeDefaultsHastPlugin = defineHastPlugin({ + name: "code-defaults", + element: { + filter: ["code"], + visit(node, ctx) { + const parent = ctx.parent(node); + const isBlock = + !!parent && "tagName" in parent && parent.tagName === "pre"; + const existing = Array.isArray(node.properties?.className) + ? (node.properties.className as unknown[]) + : []; + + if (isBlock) { + const hasLanguage = existing.some( + (cls) => typeof cls === "string" && cls.startsWith("language-"), + ); + if (hasLanguage) return; + ctx.setProperty(node, "className", [...existing, "nohighlight"]); + } else { + ctx.setProperty(node, "className", [ + ...existing, + "language-plaintext", + "highlighter-rouge", + ]); + } + }, + }, +}); diff --git a/src/lib/markdown/plugins/inlineCodeHastPlugin.ts b/src/lib/markdown/plugins/inlineCodeHastPlugin.ts deleted file mode 100644 index 32d1b8d856..0000000000 --- a/src/lib/markdown/plugins/inlineCodeHastPlugin.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { defineHastPlugin } from "satteri"; - -// Kramdown (Jekyll's Markdown converter) routes every code span through its -// syntax highlighter (Rouge in Jekyll's config), even inline ones with no -// declared language — Rouge falls back to its plaintext lexer and kramdown -// wraps the result as ``. -// CommonMark/Sätteri has no such hook for inline code (only fenced blocks -// carry a language), so plain `` comes out bare. Two site scripts rely -// on kramdown's classes being there: js/syntax-highlight.js only -// syntax-highlights `.highlighter-rouge` elements, and -// js/new-docs-code-snippet-copy.js attaches a copy-button overlay to every -// `` *except* ones classed `language-plaintext` — without this plugin, -// every inline code span across the site would get a copy button. -export const inlineCodeHastPlugin = defineHastPlugin({ - name: "inline-code", - element: { - filter: ["code"], - visit(node, ctx) { - const parent = ctx.parent(node); - if (parent && "tagName" in parent && parent.tagName === "pre") return; - const existing = Array.isArray(node.properties?.className) - ? (node.properties.className as unknown[]) - : []; - ctx.setProperty(node, "className", [ - ...existing, - "language-plaintext", - "highlighter-rouge", - ]); - }, - }, -}); diff --git a/src/lib/markdown/plugins/relativeImagePlugin.ts b/src/lib/markdown/plugins/relativeImagePlugin.ts new file mode 100644 index 0000000000..3a42f3ba94 --- /dev/null +++ b/src/lib/markdown/plugins/relativeImagePlugin.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { DOCS_SRC_ROOT } from "@/constants"; +import { defineHastPlugin } from "satteri"; + +// Resolves relative images from docs md files. +// The images themselves are copied via viteStaticCopy in astro.config.mjs. + +export const relativeImagePlugin = defineHastPlugin({ + name: "relative-image-resolver", + element: { + filter: ["img"], + visit(node, ctx) { + const rawSrc = node.properties?.src; + if (typeof rawSrc !== "string" || rawSrc === "") return; + // Absolute site paths, absolute URLs, and data: URIs need no rewriting. + if (rawSrc.startsWith("/") || URL.canParse(rawSrc)) return; + if (!ctx.fileURL) return; + + const absPath = fileURLToPath(new URL(decodeURI(rawSrc), ctx.fileURL)); + const relPath = path.relative(DOCS_SRC_ROOT, absPath); + if (relPath.startsWith("..") || path.isAbsolute(relPath)) return; + + const newSrc = `/docs/${relPath.split(path.sep).map(encodeURIComponent).join("/")}`; + ctx.setProperty(node, "src", newSrc); + }, + }, +}); diff --git a/src/pages/docs/[version]/[...slug].astro b/src/pages/docs/[version]/[...slug].astro new file mode 100644 index 0000000000..46e60ca6a5 --- /dev/null +++ b/src/pages/docs/[version]/[...slug].astro @@ -0,0 +1,76 @@ +--- +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import NewDocsLayout from "@/layouts/NewDocsLayout.astro"; +import OldDocsLayout from "@/layouts/OldDocsLayout.astro"; +import { resolveDocUrl } from "@/lib/docs/resolveDoc"; +import { getLiquidRenderer } from "@/lib/liquid/liquidRenderer"; +import { getMarkdownRenderer } from "@/lib/markdown/markdownRenderer"; +import { getCollection, type DataEntryMap } from "astro:content"; + +type Props = + | { kind: "md"; doc: DataEntryMap["docs"][number] } + | { kind: "html"; doc: DataEntryMap["docsHtml"][number] }; + +export const getStaticPaths = async () => { + const [docs, docsHtml] = await Promise.all([ + getCollection("docs"), + getCollection("docsHtml"), + ]); + + const toPath = + (kind: Kind) => + (doc: { id: string; data: { permalink?: string } }) => { + const { version, slug } = resolveDocUrl({ + id: doc.id, + permalink: doc.data.permalink, + // For prod builds, we want to output like folder/index.html, but for the dev server, the route should exclude /index + includeTrailingIndex: import.meta.env.MODE !== "development", + }); + return { props: { kind, doc }, params: { version, slug } }; + }; + + return [...docs.map(toPath("md")), ...docsHtml.map(toPath("html"))]; +}; + +const { version, slug = "" } = Astro.params; +const { kind, doc } = Astro.props; +const dirname = path.dirname(doc.filePath!); + +// Process liquid first (e.g. control flow, includes, variables, etc) +const lq = getLiquidRenderer({ page: doc.data, dirname }); +const lqOutput = await lq.render(doc.body!, undefined, { + maxSyntaxErrors: version === "latest" ? 0 : 3, +}); + +// `.html` docs (TypeDoc SDK API reference pages, api.html ToC pages) are +// already complete standalone HTML documents — only Liquid needs to run on +// them (e.g. the embedded-analytics-sdk-metadata include), no markdown +// conversion, and no NewDocsLayout chrome (mirrors the old Jekyll +// `docs-api` layout, which was a bare passthrough). +let renderedHtml: string; +if (kind === "html") { + renderedHtml = lqOutput; +} else { + const md = await getMarkdownRenderer(); + const renderResult = await md.render(lqOutput, { + fileURL: pathToFileURL(path.resolve(doc.filePath!)), + }); + renderedHtml = renderResult.code; +} + +const Layout = doc.data.layout === "docs" ? OldDocsLayout : NewDocsLayout; +--- + +{ + kind === "html" ? ( + + ) : ( + + + + ) +} diff --git a/src/pages/docs/[version]/llms-[section]-full.txt.ts b/src/pages/docs/[version]/llms-[section]-full.txt.ts new file mode 100644 index 0000000000..e2ad002540 --- /dev/null +++ b/src/pages/docs/[version]/llms-[section]-full.txt.ts @@ -0,0 +1,30 @@ +import { + generateFullContent, + getDocsByVersion, + getFullSections, + type Doc, + type LlmsFullSection, +} from "@/lib/docs/llmsTxt"; +import type { APIRoute, GetStaticPaths } from "astro"; + +export const getStaticPaths: GetStaticPaths = async () => { + const docsByVersion = await getDocsByVersion(); + return [...docsByVersion.entries()].flatMap(([version, docs]) => + getFullSections(docs).map((section) => ({ + params: { version, section }, + props: { docs }, + })), + ); +}; + +export const GET: APIRoute = async ({ params, props }) => { + const { docs } = props as { docs: Doc[] }; + const content = generateFullContent( + params.version!, + params.section as LlmsFullSection, + docs, + )!; + return new Response(content, { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +}; diff --git a/src/pages/docs/[version]/llms.txt.ts b/src/pages/docs/[version]/llms.txt.ts new file mode 100644 index 0000000000..8aae2d778d --- /dev/null +++ b/src/pages/docs/[version]/llms.txt.ts @@ -0,0 +1,21 @@ +import { + generateIndexContent, + getDocsByVersion, + type Doc, +} from "@/lib/docs/llmsTxt"; +import type { APIRoute, GetStaticPaths } from "astro"; + +export const getStaticPaths: GetStaticPaths = async () => { + const docsByVersion = await getDocsByVersion(); + return [...docsByVersion.entries()].map(([version, docs]) => ({ + params: { version }, + props: { docs }, + })); +}; + +export const GET: APIRoute = async ({ params, props }) => { + const { docs } = props as { docs: Doc[] }; + return new Response(generateIndexContent(params.version!, docs), { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +}; diff --git a/src/pages/docs/astro-test/[...slug].astro b/src/pages/docs/astro-test/[...slug].astro deleted file mode 100644 index 88eb3d98eb..0000000000 --- a/src/pages/docs/astro-test/[...slug].astro +++ /dev/null @@ -1,41 +0,0 @@ ---- -import NewDocsLayout from "@/layouts/NewDocsLayout.astro"; -import { getLiquidRenderer } from "@/lib/liquid/liquidRenderer"; -import { getMarkdownRenderer } from "@/lib/markdown/markdownRenderer"; -import { getCollection, type DataEntryMap } from "astro:content"; - -type Props = { - example: DataEntryMap["examples"][number]; -}; - -export const getStaticPaths = async () => { - const examples = await getCollection("examples"); - return [ - ...examples.map((example) => ({ - props: { example }, - params: { slug: example.id.replace(/^src\//, "") }, - })), - ]; -}; - -const { example } = Astro.props; - -// This is for debug purposes only. When rendering _docs, the dirname will be the source md file's path. -const dirname = "_docs/latest/embedding"; - -// Process liquid first (e.g. control flow, includes, variables, etc) -const lq = getLiquidRenderer({ page: example.data, dirname }); -const lqOutput = await lq.render(example.body!); - -// Remove whitespace between elements so satteri doesn't turn them into code snippets. -// Mimics previous behavior (which used jekyll + kramdown). -const lqProcessed = lqOutput.replace(/>\s+<"); - -// Convert processed markdown to html -const md = await getMarkdownRenderer(); -const renderResult = await md.render(lqProcessed); ---- - - - - diff --git a/src/pages/docs/sitemap.xml.ts b/src/pages/docs/sitemap.xml.ts new file mode 100644 index 0000000000..695305541c --- /dev/null +++ b/src/pages/docs/sitemap.xml.ts @@ -0,0 +1,31 @@ +import { resolveDocUrl } from "@/lib/docs/resolveDoc"; +import type { APIRoute } from "astro"; +import { getCollection } from "astro:content"; + +const SITE_URL = import.meta.env.SITE; + +const isLatest = ({ id }: { id: string }) => + id === "latest" || id.startsWith("latest/"); + +export const GET: APIRoute = async () => { + const [docs, docsHtml] = await Promise.all([ + getCollection("docs", isLatest), + getCollection("docsHtml", isLatest), + ]); + + const paths = [...docs, ...docsHtml] + .map( + (doc) => resolveDocUrl({ id: doc.id, permalink: doc.data.permalink }).url, + ) + .sort(); + + const body = ` + +${paths.map((path) => ` \n ${`${SITE_URL}${path}`}\n `).join("\n")} + +`; + + return new Response(body, { + headers: { "Content-Type": "application/xml" }, + }); +};