diff --git a/.github/workflows/validate-docs.yml b/.github/workflows/validate-docs.yml
new file mode 100644
index 00000000..cf5f2eb5
--- /dev/null
+++ b/.github/workflows/validate-docs.yml
@@ -0,0 +1,142 @@
+name: Validate Documentation
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened] # Run on PR open AND every commit
+ branches:
+ - main
+ paths:
+ - '**.mdx'
+ - '**.md'
+ - 'images/**'
+ - 'scripts/**'
+ - '.github/workflows/validate-docs.yml'
+
+jobs:
+ validate:
+ name: Check Links and Images
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+
+ - name: Check for broken links
+ id: check-links
+ run: |
+ echo "::group::Checking for broken links"
+ node scripts/check-links.js > /tmp/check-links-output.txt 2>&1 || echo "issues_found=true" >> $GITHUB_OUTPUT
+ cat /tmp/check-links-output.txt
+ echo "::endgroup::"
+ continue-on-error: true
+
+ - name: Check image locations
+ id: check-images
+ run: |
+ echo "::group::Checking image locations"
+ node scripts/check-image-locations.js > /tmp/check-images-output.txt 2>&1 || echo "issues_found=true" >> $GITHUB_OUTPUT
+ cat /tmp/check-images-output.txt
+ echo "::endgroup::"
+ continue-on-error: true
+
+ - name: Post or update PR comment
+ if: always() # Always run to update comment even if issues are fixed
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+
+ // Build comment body
+ let comment = '## š Documentation Validation Report\n\n';
+
+ const hasIssues = '${{ steps.check-links.outputs.issues_found }}' === 'true' ||
+ '${{ steps.check-images.outputs.issues_found }}' === 'true';
+
+ if (hasIssues) {
+ comment += 'ā ļø Some issues were found in this PR. These are **informational warnings** and will not block merging.\n\n';
+ } else {
+ comment += 'ā
All validation checks passed!\n\n';
+ }
+
+ if ('${{ steps.check-links.outputs.issues_found }}' === 'true') {
+ const linksOutput = fs.readFileSync('/tmp/check-links-output.txt', 'utf8');
+ const lines = linksOutput.split('\n');
+ const brokenLinks = lines.filter(line => line.includes('Broken link:') || line.includes('š')).slice(0, 10);
+
+ comment += '### š Links\n';
+ if (brokenLinks.length > 0) {
+ comment += 'ā Found broken links (click to expand)
\n\n';
+ comment += '```\n' + brokenLinks.join('\n') + '\n```\n';
+ comment += ' \n\n';
+ }
+ } else {
+ comment += '### š Links\nā
All links valid\n\n';
+ }
+
+ if ('${{ steps.check-images.outputs.issues_found }}' === 'true') {
+ const imagesOutput = fs.readFileSync('/tmp/check-images-output.txt', 'utf8');
+ const lines = imagesOutput.split('\n');
+ const imageIssues = lines.filter(line => line.includes('š') || line.includes('Expected in:')).slice(0, 10);
+
+ comment += '### š¼ļø Images\n';
+ if (imageIssues.length > 0) {
+ comment += 'ā Found image location issues (click to expand)
\n\n';
+ comment += '```\n' + imageIssues.join('\n') + '\n```\n';
+ comment += ' \n\n';
+ }
+ } else {
+ comment += '### š¼ļø Images\nā
All images in correct locations\n\n';
+ }
+
+ comment += '---\n';
+ comment += 'š” **Quick Reference:**\n';
+ comment += '- A page at `guides/dashboard.mdx` should use images from `images/guides/dashboard/`\n';
+ comment += '- Shared images can be placed in parent directories\n';
+ comment += '- All internal links must point to existing files\n\n';
+ comment += 'š Run `node scripts/check-links.js` and `node scripts/check-image-locations.js` locally for full details.\n';
+
+ // Find existing comment from this bot
+ const { data: comments } = await github.rest.issues.listComments({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ });
+
+ const botComment = comments.find(c =>
+ c.user.type === 'Bot' && c.body.includes('š Documentation Validation Report')
+ );
+
+ // Update existing or create new
+ if (botComment) {
+ await github.rest.issues.updateComment({
+ comment_id: botComment.id,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: comment
+ });
+ } else {
+ await github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: comment
+ });
+ }
+
+ - name: Report results
+ if: always()
+ run: |
+ if [ "${{ steps.check-links.outputs.issues_found }}" == "true" ] || [ "${{ steps.check-images.outputs.issues_found }}" == "true" ]; then
+ echo "## Documentation Validation Warnings ā ļø" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "Issues found - see PR comment for details" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "## Documentation Validation Passed ā
" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "All links are valid and images are in the correct locations!" >> $GITHUB_STEP_SUMMARY
+ fi
diff --git a/get-started/develop-in-lightdash/adding-tables-to-lightdash.mdx b/get-started/develop-in-lightdash/adding-tables-to-lightdash.mdx
index 0ab7893f..debb009a 100644
--- a/get-started/develop-in-lightdash/adding-tables-to-lightdash.mdx
+++ b/get-started/develop-in-lightdash/adding-tables-to-lightdash.mdx
@@ -159,7 +159,7 @@ Here's an example of our dbt project at Lightdash too see what that looks like i
- We have one `.yml` file per model (these are the files where all of your Tables' configuration sits)
-
+
**But, in my dbt project, I have a single schema.yml file. Not one for each model. Will that still work?**
diff --git a/get-started/exploring-data/using-explores.mdx b/get-started/exploring-data/using-explores.mdx
index 1d11f057..2063ec22 100644
--- a/get-started/exploring-data/using-explores.mdx
+++ b/get-started/exploring-data/using-explores.mdx
@@ -107,7 +107,7 @@ Saved Charts allow you to save a specific chart or table so you can share, add i
When you open a saved chart, it will always update to display the latest data in your database since it will re-run the query each time you open it.
- 
+ 
To save a chart, click the `Save chart` button at the top of the page, then give your chart a useful name and description. You'll have the option to save the chart to a Dashboard or a Space.
diff --git a/guides/embedding/dashboards.mdx b/guides/embedding/dashboards.mdx
index cf51ea0e..42aad223 100644
--- a/guides/embedding/dashboards.mdx
+++ b/guides/embedding/dashboards.mdx
@@ -444,7 +444,7 @@ Filter data based on the viewing user's properties. See the [user attributes ref
Embed individual charts for focused displays
-
+
Show different data to different users
diff --git a/guides/table-calculations.mdx b/guides/table-calculations.mdx
index 6db2b093..d154f96c 100644
--- a/guides/table-calculations.mdx
+++ b/guides/table-calculations.mdx
@@ -3,7 +3,7 @@ title: "Table calculations"
description: "Table calculations make it easy to create metrics on the fly (for example, aggregating the data in your table over some window, or getting a running total of a column)."
---
-You add table calculations in the Explore view and they appear as green columns in your results table (remember, [dimensions](references/dimensions) are blue, and [metrics](references/metrics) are orange).
+You add table calculations in the Explore view and they appear as green columns in your results table (remember, [dimensions](/references/dimensions) are blue, and [metrics](/references/metrics) are orange).
@@ -24,7 +24,7 @@ Table calculations work as an additional computational layer that builds on top
- Other table calculations (you can even stack them)
-Table calculations are useful for specialized, temporary needs, but if you find yourself repeatedly reusing a table calculation, it's a sign to add it to dbt so it's tracked in the semantic layer. This keeps your organization's metrics centralized and consistently defined. You can read more about [adding metrics to your dbt project here](references/metrics).
+Table calculations are useful for specialized, temporary needs, but if you find yourself repeatedly reusing a table calculation, it's a sign to add it to dbt so it's tracked in the semantic layer. This keeps your organization's metrics centralized and consistently defined. You can read more about [adding metrics to your dbt project here](/references/metrics).
## Creating table calculations
@@ -38,7 +38,7 @@ Table calculations work with whatever data you've already pulled into your resul
Quick calculations are shortcuts to the most common table calculations, these are only available to metrics in the contextual menu in the results table.
-
+
To learn more about what these calculations are doing, check out the docs [here](/guides/table-calculations/sql-templates). Once the table calculation is generated you can edit it to modify the SQL query or update the format.
@@ -48,7 +48,7 @@ To learn more about what these calculations are doing, check out the docs [here]
Once you've got some data in your results table, you can create a table calculation by clicking on the `+ Table calculation` in the `Results` tab of the Explore view:
- 
+ 
##### Write the SQL for your table calculation in the pop-up box
@@ -58,7 +58,7 @@ Your table calculation is defined using raw SQL that you write up in this pop up
To reference the metrics and dimensions in your results table, you can either use the autocomplete, or you can manually write the full field name using the format `${table_name.field_name}`.
- 
+ 
@@ -67,7 +67,7 @@ To reference the metrics and dimensions in your results table, you can either us
If needed, you can update the format of your table calculation to things like percent formatting using the `format` tab.
- 
+ 
| Format types | Description | Raw value | How it might look like in Lightdash |
@@ -82,7 +82,7 @@ If needed, you can update the format of your table calculation to things like pe
If you need to edit or delete a table calculation, you can just click on the toggle beside the field and do what you need to do!
- 
+ 
@@ -102,7 +102,7 @@ Since all calculations are performed before filtering, table calculation filters
For example: You use table calculations to calculate the `percentage_of_shipping_method_amount`, then filter to hide rows below 30%. The filtered rows (shown in purple) will disappear but the `percentage_total` calculation still shows 100% even though the visible percentages in the `percentage_of_shipping_method_amount` column no longer add up to 100%.
-
+
## Table calculation SQL templates
diff --git a/images/guides/dbt-repo-example.png b/images/get-started/develop-in-lightdash/adding-tables-to-lightdash/dbt-repo-example.png
similarity index 100%
rename from images/guides/dbt-repo-example.png
rename to images/get-started/develop-in-lightdash/adding-tables-to-lightdash/dbt-repo-example.png
diff --git a/images/get-started/exploring-data/dashboards/save-your-chart.png b/images/get-started/exploring-data/using-explores/save-your-chart.png
similarity index 100%
rename from images/get-started/exploring-data/dashboards/save-your-chart.png
rename to images/get-started/exploring-data/using-explores/save-your-chart.png
diff --git a/images/references/add-table-calc-sql-20de5398dc117ef3d4087cf3b6d9939b.jpg b/images/guides/table-calculations/add-table-calc-sql-20de5398dc117ef3d4087cf3b6d9939b.jpg
similarity index 100%
rename from images/references/add-table-calc-sql-20de5398dc117ef3d4087cf3b6d9939b.jpg
rename to images/guides/table-calculations/add-table-calc-sql-20de5398dc117ef3d4087cf3b6d9939b.jpg
diff --git a/images/references/create-new-table-calc-772add6d063a5122c0a25accf798fbb3.jpg b/images/guides/table-calculations/create-new-table-calc-772add6d063a5122c0a25accf798fbb3.jpg
similarity index 100%
rename from images/references/create-new-table-calc-772add6d063a5122c0a25accf798fbb3.jpg
rename to images/guides/table-calculations/create-new-table-calc-772add6d063a5122c0a25accf798fbb3.jpg
diff --git a/images/references/edit-delete-table-calc-520776c8631697527bf3760000dce88e.jpg b/images/guides/table-calculations/edit-delete-table-calc-520776c8631697527bf3760000dce88e.jpg
similarity index 100%
rename from images/references/edit-delete-table-calc-520776c8631697527bf3760000dce88e.jpg
rename to images/guides/table-calculations/edit-delete-table-calc-520776c8631697527bf3760000dce88e.jpg
diff --git a/images/references/format-table-calc-c0e3828b6681db084b4ddd6799ff2c96.jpg b/images/guides/table-calculations/format-table-calc-c0e3828b6681db084b4ddd6799ff2c96.jpg
similarity index 100%
rename from images/references/format-table-calc-c0e3828b6681db084b4ddd6799ff2c96.jpg
rename to images/guides/table-calculations/format-table-calc-c0e3828b6681db084b4ddd6799ff2c96.jpg
diff --git a/images/references/quick-table-calculation-b1e76d3e54d84ca3c5066ffdf989514b.png b/images/guides/table-calculations/quick-table-calculation-b1e76d3e54d84ca3c5066ffdf989514b.png
similarity index 100%
rename from images/references/quick-table-calculation-b1e76d3e54d84ca3c5066ffdf989514b.png
rename to images/guides/table-calculations/quick-table-calculation-b1e76d3e54d84ca3c5066ffdf989514b.png
diff --git a/images/references/table_calculation_filter_example.jpg b/images/guides/table-calculations/table_calculation_filter_example.jpg
similarity index 100%
rename from images/references/table_calculation_filter_example.jpg
rename to images/guides/table-calculations/table_calculation_filter_example.jpg
diff --git a/images/references/integrations/lightdash-mcp/mcp/chatgpt-01-click-profile-settings.png b/images/references/integrations/lightdash-mcp/chatgpt-01-click-profile-settings.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/chatgpt-01-click-profile-settings.png
rename to images/references/integrations/lightdash-mcp/chatgpt-01-click-profile-settings.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/chatgpt-02-select-connectors-then-create.png b/images/references/integrations/lightdash-mcp/chatgpt-02-select-connectors-then-create.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/chatgpt-02-select-connectors-then-create.png
rename to images/references/integrations/lightdash-mcp/chatgpt-02-select-connectors-then-create.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/chatgpt-03-fill-out-name-desc-mcp-url-select-oauth.png b/images/references/integrations/lightdash-mcp/chatgpt-03-fill-out-name-desc-mcp-url-select-oauth.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/chatgpt-03-fill-out-name-desc-mcp-url-select-oauth.png
rename to images/references/integrations/lightdash-mcp/chatgpt-03-fill-out-name-desc-mcp-url-select-oauth.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/chatgpt-04-oauth-flow-via-lightdsah.png b/images/references/integrations/lightdash-mcp/chatgpt-04-oauth-flow-via-lightdsah.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/chatgpt-04-oauth-flow-via-lightdsah.png
rename to images/references/integrations/lightdash-mcp/chatgpt-04-oauth-flow-via-lightdsah.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/claude-ai-01-click-profile-settings.png b/images/references/integrations/lightdash-mcp/claude-ai-01-click-profile-settings.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/claude-ai-01-click-profile-settings.png
rename to images/references/integrations/lightdash-mcp/claude-ai-01-click-profile-settings.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/claude-ai-02-select-connectors-then-add-custom-connector.png b/images/references/integrations/lightdash-mcp/claude-ai-02-select-connectors-then-add-custom-connector.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/claude-ai-02-select-connectors-then-add-custom-connector.png
rename to images/references/integrations/lightdash-mcp/claude-ai-02-select-connectors-then-add-custom-connector.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/claude-ai-03-fill-out-name-and-mcp-url.png b/images/references/integrations/lightdash-mcp/claude-ai-03-fill-out-name-and-mcp-url.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/claude-ai-03-fill-out-name-and-mcp-url.png
rename to images/references/integrations/lightdash-mcp/claude-ai-03-fill-out-name-and-mcp-url.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/claude-ai-04-click-connect.png b/images/references/integrations/lightdash-mcp/claude-ai-04-click-connect.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/claude-ai-04-click-connect.png
rename to images/references/integrations/lightdash-mcp/claude-ai-04-click-connect.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/claude-ai-05-oauth-flow-via-lightdsah.png b/images/references/integrations/lightdash-mcp/claude-ai-05-oauth-flow-via-lightdsah.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/claude-ai-05-oauth-flow-via-lightdsah.png
rename to images/references/integrations/lightdash-mcp/claude-ai-05-oauth-flow-via-lightdsah.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/claude-ai-06-OPTIONAL-configure.png b/images/references/integrations/lightdash-mcp/claude-ai-06-OPTIONAL-configure.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/claude-ai-06-OPTIONAL-configure.png
rename to images/references/integrations/lightdash-mcp/claude-ai-06-OPTIONAL-configure.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/claude-code-01-slash-mcp-command.png b/images/references/integrations/lightdash-mcp/claude-code-01-slash-mcp-command.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/claude-code-01-slash-mcp-command.png
rename to images/references/integrations/lightdash-mcp/claude-code-01-slash-mcp-command.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/claude-code-02-select-lightdash-navigating-using-arrow-keys.png b/images/references/integrations/lightdash-mcp/claude-code-02-select-lightdash-navigating-using-arrow-keys.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/claude-code-02-select-lightdash-navigating-using-arrow-keys.png
rename to images/references/integrations/lightdash-mcp/claude-code-02-select-lightdash-navigating-using-arrow-keys.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/claude-code-03-select-authenticate-hit-enter.png b/images/references/integrations/lightdash-mcp/claude-code-03-select-authenticate-hit-enter.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/claude-code-03-select-authenticate-hit-enter.png
rename to images/references/integrations/lightdash-mcp/claude-code-03-select-authenticate-hit-enter.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/claude-code-04-complete-oauth-flow-via-lightdash.png b/images/references/integrations/lightdash-mcp/claude-code-04-complete-oauth-flow-via-lightdash.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/claude-code-04-complete-oauth-flow-via-lightdash.png
rename to images/references/integrations/lightdash-mcp/claude-code-04-complete-oauth-flow-via-lightdash.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/claude-code-05-start-prompting-after-successful-auth.png b/images/references/integrations/lightdash-mcp/claude-code-05-start-prompting-after-successful-auth.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/claude-code-05-start-prompting-after-successful-auth.png
rename to images/references/integrations/lightdash-mcp/claude-code-05-start-prompting-after-successful-auth.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/examples-01-verifying-mcp-connection.png b/images/references/integrations/lightdash-mcp/examples-01-verifying-mcp-connection.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/examples-01-verifying-mcp-connection.png
rename to images/references/integrations/lightdash-mcp/examples-01-verifying-mcp-connection.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/examples-02-setting-up-project-and-finding-dashboards.png b/images/references/integrations/lightdash-mcp/examples-02-setting-up-project-and-finding-dashboards.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/examples-02-setting-up-project-and-finding-dashboards.png
rename to images/references/integrations/lightdash-mcp/examples-02-setting-up-project-and-finding-dashboards.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/examples-03-finding-explores-and-fields-and-executing-metric-query-to-analyze-data.png b/images/references/integrations/lightdash-mcp/examples-03-finding-explores-and-fields-and-executing-metric-query-to-analyze-data.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/examples-03-finding-explores-and-fields-and-executing-metric-query-to-analyze-data.png
rename to images/references/integrations/lightdash-mcp/examples-03-finding-explores-and-fields-and-executing-metric-query-to-analyze-data.png
diff --git a/images/references/integrations/lightdash-mcp/mcp/setting-up-custom-instructions-01-claude.png b/images/references/integrations/lightdash-mcp/setting-up-custom-instructions-01-claude.png
similarity index 100%
rename from images/references/integrations/lightdash-mcp/mcp/setting-up-custom-instructions-01-claude.png
rename to images/references/integrations/lightdash-mcp/setting-up-custom-instructions-01-claude.png
diff --git a/images/references/workspace/customizing-the-appearance-of-your-project/appearance/light-dark-mode-palette.gif b/images/references/workspace/customizing-the-appearance-of-your-project/light-dark-mode-palette.gif
similarity index 100%
rename from images/references/workspace/customizing-the-appearance-of-your-project/appearance/light-dark-mode-palette.gif
rename to images/references/workspace/customizing-the-appearance-of-your-project/light-dark-mode-palette.gif
diff --git a/images/references/workspace/customizing-the-appearance-of-your-project/appearance/list-color-palettes.png b/images/references/workspace/customizing-the-appearance-of-your-project/list-color-palettes.png
similarity index 100%
rename from images/references/workspace/customizing-the-appearance-of-your-project/appearance/list-color-palettes.png
rename to images/references/workspace/customizing-the-appearance-of-your-project/list-color-palettes.png
diff --git a/images/references/workspace/customizing-the-appearance-of-your-project/appearance/use-color-palette-chart.png b/images/references/workspace/customizing-the-appearance-of-your-project/use-color-palette-chart.png
similarity index 100%
rename from images/references/workspace/customizing-the-appearance-of-your-project/appearance/use-color-palette-chart.png
rename to images/references/workspace/customizing-the-appearance-of-your-project/use-color-palette-chart.png
diff --git a/images/references/workspace/customizing-the-appearance-of-your-project/appearance/use-theme.png b/images/references/workspace/customizing-the-appearance-of-your-project/use-theme.png
similarity index 100%
rename from images/references/workspace/customizing-the-appearance-of-your-project/appearance/use-theme.png
rename to images/references/workspace/customizing-the-appearance-of-your-project/use-theme.png
diff --git a/images/self-host/customize-deployment/assets/images/headless-browser-schema-62b496e0d9f5f705ae823c7d4fdec946.png b/images/self-host/customize-deployment/enable-headless-browser-for-lightdash/headless-browser-schema-62b496e0d9f5f705ae823c7d4fdec946.png
similarity index 100%
rename from images/self-host/customize-deployment/assets/images/headless-browser-schema-62b496e0d9f5f705ae823c7d4fdec946.png
rename to images/self-host/customize-deployment/enable-headless-browser-for-lightdash/headless-browser-schema-62b496e0d9f5f705ae823c7d4fdec946.png
diff --git a/references/integrations/lightdash-mcp.mdx b/references/integrations/lightdash-mcp.mdx
index 56aa3e0c..44ea0356 100644
--- a/references/integrations/lightdash-mcp.mdx
+++ b/references/integrations/lightdash-mcp.mdx
@@ -50,21 +50,21 @@ Set up MCP in the [Claude.ai](https://claude.ai) web app, and it will automatica
Navigate to your profile menu (bottom-left corner) and select Settings.
- 
+ 
2. **Add Custom Connector**
In the Settings menu, select "Connectors" from the sidebar, then click "Add custom connector".
- 
+ 
3. **Configure Connection**
Fill in the connection details with your Lightdash instance information.
- 
+ 
- **Name:** Lightdash (or any name you prefer)
- **URL:** `https://.lightdash.cloud/api/v1/mcp`
@@ -73,21 +73,21 @@ Set up MCP in the [Claude.ai](https://claude.ai) web app, and it will automatica
Click the "Connect" button to initiate the authentication process.
- 
+ 
5. **Complete OAuth Flow**
Log in to your Lightdash account and approve the connection when prompted.
- 
+ 
6. **Configure Permissions (Optional)**
Optionally configure which MCP tools Claude can access and set any additional permissions.
- 
+ 
@@ -101,7 +101,7 @@ ChatGPT support for MCP is coming soon\! Stay tuned for updates.
1. **Access Settings in ChatGPT**
@@ -109,7 +109,7 @@ ChatGPT support for MCP is coming soon\! Stay tuned for updates.
2. **Create Custom Connector**
@@ -117,7 +117,7 @@ ChatGPT support for MCP is coming soon\! Stay tuned for updates.
3. **Configure Connection**
@@ -130,7 +130,7 @@ ChatGPT support for MCP is coming soon\! Stay tuned for updates.
4. **Complete OAuth Flow**
@@ -152,27 +152,27 @@ Replace `` with your actual Lightdash instance name.
1. **Use the /mcp command in Claude Code**
- 
+ 
2. **Select Lightdash from the list**
- 
+ 
3. **Authenticate with Lightdash**
- 
+ 
4. **Complete OAuth Flow**
- 
+ 
5. **Start using MCP**
- 
+ 
After authentication, you can start asking questions about your Lightdash data directly in Claude Code\!
@@ -212,7 +212,7 @@ Since MCP provides raw tools without built-in intelligence, your AI assistant ne
- 
+ 
Here's a suggested template for optimizing Lightdash MCP usage. Feel free to modify these instructions based on your specific needs and use cases:
@@ -276,17 +276,17 @@ Here are some examples of how you can interact with AI assistants using MCP:
- 
+ 
- 
+ 
- 
+ 
diff --git a/references/workspace/customizing-the-appearance-of-your-project.mdx b/references/workspace/customizing-the-appearance-of-your-project.mdx
index ae379ee5..5233bbc3 100644
--- a/references/workspace/customizing-the-appearance-of-your-project.mdx
+++ b/references/workspace/customizing-the-appearance-of-your-project.mdx
@@ -10,13 +10,13 @@ To customize the colors used by default in your organization, head to the `Organ
You will see a list of color palettes that you can choose from for each mode. You can create your own palette by clicking on the `Add new palette` button. Here you can preview the palette before you save it.
-
+
### Color palettes for light and dark mode
You can configure different color palettes for light mode and dark mode to ensure optimal contrast and readability in each theme. The appearance configuration includes separate tabs for both light mode and dark mode, allowing you to customize color schemes for each theme independently.
-
+
Since dark mode is available as a user preference setting within the app, having separate palettes ensures your charts look great regardless of which theme users choose. Note that dark mode is only available when using the app directly - embedded charts, Slack integrations, and other external integrations will continue to display in light mode.
@@ -25,10 +25,10 @@ Since dark mode is available as a user preference setting within the app, having
Once you have created your palette, you can set it as the default palette for the current mode (light or dark) by clicking on the `Use this theme` button.
You can always come back to this page to change the default palette.
-
+
## Use the palette in a chart
The next time you build a chart, it will default to using these colors for the series, unless you override them in the chart config.
-
+
diff --git a/scripts/check-image-locations.js b/scripts/check-image-locations.js
new file mode 100755
index 00000000..f45fd3bc
--- /dev/null
+++ b/scripts/check-image-locations.js
@@ -0,0 +1,327 @@
+#!/usr/bin/env node
+
+/**
+ * Validates that images are placed in the correct directory structure
+ * Images should mirror the page structure as defined in CONTRIBUTING.md
+ *
+ * Rules:
+ * - A page at guides/dashboard.mdx should use images from images/guides/dashboard/
+ * - Shared images can be in parent folders
+ * - All images must exist
+ *
+ * Run: node scripts/check-image-locations.js
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+// Regex patterns for finding image references
+const MARKDOWN_IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
+const JSX_IMG_SRC_REGEX = /
]+src=["']([^"']+)["']/g;
+const FRAME_IMG_REGEX = /]*>[\s\S]*?
]+src=["']([^"']+)["']/g;
+
+const VALID_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp'];
+
+function findMDXFiles(dir, fileList = []) {
+ const files = fs.readdirSync(dir);
+
+ files.forEach(file => {
+ const filePath = path.join(dir, file);
+ const stat = fs.statSync(filePath);
+
+ if (stat.isDirectory() && !file.startsWith('.') && file !== 'node_modules') {
+ findMDXFiles(filePath, fileList);
+ } else if (file.endsWith('.mdx') || file.endsWith('.md')) {
+ fileList.push(filePath);
+ }
+ });
+
+ return fileList;
+}
+
+function extractImageReferences(content, filePath) {
+ const images = [];
+
+ // Extract markdown images: 
+ let match;
+ while ((match = MARKDOWN_IMAGE_REGEX.exec(content)) !== null) {
+ const imagePath = match[2];
+ if (!imagePath.startsWith('http://') && !imagePath.startsWith('https://')) {
+ images.push({
+ alt: match[1],
+ path: imagePath,
+ type: 'markdown',
+ line: content.substring(0, match.index).split('\n').length
+ });
+ }
+ }
+
+ // Extract JSX image src
+ while ((match = JSX_IMG_SRC_REGEX.exec(content)) !== null) {
+ const imagePath = match[1];
+ if (!imagePath.startsWith('http://') && !imagePath.startsWith('https://')) {
+ images.push({
+ path: imagePath,
+ type: 'jsx',
+ line: content.substring(0, match.index).split('\n').length
+ });
+ }
+ }
+
+ return images;
+}
+
+function getExpectedImageDirectory(mdxFilePath) {
+ // Get path relative to project root
+ const relativePath = path.relative(process.cwd(), mdxFilePath);
+
+ // Remove file extension
+ const withoutExt = relativePath.replace(/\.(mdx|md)$/, '');
+
+ // Convert to expected image path
+ // e.g., guides/dashboard.mdx -> images/guides/dashboard/
+ // e.g., get-started/setup.mdx -> images/get-started/setup/
+ return path.join('images', withoutExt);
+}
+
+function validateImageLocation(imagePath, mdxFilePath, isShared = false) {
+ const issues = [];
+
+ // Resolve absolute path for image
+ let absoluteImagePath;
+ if (imagePath.startsWith('/')) {
+ // Absolute path from project root
+ absoluteImagePath = path.join(process.cwd(), imagePath.substring(1));
+ } else {
+ // Relative path from MDX file
+ absoluteImagePath = path.join(path.dirname(mdxFilePath), imagePath);
+ }
+
+ // Check if image exists
+ if (!fs.existsSync(absoluteImagePath)) {
+ issues.push({
+ type: 'missing',
+ message: 'Image file does not exist',
+ imagePath: imagePath,
+ absolutePath: absoluteImagePath
+ });
+ return issues; // No point checking location if file doesn't exist
+ }
+
+ // Check if it's actually an image file
+ const ext = path.extname(absoluteImagePath).toLowerCase();
+ if (!VALID_IMAGE_EXTENSIONS.includes(ext)) {
+ issues.push({
+ type: 'invalid-type',
+ message: `Invalid file extension: ${ext}. Expected: ${VALID_IMAGE_EXTENSIONS.join(', ')}`,
+ imagePath: imagePath
+ });
+ }
+
+ // Skip location validation for shared images (used by multiple files)
+ if (isShared) {
+ return issues;
+ }
+
+ // Get expected directory
+ const expectedDir = getExpectedImageDirectory(mdxFilePath);
+
+ // Get actual directory of the image (relative to project root)
+ let imageDir;
+ if (imagePath.startsWith('/')) {
+ imageDir = path.dirname(imagePath.substring(1));
+ } else {
+ // Convert to absolute then back to relative from project root
+ const relativeImagePath = path.relative(process.cwd(), absoluteImagePath);
+ imageDir = path.dirname(relativeImagePath);
+ }
+
+ // Check if image is in the expected directory or a valid parent directory
+ const isInExpectedDir = imageDir === expectedDir;
+ const isInParentDir = expectedDir.startsWith(imageDir + path.sep) || expectedDir === imageDir;
+
+ // Special case: logo and favicon can be in root
+ const isSpecialFile = imagePath.includes('/logo/') ||
+ imagePath.includes('favicon') ||
+ imagePath.includes('/snippets/');
+
+ if (!isInExpectedDir && !isInParentDir && !isSpecialFile) {
+ // Check if it's in a valid parent directory (shared images)
+ const expectedParts = expectedDir.split(path.sep);
+ const imageParts = imageDir.split(path.sep);
+
+ // Allow if image is in a parent directory of the expected path
+ const isValidParent = expectedParts.length > imageParts.length &&
+ expectedParts.slice(0, imageParts.length).join(path.sep) === imageDir;
+
+ if (!isValidParent) {
+ issues.push({
+ type: 'wrong-location',
+ message: 'Image is not in the correct directory',
+ imagePath: imagePath,
+ expectedDir: expectedDir,
+ actualDir: imageDir,
+ suggestion: `Move to ${expectedDir}/ or a shared parent directory`
+ });
+ }
+ }
+
+ return issues;
+}
+
+function getRelativePath(filePath) {
+ return path.relative(process.cwd(), filePath).replace(/\\/g, '/');
+}
+
+function buildSharedImagesMap(files) {
+ const imageUsageMap = new Map(); // imagePath -> [files using it]
+
+ for (const file of files) {
+ const content = fs.readFileSync(file, 'utf8');
+ const images = extractImageReferences(content, file);
+
+ for (const image of images) {
+ const normalizedPath = image.path.startsWith('/')
+ ? image.path
+ : '/' + path.relative(process.cwd(), path.join(path.dirname(file), image.path)).replace(/\\/g, '/');
+
+ if (!imageUsageMap.has(normalizedPath)) {
+ imageUsageMap.set(normalizedPath, []);
+ }
+ imageUsageMap.get(normalizedPath).push(getRelativePath(file));
+ }
+ }
+
+ // Return set of images used by 2+ files (shared images)
+ const sharedImages = new Set();
+ for (const [imagePath, files] of imageUsageMap.entries()) {
+ if (files.length >= 2) {
+ sharedImages.add(imagePath);
+ }
+ }
+
+ return { sharedImages, imageUsageMap };
+}
+
+async function main() {
+ console.log('š¼ļø Checking image locations in documentation...\n');
+
+ const mdxFiles = findMDXFiles('.');
+ const excludedPaths = ['node_modules', '.git', 'snippets', 'CONTRIBUTING.md'];
+ const filteredFiles = mdxFiles.filter(file =>
+ !excludedPaths.some(excluded => file.includes(excluded))
+ );
+
+ console.log(`š Found ${filteredFiles.length} documentation files\n`);
+
+ // First pass: identify shared images
+ console.log('š Identifying shared images...\n');
+ const { sharedImages, imageUsageMap } = buildSharedImagesMap(filteredFiles);
+ console.log(`š Found ${sharedImages.size} images used by multiple pages\n`);
+
+ const allIssues = [];
+ let totalImages = 0;
+ let missingImages = 0;
+ let misplacedImages = 0;
+ let invalidTypes = 0;
+ let sharedImagesCount = 0;
+
+ // Check images in each file
+ for (const file of filteredFiles) {
+ const content = fs.readFileSync(file, 'utf8');
+ const images = extractImageReferences(content, file);
+ const relativePath = getRelativePath(file);
+
+ for (const image of images) {
+ totalImages++;
+
+ // Normalize image path for comparison
+ const normalizedPath = image.path.startsWith('/')
+ ? image.path
+ : '/' + path.relative(process.cwd(), path.join(path.dirname(file), image.path)).replace(/\\/g, '/');
+
+ // Skip location validation for shared images
+ const isShared = sharedImages.has(normalizedPath);
+ if (isShared) {
+ sharedImagesCount++;
+ }
+
+ const issues = validateImageLocation(image.path, file, isShared);
+
+ if (issues.length > 0) {
+ issues.forEach(issue => {
+ allIssues.push({
+ file: relativePath,
+ line: image.line,
+ ...issue
+ });
+
+ if (issue.type === 'missing') missingImages++;
+ if (issue.type === 'wrong-location') misplacedImages++;
+ if (issue.type === 'invalid-type') invalidTypes++;
+ });
+ }
+ }
+ }
+
+ // Display results
+ if (allIssues.length === 0) {
+ console.log('ā
All images are in the correct locations!\n');
+ } else {
+ console.log(`ā Found ${allIssues.length} image issues:\n`);
+
+ // Group by issue type
+ const missingIssues = allIssues.filter(i => i.type === 'missing');
+ const locationIssues = allIssues.filter(i => i.type === 'wrong-location');
+ const typeIssues = allIssues.filter(i => i.type === 'invalid-type');
+
+ if (missingIssues.length > 0) {
+ console.log(`š Missing Images (${missingIssues.length}):\n`);
+ missingIssues.forEach(({ file, line, imagePath, message }) => {
+ console.log(` š ${file}:${line}`);
+ console.log(` š ${imagePath}`);
+ console.log(` ā ${message}\n`);
+ });
+ }
+
+ if (locationIssues.length > 0) {
+ console.log(`š Misplaced Images (${locationIssues.length}):\n`);
+ locationIssues.forEach(({ file, line, imagePath, expectedDir, actualDir, suggestion }) => {
+ console.log(` š ${file}:${line}`);
+ console.log(` š ${imagePath}`);
+ console.log(` ā Expected in: ${expectedDir}/`);
+ console.log(` š Actually in: ${actualDir}/`);
+ console.log(` š” ${suggestion}\n`);
+ });
+ }
+
+ if (typeIssues.length > 0) {
+ console.log(`ā ļø Invalid File Types (${typeIssues.length}):\n`);
+ typeIssues.forEach(({ file, line, imagePath, message }) => {
+ console.log(` š ${file}:${line}`);
+ console.log(` š ${imagePath}`);
+ console.log(` ā ${message}\n`);
+ });
+ }
+ }
+
+ // Summary
+ console.log('ā'.repeat(60));
+ console.log(`Total images checked: ${totalImages}`);
+ console.log(`Shared images (used by 2+ files): ${sharedImagesCount}`);
+ console.log(`Missing images: ${missingImages}`);
+ console.log(`Misplaced images: ${misplacedImages}`);
+ console.log(`Invalid file types: ${invalidTypes}`);
+ console.log('ā'.repeat(60));
+
+ console.log('\nš” Image Placement Rules:');
+ console.log(' ⢠Images should mirror page structure');
+ console.log(' ⢠guides/dashboard.mdx ā images/guides/dashboard/');
+ console.log(' ⢠Shared images (used by multiple pages) are automatically allowed');
+ console.log(' ⢠See CONTRIBUTING.md for full guidelines\n');
+
+ // Exit with error if issues found
+ process.exit(allIssues.length > 0 ? 1 : 0);
+}
+
+main();
diff --git a/scripts/check-links.js b/scripts/check-links.js
index ec0acee2..48a0e5b2 100644
--- a/scripts/check-links.js
+++ b/scripts/check-links.js
@@ -66,11 +66,40 @@ function isExternalLink(url) {
}
function isSpecialLink(url) {
- return url.startsWith('mailto:') ||
- url.startsWith('tel:') ||
- url.startsWith('#') ||
- url.includes('{{') || // Template variables
- url.includes('${'); // Template literals
+ // Skip standard special links
+ if (url.startsWith('mailto:') ||
+ url.startsWith('tel:') ||
+ url.startsWith('#')) {
+ return true;
+ }
+
+ // Skip template variables
+ if (url.includes('{{') || // Handlebars
+ url.includes('${') || // JS template literals
+ url.includes('<%=') || // ERB/EJS templates
+ url.includes('<%')) { // ERB/EJS templates
+ return true;
+ }
+
+ // Skip placeholder/example URLs
+ const placeholderPatterns = [
+ 'uuid',
+ 'your-project',
+ 'your-workspace',
+ 'example-',
+ 'dashboard-id',
+ 'chart-id',
+ 'project-id',
+ 'workspace-id',
+ 'embed-proxy', // Example embed URLs
+ ];
+
+ const lowerUrl = url.toLowerCase();
+ if (placeholderPatterns.some(pattern => lowerUrl.includes(pattern))) {
+ return true;
+ }
+
+ return false;
}
function resolveInternalLink(url, sourceFile) {
@@ -204,11 +233,44 @@ function findOrphanedPages(allMdxFiles, docsJson) {
return orphans;
}
+function extractRedirects(docsJson) {
+ if (!docsJson || !docsJson.redirects) return [];
+ return docsJson.redirects.map(r => ({
+ source: r.source.startsWith('/') ? r.source.substring(1) : r.source,
+ destination: r.destination.startsWith('/') ? r.destination.substring(1) : r.destination
+ }));
+}
+
+function validateRedirects(redirects) {
+ const issues = [];
+
+ redirects.forEach(redirect => {
+ // Check if destination exists
+ const possiblePaths = [
+ path.join(process.cwd(), redirect.destination),
+ path.join(process.cwd(), redirect.destination + '.mdx'),
+ path.join(process.cwd(), redirect.destination + '.md'),
+ ];
+
+ const exists = possiblePaths.some(p => fs.existsSync(p));
+
+ if (!exists) {
+ issues.push({
+ source: redirect.source,
+ destination: redirect.destination,
+ issue: 'Redirect destination does not exist'
+ });
+ }
+ });
+
+ return issues;
+}
+
async function main() {
console.log('š Checking for broken links in documentation...\n');
const mdxFiles = findMDXFiles('.');
- const excludedPaths = ['node_modules', '.git'];
+ const excludedPaths = ['node_modules', '.git', 'CONTRIBUTING.md'];
const filteredFiles = mdxFiles.filter(file =>
!excludedPaths.some(excluded => file.includes(excluded))
);
@@ -256,6 +318,11 @@ async function main() {
const docsJson = loadDocsJson();
const orphanedPages = findOrphanedPages(filteredFiles, docsJson);
+ // Check redirects
+ console.log('š Checking redirects in docs.json...\n');
+ const redirects = extractRedirects(docsJson);
+ const redirectIssues = validateRedirects(redirects);
+
// Display results
if (brokenLinks.length === 0) {
console.log('ā
No broken internal links found!\n');
@@ -287,6 +354,18 @@ async function main() {
console.log('\nš” These pages exist but are not linked in docs.json navigation.\n');
}
+ // Display redirect issues
+ if (redirectIssues.length === 0) {
+ console.log('ā
All redirects are valid!\n');
+ } else {
+ console.log(`ā Found ${redirectIssues.length} invalid redirects in docs.json:\n`);
+ redirectIssues.forEach(({ source, destination, issue }) => {
+ console.log(` š ${source} ā ${destination}`);
+ console.log(` ā ${issue}\n`);
+ });
+ console.log('š” When moving pages, ensure redirect destinations exist.\n');
+ }
+
// Check external links if requested
if (CHECK_EXTERNAL && externalLinks.length > 0) {
console.log(`š Checking ${externalLinks.length} external links...\n`);
@@ -317,13 +396,14 @@ async function main() {
console.log(`Total links checked: ${totalLinks}`);
console.log(`Broken internal links: ${brokenLinks.length}`);
console.log(`Orphaned pages: ${orphanedPages.length}`);
+ console.log(`Invalid redirects: ${redirectIssues.length}`);
if (CHECK_EXTERNAL) {
console.log(`External links checked: ${externalLinks.length}`);
}
console.log('ā'.repeat(60));
// Exit with error if issues found
- const hasIssues = brokenLinks.length > 0 || orphanedPages.length > 0;
+ const hasIssues = brokenLinks.length > 0 || orphanedPages.length > 0 || redirectIssues.length > 0;
process.exit(hasIssues ? 1 : 0);
}
diff --git a/self-host/customize-deployment/enable-headless-browser-for-lightdash.mdx b/self-host/customize-deployment/enable-headless-browser-for-lightdash.mdx
index 46e0d355..0d71f0ea 100644
--- a/self-host/customize-deployment/enable-headless-browser-for-lightdash.mdx
+++ b/self-host/customize-deployment/enable-headless-browser-for-lightdash.mdx
@@ -12,7 +12,7 @@ If you are running Lightdash on self-hosting, you will also have to run this hea
## How it works
- 
+ 
When Lightdash needs to generate an image, it will open a new socket connection to the headless browser on `ws://HEADLESS_BROWSER_HOST:HEADLESS_BROWSER_PORT`