diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4f5bffd..75dd378 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+### Added - 2026-05-09
+- Established repository versioning and changelog policy in `VERSIONING.md`.
+- Added doc-comment support for generated API documentation:
+ - `ras-jsonrpc-macro` now maps `///` comments on JSON-RPC methods into OpenRPC `summary` and `description`.
+ - `ras-rest-macro` now maps `///` comments on REST endpoints into OpenAPI operation `summary` and `description`.
+- Enhanced the API explorer to render documentation from generated specs:
+ - Shows operation/method docs for both REST and JSON-RPC.
+ - Shows schema/type and field descriptions produced by `schemars::JsonSchema`.
+ - Renders a safe dependency-free Markdown subset for paragraphs, line breaks, bold, inline code, fenced code blocks, lists, and HTTP(S) links.
+- Added Playwright e2e coverage for REST and JSON-RPC explorer documentation rendering.
+
+### Changed - 2026-05-09
+- Bumped `ras-jsonrpc-macro` from `0.1.1` to `0.1.2`.
+- Bumped `ras-rest-macro` from `0.1.0` to `0.1.1`.
+
### Added - 2025-01-14
- Cat avatar system for bidirectional chat users
- Unique ASCII art cat avatars generated from username hashes
@@ -471,4 +486,4 @@ All notable changes to this project will be documented in this file.
- Created rust-jsonrpc-macro procedural macro crate foundation
- Added .gitignore for Rust and IDE artifacts
- Configured MCP integration with Context7 for enhanced documentation
-- Added CLAUDE.md for AI-assisted development guidance
\ No newline at end of file
+- Added CLAUDE.md for AI-assisted development guidance
diff --git a/Cargo.lock b/Cargo.lock
index bd7eaef..6d9f191 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3382,7 +3382,7 @@ dependencies = [
[[package]]
name = "ras-jsonrpc-macro"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
"async-trait",
"axum",
@@ -3458,7 +3458,7 @@ dependencies = [
[[package]]
name = "ras-rest-macro"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"async-trait",
"axum",
diff --git a/VERSIONING.md b/VERSIONING.md
new file mode 100644
index 0000000..6f657f3
--- /dev/null
+++ b/VERSIONING.md
@@ -0,0 +1,34 @@
+# Versioning and Changelog Policy
+
+This workspace uses independent crate versions. Bump only the publishable crates
+whose public API, generated output, runtime behavior, or documented behavior
+changed.
+
+## Versioning
+
+- Follow SemVer for crates at `1.0.0` and later.
+- While crates are `0.x`, use patch bumps for backward-compatible fixes,
+ additive behavior, docs, and tooling improvements.
+- While crates are `0.x`, use minor bumps for breaking public API changes or
+ generated-code contract changes.
+- Do not version-bump examples, fixtures, or crates marked `publish = false`
+ unless their version is meaningful to a downstream release process.
+- Keep `Cargo.lock` aligned with any package version changes.
+
+## Changelog
+
+- Every user-facing change must add an entry under `CHANGELOG.md` ->
+ `[Unreleased]`.
+- Group entries by `Added`, `Changed`, `Fixed`, `Documentation`, or
+ `Maintenance`.
+- Include the affected crate names when a change is scoped to specific crates.
+- Mention version bumps in the same changelog entry set.
+
+## Release Checklist
+
+1. Move relevant `[Unreleased]` entries into a dated release section.
+2. Confirm publishable crate versions match the release contents.
+3. Run the relevant crate tests and any affected e2e suites.
+4. Commit `Cargo.toml`, `Cargo.lock`, `CHANGELOG.md`, and release notes together.
+5. Tag releases using crate-aware tags when publishing one crate, for example
+ `ras-rest-macro-v0.1.1`.
diff --git a/crates/rest/ras-rest-macro/Cargo.toml b/crates/rest/ras-rest-macro/Cargo.toml
index 820c33c..ed4dd3a 100644
--- a/crates/rest/ras-rest-macro/Cargo.toml
+++ b/crates/rest/ras-rest-macro/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "ras-rest-macro"
-version = "0.1.0"
+version = "0.1.1"
edition = "2024"
description = "Procedural macro for type-safe REST APIs with auth integration and OpenAPI document generation"
license = "MIT OR Apache-2.0"
@@ -57,4 +57,4 @@ criterion = { workspace = true, features = ["async_tokio"] }
[[bench]]
name = "dispatch"
-harness = false
\ No newline at end of file
+harness = false
diff --git a/crates/rest/ras-rest-macro/src/api_explorer_template.html b/crates/rest/ras-rest-macro/src/api_explorer_template.html
index 6b19b82..16f68b8 100644
--- a/crates/rest/ras-rest-macro/src/api_explorer_template.html
+++ b/crates/rest/ras-rest-macro/src/api_explorer_template.html
@@ -248,7 +248,7 @@
font-size: 1.03rem;
overflow-wrap: anywhere;
}
- .titleline p {
+ .titleline .description {
margin: 0.25rem 0 0;
color: var(--muted);
}
@@ -274,6 +274,79 @@
color: var(--muted);
font-size: 0.82rem;
}
+ .schema-docs {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--panel-2);
+ padding: 0.75rem;
+ display: grid;
+ gap: 0.6rem;
+ }
+ .schema-head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ }
+ .schema-desc {
+ margin: 0;
+ color: var(--muted);
+ }
+ .schema-fields {
+ display: grid;
+ gap: 0.45rem;
+ }
+ .schema-field {
+ display: grid;
+ grid-template-columns: minmax(120px, 0.9fr) minmax(80px, 0.5fr) minmax(160px, 1.5fr);
+ gap: 0.55rem;
+ align-items: start;
+ padding-top: 0.45rem;
+ border-top: 1px solid var(--border);
+ }
+ .schema-field-desc {
+ color: var(--muted);
+ }
+ .markdown {
+ display: grid;
+ gap: 0.5rem;
+ }
+ .markdown p {
+ margin: 0;
+ white-space: pre-wrap;
+ }
+ .markdown ul {
+ margin: 0;
+ padding-left: 1.2rem;
+ display: grid;
+ gap: 0.25rem;
+ }
+ .markdown li {
+ padding-left: 0.15rem;
+ }
+ .markdown code {
+ background: var(--panel-3);
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ padding: 0.05rem 0.25rem;
+ font-size: 0.92em;
+ }
+ .markdown pre {
+ min-height: 0;
+ white-space: pre;
+ margin: 0;
+ }
+ .markdown pre code {
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+ padding: 0;
+ }
+ .markdown a {
+ color: var(--accent);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
.tabs {
display: flex;
gap: 0.45rem;
@@ -361,7 +434,7 @@
}
.list { max-height: 42vh; }
.main-scroll, .response-scroll { height: auto; }
- .grid2, .param-row { grid-template-columns: 1fr; }
+ .grid2, .param-row, .schema-field { grid-template-columns: 1fr; }
.topbar { align-items: flex-start; }
}
@@ -408,7 +481,7 @@
Select an operation
-
Choose an operation to prepare a request.
+
Choose an operation to prepare a request.
@@ -542,6 +615,155 @@
Response
return "object";
}
+ function schemaTitle(schema) {
+ const refName = schema?.$ref?.split("/").pop();
+ const resolved = resolveRef(schema);
+ return resolved?.title || refName || schemaType(schema);
+ }
+
+ function schemaFields(schema) {
+ const resolved = resolveRef(schema);
+ const properties = resolved?.properties || {};
+ const required = new Set(resolved?.required || []);
+ return Object.entries(properties).map(([name, prop]) => {
+ const propSchema = resolveRef(prop);
+ return {
+ name,
+ required: required.has(name),
+ type: schemaType(prop),
+ description: propSchema?.description || ""
+ };
+ });
+ }
+
+ function schemaHasDocs(schema) {
+ const resolved = resolveRef(schema);
+ if (!resolved) return false;
+ return Boolean(resolved.description || schemaFields(schema).some((field) => field.description));
+ }
+
+ function appendInlineMarkdown(parent, text) {
+ let index = 0;
+ const source = String(text || "");
+
+ while (index < source.length) {
+ if (source.startsWith("**", index)) {
+ const end = source.indexOf("**", index + 2);
+ if (end > index + 2) {
+ const strong = document.createElement("strong");
+ appendInlineMarkdown(strong, source.slice(index + 2, end));
+ parent.appendChild(strong);
+ index = end + 2;
+ continue;
+ }
+ }
+
+ if (source[index] === "`") {
+ const end = source.indexOf("`", index + 1);
+ if (end > index + 1) {
+ const code = document.createElement("code");
+ code.textContent = source.slice(index + 1, end);
+ parent.appendChild(code);
+ index = end + 1;
+ continue;
+ }
+ }
+
+ if (source[index] === "[") {
+ const labelEnd = source.indexOf("]", index + 1);
+ const urlStart = labelEnd + 1;
+ if (labelEnd > index + 1 && source[urlStart] === "(") {
+ const urlEnd = source.indexOf(")", urlStart + 1);
+ const href = source.slice(urlStart + 1, urlEnd);
+ if (urlEnd > urlStart + 1 && isSafeMarkdownUrl(href)) {
+ const link = document.createElement("a");
+ link.href = href;
+ link.target = "_blank";
+ link.rel = "noreferrer noopener";
+ appendInlineMarkdown(link, source.slice(index + 1, labelEnd));
+ parent.appendChild(link);
+ index = urlEnd + 1;
+ continue;
+ }
+ }
+ }
+
+ const next = ["**", "`", "["]
+ .map((token) => source.indexOf(token, index + 1))
+ .filter((position) => position !== -1)
+ .sort((a, b) => a - b)[0] ?? source.length;
+ parent.appendChild(document.createTextNode(source.slice(index, next)));
+ index = next;
+ }
+ }
+
+ function isSafeMarkdownUrl(href) {
+ try {
+ const url = new URL(href, window.location.href);
+ return url.protocol === "http:" || url.protocol === "https:";
+ } catch (_) {
+ return false;
+ }
+ }
+
+ function renderMarkdownInto(container, text) {
+ container.textContent = "";
+ container.classList.add("markdown");
+
+ const lines = String(text || "").replace(/\r\n?/g, "\n").split("\n");
+ let index = 0;
+
+ while (index < lines.length) {
+ if (!lines[index].trim()) {
+ index += 1;
+ continue;
+ }
+
+ if (lines[index].trimStart().startsWith("```")) {
+ const codeLines = [];
+ index += 1;
+ while (index < lines.length && !lines[index].trimStart().startsWith("```")) {
+ codeLines.push(lines[index]);
+ index += 1;
+ }
+ if (index < lines.length) index += 1;
+
+ const pre = document.createElement("pre");
+ const code = document.createElement("code");
+ code.textContent = codeLines.join("\n");
+ pre.appendChild(code);
+ container.appendChild(pre);
+ continue;
+ }
+
+ if (/^\s*-\s+/.test(lines[index])) {
+ const list = document.createElement("ul");
+ while (index < lines.length && /^\s*-\s+/.test(lines[index])) {
+ const item = document.createElement("li");
+ appendInlineMarkdown(item, lines[index].replace(/^\s*-\s+/, ""));
+ list.appendChild(item);
+ index += 1;
+ }
+ container.appendChild(list);
+ continue;
+ }
+
+ const paragraphLines = [];
+ while (
+ index < lines.length
+ && lines[index].trim()
+ && !lines[index].trimStart().startsWith("```")
+ && !/^\s*-\s+/.test(lines[index])
+ ) {
+ paragraphLines.push(lines[index]);
+ index += 1;
+ }
+ const paragraph = document.createElement("p");
+ appendInlineMarkdown(paragraph, paragraphLines.join("\n"));
+ container.appendChild(paragraph);
+ }
+ }
+
function exampleFromSchema(schema, seen = new Set()) {
const resolved = resolveRef(schema);
if (!resolved) return {};
@@ -573,6 +795,57 @@ Response
return {};
}
+ function renderSchemaDocs(title, schema) {
+ if (!schemaHasDocs(schema)) return null;
+
+ const resolved = resolveRef(schema);
+ const docs = document.createElement("div");
+ docs.className = "schema-docs";
+ const section = document.createElement("div");
+ section.className = "section-title";
+ section.textContent = title;
+ const head = document.createElement("div");
+ head.className = "schema-head";
+ const name = document.createElement("strong");
+ name.textContent = schemaTitle(schema);
+ const type = document.createElement("span");
+ type.className = "badge";
+ type.textContent = schemaType(schema);
+ head.append(name, type);
+ docs.append(section, head);
+
+ if (resolved?.description) {
+ const description = document.createElement("div");
+ description.className = "schema-desc";
+ renderMarkdownInto(description, resolved.description);
+ docs.appendChild(description);
+ }
+
+ const fields = schemaFields(schema);
+ if (fields.length) {
+ const rows = document.createElement("div");
+ rows.className = "schema-fields";
+ fields.forEach((field) => {
+ const row = document.createElement("div");
+ row.className = "schema-field";
+ const fieldName = document.createElement("div");
+ fieldName.className = "mono";
+ fieldName.textContent = `${field.name}${field.required ? " *" : ""}`;
+ const fieldType = document.createElement("span");
+ fieldType.className = "badge";
+ fieldType.textContent = field.type;
+ const fieldDescription = document.createElement("div");
+ fieldDescription.className = "schema-field-desc";
+ renderMarkdownInto(fieldDescription, field.description || "");
+ row.append(fieldName, fieldType, fieldDescription);
+ rows.appendChild(row);
+ });
+ docs.appendChild(rows);
+ }
+
+ return docs;
+ }
+
function jsonPretty(value) {
if (typeof value === "string") return value;
return JSON.stringify(value, null, 2);
@@ -843,7 +1116,11 @@ Response
});
if (operation.requestSchema) {
fragment.appendChild(editorBlock("JSON body", "body-editor", jsonPretty(exampleFromSchema(operation.requestSchema))));
+ const docs = renderSchemaDocs("Request schema", operation.requestSchema);
+ if (docs) fragment.appendChild(docs);
}
+ const responseDocs = renderSchemaDocs("Response schema", operation.responseSchema);
+ if (responseDocs) fragment.appendChild(responseDocs);
return fragment;
}
@@ -879,12 +1156,16 @@ Response
fragment.appendChild(grid);
if (operation.paramsSchema) {
fragment.appendChild(editorBlock("Params", "params-editor", jsonPretty(exampleFromSchema(operation.paramsSchema))));
+ const docs = renderSchemaDocs("Params schema", operation.paramsSchema);
+ if (docs) fragment.appendChild(docs);
} else {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = "This method has no params.";
fragment.appendChild(empty);
}
+ const responseDocs = renderSchemaDocs("Result schema", operation.responseSchema);
+ if (responseDocs) fragment.appendChild(responseDocs);
return fragment;
}
@@ -935,7 +1216,10 @@ Response
state.selectedId = id;
const operation = activeOperation();
$("operation-title").textContent = operation ? `${operation.method} ${operation.label}` : "Select an operation";
- $("operation-description").textContent = operation?.description || operation?.summary || "Prepare and send a request.";
+ renderMarkdownInto(
+ $("operation-description"),
+ operation?.description || operation?.summary || "Prepare and send a request."
+ );
renderRequestForm();
renderSaved();
if (rerenderList) renderOperations();
diff --git a/crates/rest/ras-rest-macro/src/client.rs b/crates/rest/ras-rest-macro/src/client.rs
index bd2f3cd..cccdb43 100644
--- a/crates/rest/ras-rest-macro/src/client.rs
+++ b/crates/rest/ras-rest-macro/src/client.rs
@@ -6,11 +6,12 @@ use syn::Type;
/// segment as well as fully-qualified forms like `std::option::Option` /
/// `core::option::Option` — anything whose last path segment is `Option`.
fn is_option_type(ty: &Type) -> bool {
- if let Type::Path(type_path) = ty {
- if let Some(last) = type_path.path.segments.last() {
- return last.ident == "Option";
- }
+ if let Type::Path(type_path) = ty
+ && let Some(last) = type_path.path.segments.last()
+ {
+ return last.ident == "Option";
}
+
false
}
diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs
index d7b97eb..1e03081 100644
--- a/crates/rest/ras-rest-macro/src/lib.rs
+++ b/crates/rest/ras-rest-macro/src/lib.rs
@@ -87,6 +87,7 @@ enum OpenApiConfig {
#[derive(Debug)]
struct EndpointDefinition {
+ docs: Option,
method: HttpMethod,
auth: AuthRequirement,
path: String,
@@ -97,6 +98,29 @@ struct EndpointDefinition {
handler_name: Ident,
}
+#[derive(Debug)]
+struct DocComment {
+ summary: String,
+ description: String,
+}
+
+impl DocComment {
+ fn from_lines(lines: Vec) -> Option {
+ let lines: Vec = lines
+ .into_iter()
+ .map(|line| line.trim().to_string())
+ .collect();
+ let start = lines.iter().position(|line| !line.is_empty())?;
+ let end = lines.iter().rposition(|line| !line.is_empty())?;
+ let lines = &lines[start..=end];
+
+ Some(Self {
+ summary: lines[0].clone(),
+ description: lines.join("\n"),
+ })
+ }
+}
+
#[derive(Debug)]
enum HttpMethod {
Get,
@@ -146,6 +170,38 @@ enum AuthRequirement {
WithPermissions(Vec>), // Vec of permission groups - OR between groups, AND within groups
}
+const DOC_COMMENT_EXPECTED: &str = "Expected doc comment in the form `/// ...`";
+
+fn parse_doc_comment_attrs(
+ attrs: Vec,
+ entry_kind: &str,
+) -> syn::Result