fix: better styling for docs

This commit is contained in:
2026-05-04 07:01:43 +02:00
parent cb46b45cda
commit 6b6c985187
7 changed files with 3602 additions and 1107 deletions

View File

@@ -20,6 +20,7 @@ This document provides context and best practices for GitHub Copilot when workin
- you must use ecto to generate migrations and snapshots - you must use ecto to generate migrations and snapshots
- on MacOS we use native menus and you have to hook them into the intercept for new menu items - on MacOS we use native menus and you have to hook them into the intercept for new menu items
- there are two areas of localization, you sometimes need both (menus for example) - there are two areas of localization, you sometimes need both (menus for example)
- localization is done with elixier gettext and you need mix gettext.extract to update translation files
- all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast - all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast
- metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other. - metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other.
- if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database - if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database

4275
API.md

File diff suppressed because it is too large Load Diff

View File

@@ -26,14 +26,26 @@
<div class="misc-editor-content"> <div class="misc-editor-content">
<%= case @misc_editor.kind do %> <%= case @misc_editor.kind do %>
<% :documentation -> %> <% :documentation -> %>
<article class="misc-card help-doc-markdown" data-testid="help-documentation"> <div class="documentation-view">
<%= markdown_html(@misc_editor.markdown) %> <main class="documentation-scroll">
</article> <div class="documentation-content markdown-body">
<article class="documentation-article help-doc-markdown" data-testid="help-documentation">
<%= markdown_html(@misc_editor.markdown) %>
</article>
</div>
</main>
</div>
<% :api_documentation -> %> <% :api_documentation -> %>
<article class="misc-card help-doc-markdown" data-testid="help-api-documentation"> <div class="documentation-view">
<%= markdown_html(@misc_editor.markdown) %> <main class="documentation-scroll">
</article> <div class="documentation-content markdown-body">
<article class="documentation-article help-doc-markdown" data-testid="help-api-documentation">
<%= markdown_html(@misc_editor.markdown) %>
</article>
</div>
</main>
</div>
<% :site_validation -> %> <% :site_validation -> %>
<div class="misc-columns"> <div class="misc-columns">

View File

@@ -1236,21 +1236,25 @@ defmodule BDS.Scripting.ApiDocs do
end end
defp table_of_contents do defp table_of_contents do
@methods module_names()
|> Enum.map(& &1.module)
|> Enum.uniq()
|> Enum.map(fn module_name -> "- [#{module_name}](##{module_name})" end) |> Enum.map(fn module_name -> "- [#{module_name}](##{module_name})" end)
|> Kernel.++(["- [Data Structures](#data-structures)"]) |> Kernel.++(["- [Data Structures](#data-structures)"])
end end
defp render_modules do defp render_modules do
@methods module_names()
|> Enum.group_by(& &1.module) |> Enum.flat_map(fn module_name ->
|> Enum.flat_map(fn {module_name, methods} -> methods = module_methods(module_name)
[ [
"## #{module_name}", "## #{module_name}",
"", "",
"**Module APIs**",
"",
Enum.map(methods, fn method -> "- [#{method.module}.#{method.name}](##{method.module}#{method.name})" end),
"",
Enum.map(methods, &render_method/1), Enum.map(methods, &render_method/1),
"[↑ Back to Table of contents](#table-of-contents)",
"" ""
] ]
end) end)
@@ -1269,6 +1273,14 @@ defmodule BDS.Scripting.ApiDocs do
"**Response specification**", "**Response specification**",
"", "",
"- Return type: `#{method.returns}`", "- Return type: `#{method.returns}`",
render_nullability(method.returns),
render_data_structure_references(method.returns),
"",
"**Example response**",
"",
"```lua",
render_example_response(method.returns),
"```",
"", "",
"**Example call**", "**Example call**",
"", "",
@@ -1277,6 +1289,7 @@ defmodule BDS.Scripting.ApiDocs do
"```", "```",
"" ""
] ]
|> Enum.reject(&is_nil/1)
end end
defp render_params([]), do: ["- None"] defp render_params([]), do: ["- None"]
@@ -1289,18 +1302,191 @@ defmodule BDS.Scripting.ApiDocs do
end end
defp example_call(method) do defp example_call(method) do
args = args = Enum.map_join(method.params, ", ", &example_argument/1)
method.params
|> Enum.map(fn param -> example_value(param.type) end)
|> Enum.join(", ")
"local result = bds.#{method.module}.#{method.name}(#{args})" "local result = bds.#{method.module}.#{method.name}(#{args})"
end end
defp example_value("string"), do: "\"value\"" defp module_names do
defp example_value("table"), do: "{}" @methods
defp example_value("integer"), do: "1" |> Enum.map(& &1.module)
defp example_value(_type), do: "nil" |> Enum.uniq()
end
defp module_methods(module_name) do
Enum.filter(@methods, &(&1.module == module_name))
end
defp render_nullability(returns) do
if nullable_return?(returns) do
"- Nullability: Returns `nil` when no matching value exists or the operation cannot produce a value."
end
end
defp render_data_structure_references(returns) do
case response_structure_names(returns) do
[] -> nil
names -> "- Data structures: `#{Enum.join(names, "`, `")}`"
end
end
defp render_example_response(returns) do
returns
|> example_response_value()
|> render_lua_value(0)
end
defp example_argument(%{name: name, type: type}) do
example_argument_value(name, type)
end
defp example_argument_value(name, "string") do
case name do
"id" -> "\"id-1\""
suffix when suffix in ["post_id", "media_id", "project_id", "tag_id", "target_tag_id"] -> "\"id-1\""
"source_tag_ids" -> "{\"id-1\", \"id-2\"}"
"language" -> "\"en\""
"status" -> "\"draft\""
"kind" -> "\"post\""
"slug" -> "\"example-slug\""
"title" -> "\"Example Title\""
"name" -> "\"Example Name\""
"query" -> "\"example query\""
"content" -> "\"Example content\""
"message" -> "\"Update content\""
"folder_path" -> "\"/Users/me/Sites/example\""
"source_path" -> "\"/Users/me/Pictures/example.jpg\""
"item_path" -> "\"/Users/me/Sites/example/output/index.html\""
"action" -> "\"save\""
_ -> "\"value\""
end
end
defp example_argument_value("limit", "integer"), do: "10"
defp example_argument_value(_name, "integer"), do: "1"
defp example_argument_value(_name, "number"), do: "1.0"
defp example_argument_value(name, "table") do
case name do
"data" -> "{title = \"Example Title\"}"
"filters" -> "{status = \"draft\"}"
"options" -> "{}"
"updates" -> "{name = \"Updated Blog\"}"
"prefs" -> "{provider = \"filesystem\"}"
"credentials" -> "{provider = \"sftp\"}"
"target_ids" -> "{\"id-2\", \"id-3\"}"
"exclude_tags" -> "{\"draft\"}"
_ -> "{}"
end
end
defp example_argument_value(_name, _type), do: "nil"
defp nullable_return?(returns), do: String.contains?(returns, "nil")
defp response_structure_names(returns) do
structure_names = MapSet.new(Enum.map(@data_structures, & &1.name))
returns
|> String.split(~r/\s*\|\s*/)
|> Enum.map(&String.replace(&1, "[]", ""))
|> Enum.reject(&(&1 in ["nil", "boolean", "string", "integer", "number", "table"]))
|> Enum.filter(&MapSet.member?(structure_names, &1))
|> Enum.uniq()
end
defp example_response_value(returns) do
cond do
returns == "nil" -> nil
nullable_return?(returns) -> {:nullable, example_response_value(non_nil_return(returns))}
String.ends_with?(returns, "[]") -> [example_value_for_type(String.trim_trailing(returns, "[]"))]
true -> example_value_for_type(returns)
end
end
defp non_nil_return(returns) do
returns
|> String.split(~r/\s*\|\s*/)
|> Enum.reject(&(&1 == "nil"))
|> List.first()
end
defp example_value_for_type("boolean"), do: true
defp example_value_for_type("string"), do: "value"
defp example_value_for_type("integer"), do: 1
defp example_value_for_type("number"), do: 1.0
defp example_value_for_type("nil"), do: nil
defp example_value_for_type("table"), do: [{"key", "value"}]
defp example_value_for_type(type) do
case Enum.find(@data_structures, &(&1.name == type)) do
nil -> [{"key", "value"}]
structure -> Enum.map(structure.fields, fn field -> {field.name, example_field_value(field.type)} end)
end
end
defp example_field_value(type) do
cond do
String.contains?(type, " | nil") -> nil
String.ends_with?(type, "[]") -> [example_value_for_type(String.trim_trailing(type, "[]"))]
true -> example_value_for_type(type)
end
end
defp render_lua_value({:nullable, value}, indent) do
["nil -- or", render_lua_value(value, indent)]
|> Enum.join("\n")
end
defp render_lua_value(true, _indent), do: "true"
defp render_lua_value(false, _indent), do: "false"
defp render_lua_value(nil, _indent), do: "nil"
defp render_lua_value(value, _indent) when is_integer(value), do: Integer.to_string(value)
defp render_lua_value(value, _indent) when is_float(value), do: :erlang.float_to_binary(value, [:compact])
defp render_lua_value(value, _indent) when is_binary(value), do: inspect(value)
defp render_lua_value([], _indent), do: "{}"
defp render_lua_value(list, indent) when is_list(list) do
if keyword_like_list?(list) do
render_lua_table(list, indent)
else
render_lua_array(list, indent)
end
end
defp keyword_like_list?(list) do
Enum.all?(list, fn
{key, _value} when is_binary(key) -> true
_ -> false
end)
end
defp render_lua_table(entries, indent) do
outer_indent = indent_spaces(indent)
inner_indent = indent_spaces(indent + 2)
rendered_entries =
Enum.map_join(entries, ",\n", fn {key, value} ->
"#{inner_indent}#{key} = #{render_lua_value(value, indent + 2)}"
end)
"{\n#{rendered_entries}\n#{outer_indent}}"
end
defp render_lua_array(values, indent) do
outer_indent = indent_spaces(indent)
inner_indent = indent_spaces(indent + 2)
rendered_values =
Enum.map_join(values, ",\n", fn value ->
"#{inner_indent}#{render_lua_value(value, indent + 2)}"
end)
"{\n#{rendered_values}\n#{outer_indent}}"
end
defp indent_spaces(indent), do: String.duplicate(" ", indent)
defp render_data_structures do defp render_data_structures do
Enum.flat_map(@data_structures, fn structure -> Enum.flat_map(@data_structures, fn structure ->

View File

@@ -4457,6 +4457,189 @@ button svg * {
flex: 1; flex: 1;
} }
.help-doc-view {
--doc-bg: var(--panel-1, #1e1e1e);
--doc-surface: var(--panel-2, #252526);
--doc-border: var(--line, #3c3c3c);
--doc-text: var(--vscode-editor-foreground, #d4d4d4);
--doc-muted: var(--vscode-descriptionForeground, #9da3ad);
--doc-link: var(--vscode-textLink-foreground, #9cdcfe);
--doc-code-bg: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.2));
--doc-hover: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06));
}
.help-doc-view .misc-editor-content {
padding: 0;
overflow: hidden;
}
.documentation-view {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--doc-bg);
}
.documentation-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 28px 24px 40px;
background: var(--doc-bg);
}
.documentation-content {
max-width: 920px;
margin: 0 auto;
color: var(--doc-text);
}
.documentation-article {
background: var(--doc-surface);
padding: 18px 20px 24px;
border: 1px solid var(--doc-border);
border-radius: 10px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
}
.documentation-content.markdown-body > .documentation-article > :first-child {
margin-top: 0;
}
.documentation-content.markdown-body > .documentation-article > :last-child {
margin-bottom: 0;
}
.documentation-content.markdown-body h1,
.documentation-content.markdown-body h2,
.documentation-content.markdown-body h3 {
color: var(--doc-text);
border-bottom: 1px solid var(--doc-border);
padding-bottom: 6px;
line-height: 1.25;
}
.documentation-content.markdown-body h1 {
font-size: 1.9rem;
}
.documentation-content.markdown-body h2 {
margin-top: 2rem;
font-size: 1.35rem;
}
.documentation-content.markdown-body h3 {
margin-top: 1.6rem;
font-size: 1.05rem;
}
.documentation-content.markdown-body p,
.documentation-content.markdown-body li,
.documentation-content.markdown-body td,
.documentation-content.markdown-body th {
line-height: 1.6;
}
.documentation-content.markdown-body a {
color: var(--doc-link);
text-decoration-thickness: 1px;
text-underline-offset: 0.14em;
}
.documentation-content.markdown-body a:hover {
color: var(--doc-text);
}
.documentation-content.markdown-body hr {
border: 0;
border-top: 1px solid var(--doc-border);
opacity: 0.8;
}
.documentation-content.markdown-body code {
background: var(--doc-code-bg);
padding: 0.12em 0.4em;
border-radius: 4px;
font: 0.92em/1.45 "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
}
.documentation-content.markdown-body pre {
margin: 0.9rem 0 1.2rem;
background: var(--doc-code-bg);
border: 1px solid var(--doc-border);
border-radius: 8px;
padding: 14px 16px;
overflow: auto;
}
.documentation-content.markdown-body pre code {
padding: 0;
background: transparent;
font-size: 0.9em;
}
.documentation-content.markdown-body blockquote {
margin: 1rem 0;
padding: 0 0 0 12px;
border-left: 3px solid var(--doc-border);
color: var(--doc-muted);
}
.documentation-content.markdown-body table {
width: 100%;
margin: 1rem 0 1.4rem;
border-collapse: collapse;
display: table;
}
.documentation-content.markdown-body th,
.documentation-content.markdown-body td {
border: 1px solid var(--doc-border);
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.documentation-content.markdown-body th {
background: var(--doc-hover);
font-weight: 700;
}
.documentation-content.markdown-body ul,
.documentation-content.markdown-body ol {
margin: 0.85rem 0 1rem;
padding-left: 1.5rem;
display: block;
}
.documentation-content.markdown-body ul {
list-style: disc;
}
.documentation-content.markdown-body ol {
list-style: decimal;
}
.documentation-content.markdown-body li {
margin: 0.3rem 0;
}
.documentation-content.markdown-body li > ul,
.documentation-content.markdown-body li > ol {
margin-top: 0.35rem;
margin-bottom: 0.35rem;
}
.documentation-content.markdown-body strong {
color: var(--doc-text);
}
.documentation-content.markdown-body img {
max-width: 100%;
height: auto;
}
.misc-columns { .misc-columns {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));

View File

@@ -1047,6 +1047,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert has_element?(view, ".tab[data-tab-type='documentation'] .tab-title", "Documentation") assert has_element?(view, ".tab[data-tab-type='documentation'] .tab-title", "Documentation")
assert has_element?(view, "[data-testid='help-documentation']") assert has_element?(view, "[data-testid='help-documentation']")
assert has_element?(view, ".documentation-content.markdown-body .documentation-article")
assert render(view) =~ "bDS2 User Guide" assert render(view) =~ "bDS2 User Guide"
end end
@@ -1067,6 +1068,7 @@ defmodule BDS.Desktop.ShellLiveTest do
) )
assert has_element?(view, "[data-testid='help-api-documentation']") assert has_element?(view, "[data-testid='help-api-documentation']")
assert has_element?(view, ".documentation-content.markdown-body .documentation-article")
assert render(view) =~ "API Documentation" assert render(view) =~ "API Documentation"
assert render(view) =~ "local result = bds.posts.get" assert render(view) =~ "local result = bds.posts.get"
end end

View File

@@ -1,6 +1,16 @@
defmodule BDS.Scripting.ApiDocumentationTest do defmodule BDS.Scripting.ApiDocumentationTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
test "rendered API docs include richer module indexes and example responses" do
rendered = BDS.Scripting.ApiDocs.render()
assert rendered =~ "**Module APIs**"
assert rendered =~ "- [projects.create](#projectscreate)"
assert rendered =~ "**Example response**"
assert rendered =~ "- Data structures: `ProjectData`"
assert rendered =~ "[↑ Back to Table of contents](#table-of-contents)"
end
test "API.md matches the generated Lua scripting contract" do test "API.md matches the generated Lua scripting contract" do
api_doc_path = Path.expand("../../../API.md", __DIR__) api_doc_path = Path.expand("../../../API.md", __DIR__)