Repository: dbt-labs/dbt-core

Issue:
[CT-3105] [Feature] Generate static documentation (static_index.html)
### Is this your first time submitting a feature request?

- [X] I have read the [expectations for open source contributors](https://docs.getdbt.com/docs/contributing/oss-expectations)
- [X] I have searched the existing issues, and I could not find an existing issue for this feature
- [X] I am requesting a straightforward extension of existing dbt functionality, rather than a Big Idea better suited to a discussion

### Describe the feature

Static webpages (all resources bundled within them) are very useful -- you can inspect them directly without starting a webbrowser, and they can be served by e.g. object stores.

There is a long-standing feature request in dbt-docs for this [here](https://github.com/dbt-labs/dbt-docs/issues/53). I have created a PR within dbt-docs and will be creating a PR within dbt-core based on its output.


### Describe alternatives you've considered

External tools (as described in the [dbt-docs issue](https://github.com/dbt-labs/dbt-docs/issues/53)) can be created, but this is ugly and a hack.

In terms of doing this properly (within dbt itself), I considered a few other alternatives -- delayed webpack, bundling within Python (dbt-core), but all of these are both very complicated, different to how things are working now.

### Who will this benefit?

See the history of [dbt-docs issue](https://github.com/dbt-labs/dbt-docs/issues/53) for some of the benefits.

One specific example is hosting in Google Cloud Storage buckets -- a static index.html can be directly shared using GCP permissions, while one that retrieves manifest.json and catalog.json cannot. The bucket must either be public (usually undesirable) or exposed through a webserver (more complex).

### Are you interested in contributing this feature?

Yes. PR will be attached.

### Anything else?

_No response_


Discussion:

Resolving patch:
diff --git a/core/dbt/cli/main.py b/core/dbt/cli/main.py
--- a/core/dbt/cli/main.py
+++ b/core/dbt/cli/main.py
@@ -254,6 +254,7 @@ def docs(ctx, **kwargs):
 @p.select
 @p.selector
 @p.empty_catalog
+@p.static
 @p.state
 @p.defer_state
 @p.deprecated_state
diff --git a/core/dbt/cli/params.py b/core/dbt/cli/params.py
--- a/core/dbt/cli/params.py
+++ b/core/dbt/cli/params.py
@@ -457,6 +457,13 @@
     is_flag=True,
 )
 
+static = click.option(
+    "--static",
+    help="Generate an additional static_index.html with manifest and catalog built-in.",
+    default=False,
+    is_flag=True,
+)
+
 state = click.option(
     "--state",
     envvar="DBT_STATE",
diff --git a/core/dbt/task/generate.py b/core/dbt/task/generate.py
--- a/core/dbt/task/generate.py
+++ b/core/dbt/task/generate.py
@@ -5,6 +5,7 @@
 import agate
 
 from dbt.dataclass_schema import ValidationError
+from dbt.clients.system import load_file_contents
 
 from .compile import CompileTask
 
@@ -36,7 +37,9 @@
 import dbt.utils
 import dbt.compilation
 import dbt.exceptions
-
+from dbt.constants import (
+    MANIFEST_FILE_NAME,
+)
 
 CATALOG_FILENAME = "catalog.json"
 
@@ -259,14 +262,32 @@ def run(self) -> CatalogArtifact:
             errors=errors,
         )
 
-        path = os.path.join(self.config.project_target_path, CATALOG_FILENAME)
-        results.write(path)
+        catalog_path = os.path.join(self.config.project_target_path, CATALOG_FILENAME)
+        results.write(catalog_path)
         if self.args.compile:
             write_manifest(self.manifest, self.config.project_target_path)
 
+        if self.args.static:
+
+            # Read manifest.json and catalog.json
+            read_manifest_data = load_file_contents(
+                os.path.join(self.config.project_target_path, MANIFEST_FILE_NAME)
+            )
+            read_catalog_data = load_file_contents(catalog_path)
+
+            # Create new static index file contents
+            index_data = load_file_contents(DOCS_INDEX_FILE_PATH)
+            index_data = index_data.replace('"MANIFEST.JSON INLINE DATA"', read_manifest_data)
+            index_data = index_data.replace('"CATALOG.JSON INLINE DATA"', read_catalog_data)
+
+            # Write out the new index file
+            static_index_path = os.path.join(self.config.project_target_path, "static_index.html")
+            with open(static_index_path, "wb") as static_index_file:
+                static_index_file.write(bytes(index_data, "utf8"))
+
         if exceptions:
             fire_event(WriteCatalogFailure(num_exceptions=len(exceptions)))
-        fire_event(CatalogWritten(path=os.path.abspath(path)))
+        fire_event(CatalogWritten(path=os.path.abspath(catalog_path)))
         return results
 
     def get_catalog_results(


Test patch:
diff --git a/tests/functional/docs/test_static.py b/tests/functional/docs/test_static.py
new file mode 100644
--- /dev/null
+++ b/tests/functional/docs/test_static.py
@@ -0,0 +1,50 @@
+import pytest
+
+from dbt.clients.system import load_file_contents
+from dbt.include.global_project import DOCS_INDEX_FILE_PATH
+from dbt.tests.util import run_dbt
+import os
+
+
+class TestStaticGenerate:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {"my_model.sql": "select 1 as fun"}
+
+    def test_static_generated(self, project):
+        run_dbt(["docs", "generate", "--static"])
+
+        source_index_html = load_file_contents(DOCS_INDEX_FILE_PATH)
+
+        target_index_html = load_file_contents(
+            os.path.join(project.project_root, "target", "index.html")
+        )
+
+        # Validate index.html was copied correctly
+        assert len(target_index_html) == len(source_index_html)
+        assert hash(target_index_html) == hash(source_index_html)
+
+        manifest_data = load_file_contents(
+            os.path.join(project.project_root, "target", "manifest.json")
+        )
+
+        catalog_data = load_file_contents(
+            os.path.join(project.project_root, "target", "catalog.json")
+        )
+
+        static_index_html = load_file_contents(
+            os.path.join(project.project_root, "target", "static_index.html")
+        )
+
+        # Calculate expected static_index.html
+        expected_static_index_html = source_index_html
+        expected_static_index_html = expected_static_index_html.replace(
+            '"MANIFEST.JSON INLINE DATA"', manifest_data
+        )
+        expected_static_index_html = expected_static_index_html.replace(
+            '"CATALOG.JSON INLINE DATA"', catalog_data
+        )
+
+        # Validate static_index.html was generated correctly
+        assert len(expected_static_index_html) == len(static_index_html)
+        assert hash(expected_static_index_html) == hash(static_index_html)