mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-05-12 18:57:51 -04:00
d78517fd9a
# Description of Changes This PR does several small things: 1. It removes the explicit `h1` tags on every page, and either uses the side bar title directly, or puts it in the frontmatter 2. It merges what are currently called quickstarts into a single Chat App Tutorial 3. It creates new quickstarts which just use `spacetime dev --template` to get you up and running quickly 4. It adds a "The Zen of SpacetimeDB" page much like the Zen of Python which goes over the 5 key principles of SpacetimeDB 5. It reorders all Tabs groups so that the ordering is `TypeScript`, `C#`, `Rust`, `Unreal`, `C++`, `Blueprints` (order of decreasing popularity). 6. It improves the sidebar navigation by having categories act as overview pages, and also fixes the breadcrumbs 7. It fixes various small typos and issues 8. Closes #3610 and adds cursor rules files generally 9. It fixes general styling on the docs page by bring it inline with the UI design: Old: <img width="1678" height="958" alt="image" src="https://github.com/user-attachments/assets/f36efee6-b81a-4463-a179-da68b3a7152e" /> New: <img width="1678" height="957" alt="image" src="https://github.com/user-attachments/assets/f430f77d-0663-47f2-9727-45cbfe10e4c7" /> https://github.com/user-attachments/assets/adc5a78a-ada8-45b5-8078-a45cb81477a3 # API and ABI breaking changes This PR does NOT change any old links. It does add new pages though. # Expected complexity level and risk 3 - it's a large change. I manually tested the TypeScript Chat App Tutorial but I have not gone through the Rust and C# quickstarts. However, we have testing on the quickstarts and this is text only so can be carefully reviewed. # Testing <!-- Describe any testing you've done, and any testing you'd like your reviewers to do, so that you're confident that all the changes work as expected! --> - [x] Ran through each step of the Chat App TypeScript tutorial to ensure it is working - [x] Ran and tested the styles and the functionality of the side bar --------- Signed-off-by: Tyler Cloutier <cloutiertyler@users.noreply.github.com> Co-authored-by: spacetimedb-bot <spacetimedb-bot@users.noreply.github.com> Co-authored-by: clockworklabs-bot <clockworklabs-bot@users.noreply.github.com> Co-authored-by: Zeke Foppa <bfops@users.noreply.github.com> Co-authored-by: clockwork-labs-bot <clockwork-labs-bot@users.noreply.github.com>
379 lines
14 KiB
Python
379 lines
14 KiB
Python
import logging
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
import tempfile
|
|
import xmltodict
|
|
|
|
import smoketests
|
|
from .. import Smoketest, STDB_DIR, run_cmd, TEMPLATE_CARGO_TOML, TYPESCRIPT_BINDINGS_PATH, build_typescript_sdk, pnpm
|
|
|
|
|
|
def _write_file(path: Path, content: str):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content)
|
|
|
|
|
|
def _append_to_file(path: Path, content: str):
|
|
with open(path, "a", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
|
|
def _parse_quickstart(doc_path: Path, language: str, module_name: str, server: bool) -> str:
|
|
"""Extract code blocks from `quickstart.md` docs.
|
|
This will replicate the steps in the quickstart guide, so if it fails the quickstart guide is broken.
|
|
"""
|
|
content = Path(doc_path).read_text()
|
|
|
|
# append " server" to the codeblock language if we're extracting server code
|
|
if server:
|
|
codeblock_lang = "ts server" if language == "typescript" else f"{language} server"
|
|
else:
|
|
codeblock_lang = "ts" if language == "typescript" else language
|
|
|
|
blocks = re.findall(rf"```{codeblock_lang}\n(.*?)\n```", content, re.DOTALL)
|
|
|
|
end = ""
|
|
if language == "csharp":
|
|
found = False
|
|
filtered_blocks = []
|
|
for block in blocks:
|
|
# The doc first create an empty class Module, so we need to fixup the closing
|
|
if "partial class Module" in block:
|
|
block = block.replace("}", "")
|
|
end = "\n}"
|
|
# Remove the first `OnConnected` block, which body is later updated
|
|
if "OnConnected(DbConnection conn" in block and not found:
|
|
found = True
|
|
continue
|
|
filtered_blocks.append(block)
|
|
blocks = filtered_blocks
|
|
# So we could have a different db for each language
|
|
return "\n".join(blocks).replace("quickstart-chat", module_name) + end
|
|
|
|
def load_nuget_config(p: Path):
|
|
if p.exists():
|
|
with p.open("rb") as f:
|
|
return xmltodict.parse(f.read(), force_list=["add", "packageSource", "package"])
|
|
return {}
|
|
|
|
def save_nuget_config(p: Path, doc: dict):
|
|
# Write back (pretty, UTF-8, no BOM)
|
|
xml = xmltodict.unparse(doc, pretty=True)
|
|
p.write_text(xml, encoding="utf-8")
|
|
|
|
def add_source(doc: dict, *, key: str, path: str) -> None:
|
|
cfg = doc.setdefault("configuration", {})
|
|
sources = cfg.setdefault("packageSources", {})
|
|
source_entries = sources.setdefault("add", [])
|
|
source = {"@key": key, "@value": path}
|
|
source_entries.append(source)
|
|
|
|
def add_mapping(doc: dict, *, key: str, pattern: str) -> None:
|
|
cfg = doc.setdefault("configuration", {})
|
|
|
|
psm = cfg.setdefault("packageSourceMapping", {})
|
|
mapping_sources = psm.setdefault("packageSource", [])
|
|
|
|
# Find or create the target <packageSource key="...">
|
|
target = next((s for s in mapping_sources if s.get("@key") == key), None)
|
|
if target is None:
|
|
target = {"@key": key, "package": []}
|
|
mapping_sources.append(target)
|
|
|
|
pkgs = target.setdefault("package", [])
|
|
|
|
existing = {pkg.get("@pattern") for pkg in pkgs if "@pattern" in pkg}
|
|
if pattern not in existing:
|
|
pkgs.append({"@pattern": pattern})
|
|
|
|
def override_nuget_package(*, project_dir: Path, package: str, source_dir: Path, build_subdir: str):
|
|
"""Override nuget config to use a local NuGet package on a .NET project"""
|
|
# Make sure the local package is built
|
|
run_cmd("dotnet", "pack", cwd=source_dir)
|
|
|
|
p = Path(project_dir) / "nuget.config"
|
|
doc = load_nuget_config(p)
|
|
add_source(doc, key=package, path=source_dir/build_subdir)
|
|
add_mapping(doc, key=package, pattern=package)
|
|
# Fallback for other packages
|
|
add_mapping(doc, key="nuget.org", pattern="*")
|
|
save_nuget_config(p, doc)
|
|
|
|
# Clear any caches for nuget packages
|
|
run_cmd("dotnet", "nuget", "locals", "--clear", "all", capture_stderr=True)
|
|
|
|
class BaseQuickstart(Smoketest):
|
|
AUTOPUBLISH = False
|
|
MODULE_CODE = ""
|
|
|
|
lang = None
|
|
client_lang = None
|
|
codeblock_langs = None
|
|
server_doc = None
|
|
client_doc = None
|
|
server_file = None
|
|
client_file = None
|
|
module_bindings = None
|
|
extra_code = None
|
|
replacements = {}
|
|
connected_str = None
|
|
run_cmd = []
|
|
build_cmd = []
|
|
|
|
def project_init(self, path: Path):
|
|
raise NotImplementedError
|
|
|
|
def sdk_setup(self, path: Path):
|
|
raise NotImplementedError
|
|
|
|
@property
|
|
def _module_name(self):
|
|
return f"quickstart-chat-{self.lang}"
|
|
|
|
def _publish(self) -> Path:
|
|
base_path = Path(self.enterClassContext(tempfile.TemporaryDirectory()))
|
|
server_path = base_path / "server"
|
|
|
|
self.generate_server(server_path)
|
|
self.publish_module(self._module_name, capture_stderr=True, clear=True)
|
|
return base_path / "client"
|
|
|
|
def generate_server(self, server_path: Path):
|
|
"""Generate the server code from the quickstart documentation."""
|
|
logging.info(f"Generating server code {self.lang}: {server_path}...")
|
|
self.spacetime(
|
|
"init",
|
|
"--non-interactive",
|
|
"--lang",
|
|
self.lang,
|
|
"--project-path",
|
|
server_path,
|
|
"spacetimedb-project",
|
|
capture_stderr=True,
|
|
)
|
|
self.project_path = server_path / "spacetimedb"
|
|
shutil.copy2(STDB_DIR / "rust-toolchain.toml", self.project_path)
|
|
_write_file(self.project_path / self.server_file, _parse_quickstart(self.server_doc, self.lang, self._module_name, server=True))
|
|
self.server_postprocess(self.project_path)
|
|
self.spacetime("build", "-d", "-p", self.project_path, capture_stderr=True)
|
|
|
|
def server_postprocess(self, server_path: Path):
|
|
"""Optional per-language hook."""
|
|
pass
|
|
|
|
def check(self, input_cmd: str, client_path: Path, contains: str):
|
|
"""Run the client command and check if the output contains the expected string."""
|
|
output = run_cmd(*self.run_cmd, input=input_cmd, cwd=client_path, capture_stderr=True, text=True)
|
|
print(f"Output for {self.lang} client:\n{output}")
|
|
self.assertIn(contains, output)
|
|
|
|
def _test_quickstart(self):
|
|
"""Run the quickstart client."""
|
|
client_path = self._publish()
|
|
self.project_init(client_path)
|
|
self.sdk_setup(client_path)
|
|
|
|
run_cmd(*self.build_cmd, cwd=client_path, capture_stderr=True)
|
|
|
|
client_lang = self.client_lang or self.lang
|
|
self.spacetime(
|
|
"generate", "--lang", client_lang,
|
|
"--out-dir", client_path / self.module_bindings,
|
|
"--project-path", self.project_path, capture_stderr=True
|
|
)
|
|
# Replay the quickstart guide steps
|
|
main = _parse_quickstart(self.client_doc, client_lang, self._module_name, server=False)
|
|
for src, dst in self.replacements.items():
|
|
main = main.replace(src, dst)
|
|
main += "\n" + self.extra_code
|
|
server = self.get_server_address()
|
|
host = server["address"]
|
|
protocol = server["protocol"]
|
|
main = main.replace("http://localhost:3000", f"{protocol}://{host}")
|
|
_write_file(client_path / self.client_file, main)
|
|
|
|
self.check("", client_path, self.connected_str)
|
|
self.check("/name Alice", client_path, "Alice")
|
|
self.check("Hello World", client_path, "Hello World")
|
|
|
|
|
|
class Rust(BaseQuickstart):
|
|
lang = "rust"
|
|
server_doc = STDB_DIR / "docs/docs/00100-intro/00300-tutorials/00100-chat-app.md"
|
|
client_doc = STDB_DIR / "docs/docs/00100-intro/00300-tutorials/00100-chat-app.md"
|
|
server_file = "src/lib.rs"
|
|
client_file = "src/main.rs"
|
|
module_bindings = "src/module_bindings"
|
|
run_cmd = ["cargo", "run"]
|
|
build_cmd = ["cargo", "build"]
|
|
|
|
replacements = {
|
|
# Replace the interactive user input to allow direct testing
|
|
"user_input_loop(&ctx)": "user_input_direct(&ctx)",
|
|
# Don't cache the token, because it will cause the test to fail if we run against a non-default server (because we don't cache the corresponding signing keypair)
|
|
".with_token(creds_store()": "//.with_token(creds_store()"
|
|
}
|
|
extra_code = """
|
|
fn user_input_direct(ctx: &DbConnection) {
|
|
let mut line = String::new();
|
|
std::io::stdin().read_line(&mut line).expect("Failed to read from stdin.");
|
|
if let Some(name) = line.strip_prefix("/name ") {
|
|
ctx.reducers.set_name(name.to_string()).unwrap();
|
|
} else {
|
|
ctx.reducers.send_message(line).unwrap();
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
|
std::process::exit(0);
|
|
}
|
|
"""
|
|
connected_str = "connected"
|
|
|
|
def project_init(self, path: Path):
|
|
run_cmd("cargo", "new", "--bin", "--name", "quickstart_chat_client", "client", cwd=path.parent,
|
|
capture_stderr=True)
|
|
|
|
def sdk_setup(self, path: Path):
|
|
sdk_rust_path = (STDB_DIR / "sdks/rust").absolute()
|
|
sdk_rust_toml_escaped = str(sdk_rust_path).replace('\\', '\\\\\\\\') # double escape for re.sub + toml
|
|
sdk_rust_toml = f'spacetimedb-sdk = {{ path = "{sdk_rust_toml_escaped}" }}\nlog = "0.4"\nhex = "0.4"\n'
|
|
_append_to_file(path / "Cargo.toml", sdk_rust_toml)
|
|
|
|
def server_postprocess(self, server_path: Path):
|
|
_write_file(server_path / "Cargo.toml", self.cargo_manifest(TEMPLATE_CARGO_TOML))
|
|
|
|
def test_quickstart(self):
|
|
"""Run the Rust quickstart guides for server and client."""
|
|
self._test_quickstart()
|
|
|
|
|
|
class CSharp(BaseQuickstart):
|
|
lang = "csharp"
|
|
server_doc = STDB_DIR / "docs/docs/00100-intro/00300-tutorials/00100-chat-app.md"
|
|
client_doc = STDB_DIR / "docs/docs/00100-intro/00300-tutorials/00100-chat-app.md"
|
|
server_file = "Lib.cs"
|
|
client_file = "Program.cs"
|
|
module_bindings = "module_bindings"
|
|
run_cmd = ["dotnet", "run"]
|
|
build_cmd = ["dotnet", "build"]
|
|
|
|
# Replace the interactive user input to allow direct testing
|
|
replacements = {
|
|
"InputLoop();": "UserInputDirect();",
|
|
".OnConnect(OnConnected)": ".OnConnect(OnConnectedSignal)",
|
|
".OnConnectError(OnConnectError)": ".OnConnectError(OnConnectErrorSignal)",
|
|
# Don't cache the token, because it will cause the test to fail if we run against a non-default server (because we don't cache the corresponding signing keypair)
|
|
".WithToken(AuthToken.Token)": "//.WithToken(AuthToken.Token)",
|
|
"Main();": "" # To put the main function at the end so it can see the new functions
|
|
}
|
|
# So we can wait for the connection to be established...
|
|
extra_code = """
|
|
var connectedEvent = new ManualResetEventSlim(false);
|
|
var connectionFailed = new ManualResetEventSlim(false);
|
|
void OnConnectErrorSignal(Exception e)
|
|
{
|
|
OnConnectError(e);
|
|
connectionFailed.Set();
|
|
}
|
|
void OnConnectedSignal(DbConnection conn, Identity identity, string authToken)
|
|
{
|
|
OnConnected(conn, identity, authToken);
|
|
connectedEvent.Set();
|
|
}
|
|
|
|
void UserInputDirect() {
|
|
string? line = Console.In.ReadToEnd()?.Trim();
|
|
if (line == null) Environment.Exit(0);
|
|
|
|
if (!WaitHandle.WaitAny(
|
|
new[] { connectedEvent.WaitHandle, connectionFailed.WaitHandle },
|
|
TimeSpan.FromSeconds(5)
|
|
).Equals(0))
|
|
{
|
|
Console.WriteLine("Failed to connect to server within timeout.");
|
|
Environment.Exit(1);
|
|
}
|
|
|
|
if (line.StartsWith("/name ")) {
|
|
input_queue.Enqueue(("name", line[6..]));
|
|
} else {
|
|
input_queue.Enqueue(("message", line));
|
|
}
|
|
Thread.Sleep(1000);
|
|
}
|
|
Main();
|
|
"""
|
|
connected_str = "Connected"
|
|
|
|
def project_init(self, path: Path):
|
|
run_cmd("dotnet", "new", "console", "--name", "QuickstartChatClient", "--output", path, capture_stderr=True)
|
|
|
|
def sdk_setup(self, path: Path):
|
|
override_nuget_package(
|
|
project_dir=STDB_DIR/"sdks/csharp",
|
|
package="SpacetimeDB.BSATN.Runtime",
|
|
source_dir=(STDB_DIR / "crates/bindings-csharp/BSATN.Runtime").absolute(),
|
|
build_subdir="bin/Release"
|
|
)
|
|
# This one is only needed because the regression-tests subdir uses it
|
|
override_nuget_package(
|
|
project_dir=STDB_DIR/"sdks/csharp",
|
|
package="SpacetimeDB.Runtime",
|
|
source_dir=(STDB_DIR / "crates/bindings-csharp/Runtime").absolute(),
|
|
build_subdir="bin/Release"
|
|
)
|
|
override_nuget_package(
|
|
project_dir=path,
|
|
package="SpacetimeDB.BSATN.Runtime",
|
|
source_dir=(STDB_DIR / "crates/bindings-csharp/BSATN.Runtime").absolute(),
|
|
build_subdir="bin/Release"
|
|
)
|
|
override_nuget_package(
|
|
project_dir=path,
|
|
package="SpacetimeDB.ClientSDK",
|
|
source_dir=(STDB_DIR / "sdks/csharp").absolute(),
|
|
build_subdir="bin~/Release"
|
|
)
|
|
run_cmd("dotnet", "add", "package", "SpacetimeDB.ClientSDK", cwd=path, capture_stderr=True)
|
|
|
|
def server_postprocess(self, server_path: Path):
|
|
override_nuget_package(
|
|
project_dir=server_path,
|
|
package="SpacetimeDB.Runtime",
|
|
source_dir=(STDB_DIR / "crates/bindings-csharp/Runtime").absolute(),
|
|
build_subdir="bin/Release"
|
|
)
|
|
override_nuget_package(
|
|
project_dir=server_path,
|
|
package="SpacetimeDB.BSATN.Runtime",
|
|
source_dir=(STDB_DIR / "crates/bindings-csharp/BSATN.Runtime").absolute(),
|
|
build_subdir="bin/Release"
|
|
)
|
|
|
|
def test_quickstart(self):
|
|
"""Run the C# quickstart guides for server and client."""
|
|
if not smoketests.HAVE_DOTNET:
|
|
self.skipTest("C# SDK requires .NET to be installed.")
|
|
self._test_quickstart()
|
|
|
|
# We use the Rust client for testing the TypeScript server quickstart because
|
|
# the TypeScript client quickstart is a React app, which is difficult to
|
|
# smoketest.
|
|
class TypeScript(Rust):
|
|
lang = "typescript"
|
|
client_lang = "rust"
|
|
server_doc = STDB_DIR / "docs/docs/00100-intro/00300-tutorials/00100-chat-app.md"
|
|
server_file = "src/index.ts"
|
|
|
|
def server_postprocess(self, server_path: Path):
|
|
build_typescript_sdk()
|
|
# We already have spacetimedb as a depencency, but it's expecting to fetch from npm.
|
|
# If we don't uninstall before installing, pnpm can panic because it can't find
|
|
# the specified version on npm (even though we're about to override it anyway).
|
|
pnpm("uninstall", 'spacetimedb', cwd=server_path)
|
|
pnpm("install", TYPESCRIPT_BINDINGS_PATH, cwd=server_path)
|
|
|
|
def test_quickstart(self):
|
|
"""Run the TypeScript quickstart guides for server."""
|
|
self._test_quickstart()
|